import 'dart:convert'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../notification/notification_service.dart'; import '../notification/notification_tasks.dart'; import 'nid_store.dart'; import 'push_actions.dart'; import 'push_subject.dart'; /// Renders decrypted push subjects (and plaintext Connect pushes) as local /// notifications. Talk messages get a [MessagingStyleInformation] with inline /// reply + mark-as-read actions and a per-chat tag; everything else renders in /// a generic channel. class PushRenderer { static const talkChannelId = 'talk_messages'; static const talkChannelName = 'Talk-Nachrichten'; static const generalChannelId = 'nextcloud_general'; static const generalChannelName = 'Benachrichtigungen'; static const String iosTalkCategory = 'TALK_MESSAGE'; final NidStore _nidStore; PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore(); FlutterLocalNotificationsPlugin get _plugin => NotificationService().flutterLocalNotificationsPlugin; /// Creates the Android notification channels. Safe to call repeatedly. static Future ensureChannels() async { final android = NotificationService().flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); if (android == null) return; await android.createNotificationChannel( const AndroidNotificationChannel( talkChannelId, talkChannelName, description: 'Neue Nachrichten aus Nextcloud Talk', importance: Importance.high, ), ); await android.createNotificationChannel( const AndroidNotificationChannel( generalChannelId, generalChannelName, description: 'Allgemeine Benachrichtigungen', ), ); } /// Renders a decrypted Nextcloud push subject. Future render(PushSubject subject) async { if (subject.isTalk) { await _renderTalk(subject); } else { await _renderGeneric(subject); } } Future _renderTalk(PushSubject subject) async { final nid = subject.nid ?? _fallbackId(subject.id); final chatToken = subject.id; final tag = chatToken != null ? NotificationTasks.chatTag(chatToken) : 'talk_$nid'; final text = subject.subject ?? 'Neue Nachricht'; final (senderName, messageText) = _splitSender(text); final payload = _payload(chatToken: chatToken, nid: nid); final messagingStyle = MessagingStyleInformation( const Person(key: 'self', name: 'Ich'), conversationTitle: senderName, groupConversation: false, messages: [ Message(messageText, DateTime.now(), Person(name: senderName)), ], ); final androidDetails = AndroidNotificationDetails( talkChannelId, talkChannelName, importance: Importance.high, priority: Priority.high, category: AndroidNotificationCategory.message, tag: tag, styleInformation: messagingStyle, actions: const [ AndroidNotificationAction( kTalkReplyActionId, 'Antworten', showsUserInterface: false, cancelNotification: false, inputs: [AndroidNotificationActionInput(label: 'Nachricht')], ), AndroidNotificationAction( kTalkMarkReadActionId, 'Gelesen', showsUserInterface: false, ), ], ); final iosDetails = DarwinNotificationDetails( threadIdentifier: tag, categoryIdentifier: iosTalkCategory, ); await _nidStore.put( NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken), ); await _plugin.show( id: nid, title: senderName, body: messageText, notificationDetails: NotificationDetails( android: androidDetails, iOS: iosDetails, ), payload: payload, ); } Future _renderGeneric(PushSubject subject) async { final nid = subject.nid ?? _fallbackId(subject.subject); const androidDetails = AndroidNotificationDetails( generalChannelId, generalChannelName, ); const iosDetails = DarwinNotificationDetails(); await _nidStore.put( NidEntry(nid: nid, notificationId: nid, tag: 'nc_$nid'), ); await _plugin.show( id: nid, title: subject.subject ?? 'Neue Benachrichtigung', body: null, notificationDetails: const NotificationDetails( android: androidDetails, iOS: iosDetails, ), payload: _payload(chatToken: null, nid: nid), ); } /// Renders a plaintext MarianumConnect direct push (Android only — iOS shows /// the native alert itself). Future renderConnect({ required String title, required String body, Map? data, }) async { final id = _fallbackId('$title$body'); const androidDetails = AndroidNotificationDetails( generalChannelId, generalChannelName, importance: Importance.high, priority: Priority.high, ); await _plugin.show( id: id, title: title, body: body, notificationDetails: const NotificationDetails(android: androidDetails), payload: data == null ? null : jsonEncode(data), ); } String _payload({required String? chatToken, required int nid}) => jsonEncode({'chatToken': ?chatToken, 'nid': nid}); /// Splits a `"Sender: message"` subject into its parts, falling back to a /// generic sender label when there's no delimiter. (String, String) _splitSender(String subject) { final idx = subject.indexOf(': '); if (idx > 0 && idx < subject.length - 2) { return (subject.substring(0, idx), subject.substring(idx + 2)); } return ('Talk', subject); } /// Deterministic non-negative 31-bit id from a string, used when the push /// carries no `nid`. int _fallbackId(String? seed) { if (seed == null || seed.isEmpty) return 0; var hash = 0; for (final unit in seed.codeUnits) { hash = (hash * 31 + unit) & 0x7fffffff; } return hash; } }