From 9ca54e6a8254b30e8e2037d7b0bb82eb41d36217 Mon Sep 17 00:00:00 2001 From: Sven Date: Wed, 22 Apr 2026 18:29:51 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20#15:=20Push-Nachrichten=20=E2=80=93=20war?= =?UTF-8?q?mer=20Ton=20+=20Pers=C3=B6nlichkeitsanpassung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PersonalityEngine: callWindowCopy() + aftermathCopy() zentralisieren alle Notification-Texte (bisher verstreut und teils unlokalisiert) - CallWindowManager: 3 Varianten nach Profil (high E / high N / default), String(localized:) + Error-Logging ergänzt - AftermathNotificationManager: Titel "Nachwirkung: %@" → "Wie war's mit %@?", Body via PersonalityEngine.aftermathCopy() - Todo- und Vorhaben-Erinnerungen: subtitle "Dein Vorhaben" für persönlicheren Kontext (AddTodoView, EditTodoView, AddMomentView) - AddMomentView: Logging + Error-Callback nachgezogen (wie AddTodoView) - 9 neue Tests in NahbarPersonalityTests (NotificationCopyTests) - Lokalisierung: 4 neue Strings inkl. bisher unlokalisierter Texte Co-Authored-By: Claude Sonnet 4.6 --- nahbar/AftermathNotificationManager.swift | 13 +-- nahbar/nahbar/AddMomentView.swift | 23 +++++- nahbar/nahbar/AddTodoView.swift | 1 + nahbar/nahbar/CallWindowManager.swift | 31 +++++--- nahbar/nahbar/Localizable.xcstrings | 47 ++++++++++- nahbar/nahbar/PersonDetailView.swift | 1 + nahbar/nahbar/PersonalityEngine.swift | 37 +++++++++ .../nahbarTests/NahbarPersonalityTests.swift | 79 +++++++++++++++++++ 8 files changed, 207 insertions(+), 25 deletions(-) diff --git a/nahbar/AftermathNotificationManager.swift b/nahbar/AftermathNotificationManager.swift index d8d903e..1adba6f 100644 --- a/nahbar/AftermathNotificationManager.swift +++ b/nahbar/AftermathNotificationManager.swift @@ -58,15 +58,10 @@ final class AftermathNotificationManager { private func createNotification(momentID: UUID, personName: String, delay: TimeInterval) { let content = UNMutableNotificationContent() - content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName) - // Persönlichkeitsgerechter Body-Text (softer für hohen Neurotizismus) - let defaultBody = String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute.") - if let profile = PersonalityStore.shared.profile, - case .delayed(_, let softerCopy?) = PersonalityEngine.ratingPromptTiming(for: profile) { - content.body = softerCopy - } else { - content.body = defaultBody - } + // Wärmerer, gesprächigerer Titel statt klinischem "Nachwirkung: [Name]" + content.title = String.localizedStringWithFormat(String(localized: "Wie war's mit %@?"), personName) + // Persönlichkeitsgerechter Body-Text via PersonalityEngine + content.body = PersonalityEngine.aftermathCopy(profile: PersonalityStore.shared.profile) content.sound = .default content.categoryIdentifier = Self.categoryID content.userInfo = [ diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift index 7ecd605..67fa27a 100644 --- a/nahbar/nahbar/AddMomentView.swift +++ b/nahbar/nahbar/AddMomentView.swift @@ -2,6 +2,9 @@ import SwiftUI import SwiftData import EventKit import UserNotifications +import OSLog + +private let intentionNotificationLogger = Logger(subsystem: "nahbar", category: "IntentionNotification") struct AddMomentView: View { @Environment(\.nahbarTheme) var theme @@ -419,13 +422,21 @@ struct AddMomentView: View { private func scheduleIntentionReminder(for moment: Moment) { let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - guard granted else { return } + center.requestAuthorization(options: [.alert, .sound]) { granted, error in + if let error { + intentionNotificationLogger.error("Berechtigung-Fehler: \(error.localizedDescription)") + } + guard granted else { + intentionNotificationLogger.warning("Notification-Berechtigung abgelehnt – keine Vorhaben-Erinnerung.") + return + } let content = UNMutableNotificationContent() content.title = person.firstName + content.subtitle = String(localized: "Dein Vorhaben") content.body = moment.text content.sound = .default + content.userInfo = ["momentID": moment.id.uuidString] let components = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute], from: reminderDate @@ -436,7 +447,13 @@ struct AddMomentView: View { content: content, trigger: trigger ) - center.add(request) + center.add(request) { error in + if let error { + intentionNotificationLogger.error("Vorhaben-Erinnerung konnte nicht geplant werden: \(error.localizedDescription)") + } else { + intentionNotificationLogger.info("Vorhaben-Erinnerung geplant: \(moment.id.uuidString)") + } + } } } diff --git a/nahbar/nahbar/AddTodoView.swift b/nahbar/nahbar/AddTodoView.swift index 7d281da..f60fc02 100644 --- a/nahbar/nahbar/AddTodoView.swift +++ b/nahbar/nahbar/AddTodoView.swift @@ -181,6 +181,7 @@ struct AddTodoView: View { } let content = UNMutableNotificationContent() content.title = person.firstName + content.subtitle = String(localized: "Dein Vorhaben") content.body = todo.title content.sound = .default content.userInfo = ["todoID": todo.id.uuidString] diff --git a/nahbar/nahbar/CallWindowManager.swift b/nahbar/nahbar/CallWindowManager.swift index a9071f1..67b6160 100644 --- a/nahbar/nahbar/CallWindowManager.swift +++ b/nahbar/nahbar/CallWindowManager.swift @@ -1,6 +1,9 @@ import Foundation import Combine import UserNotifications +import OSLog + +private let logger = Logger(subsystem: "nahbar", category: "CallWindowNotification") class CallWindowManager: ObservableObject { static let shared = CallWindowManager() @@ -99,18 +102,20 @@ class CallWindowManager: ObservableObject { cancelNotifications() guard isEnabled else { return } - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in - guard granted else { return } + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in + if let error { + logger.error("Berechtigung-Fehler: \(error.localizedDescription)") + } + guard granted else { + logger.warning("Notification-Berechtigung abgelehnt – keine Gesprächsfenster-Erinnerung.") + return + } + // Persönlichkeitsgerechter Body-Text via PersonalityEngine + let body = PersonalityEngine.callWindowCopy(profile: PersonalityStore.shared.profile) for weekday in self.selectedWeekdays { let content = UNMutableNotificationContent() - content.title = "Gesprächszeit" - // Persönlichkeitsgerechter Body-Text (wärmer bei hohem Neurotizismus) - let profile = PersonalityStore.shared.profile - if let profile, profile.level(for: .neuroticism) == .high { - content.body = "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂" - } else { - content.body = "Wer freut sich heute von dir zu hören?" - } + content.title = String(localized: "Gesprächszeit") + content.body = body content.sound = .default content.categoryIdentifier = "CALL_WINDOW" @@ -124,7 +129,11 @@ class CallWindowManager: ObservableObject { content: content, trigger: UNCalendarNotificationTrigger(dateMatching: dc, repeats: true) ) - UNUserNotificationCenter.current().add(request) + UNUserNotificationCenter.current().add(request) { error in + if let error { + logger.error("Gesprächsfenster-Notification konnte nicht geplant werden (Wochentag \(weekday)): \(error.localizedDescription)") + } + } } } } diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index 28d9f91..e1d4f8d 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -1431,6 +1431,17 @@ } } }, + "Dein Vorhaben" : { + "comment" : "Notification subtitle for todo and intention reminders", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your intention" + } + } + } + }, "Deine Daten gehören dir" : { "comment" : "OnboardingPrivacyView – headline", "localizations" : { @@ -3368,7 +3379,6 @@ } }, "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3816,6 +3826,7 @@ }, "Nachwirkung: %@" : { "comment" : "AftermathNotificationManager – notification title with person name", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5678,7 +5689,6 @@ } }, "Wenn du magst, kannst du das Treffen kurz reflektieren." : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5700,6 +5710,17 @@ } } }, + "Wer freut sich heute von dir zu hören?" : { + "comment" : "PersonalityEngine.callWindowCopy – notification body for high extraversion or nil profile", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Who'd be happy to hear from you today?" + } + } + } + }, "Wichtig" : { "comment" : "LogbuchView swipe action – mark moment as important", "localizations" : { @@ -5835,6 +5856,17 @@ } } }, + "Wie war's mit %@?" : { + "comment" : "AftermathNotificationManager – warm notification title with person name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How was it with %@?" + } + } + } + }, "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute." : { "comment" : "AftermathNotificationManager – notification body text", "localizations" : { @@ -5945,6 +5977,17 @@ } } }, + "Zeit, dich bei jemandem zu melden?" : { + "comment" : "PersonalityEngine.callWindowCopy – default notification body", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time to reach out to someone?" + } + } + } + }, "Zeitfenster" : { "comment" : "SettingsView / CallWindowSetupView – time window section header and row label", "localizations" : { diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 46db7de..1fdb789 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -1260,6 +1260,7 @@ struct EditTodoView: View { } let content = UNMutableNotificationContent() content.title = todo.person?.firstName ?? "" + content.subtitle = String(localized: "Dein Vorhaben") content.body = todo.title content.sound = .default content.userInfo = ["todoID": todo.id.uuidString] diff --git a/nahbar/nahbar/PersonalityEngine.swift b/nahbar/nahbar/PersonalityEngine.swift index 977f4a5..e1f5983 100644 --- a/nahbar/nahbar/PersonalityEngine.swift +++ b/nahbar/nahbar/PersonalityEngine.swift @@ -74,6 +74,43 @@ enum PersonalityEngine { } } + // MARK: - Notification-Texte + + /// Body-Text für Gesprächsfenster-Notification (allgemeine Kontakt-Erinnerung, kein spezifischer Name). + /// Zentralisiert die bisher in CallWindowManager inline definierte Persönlichkeitslogik. + /// - High Extraversion → direkt, motivierend + /// - High Neuroticism (nicht high E) → weich, ermutigend + /// - Default → freundlich-neutral + static func callWindowCopy(profile: PersonalityProfile?) -> String { + guard let profile else { + return String(localized: "Wer freut sich heute von dir zu hören?") + } + switch (profile.level(for: .extraversion), profile.level(for: .neuroticism)) { + case (.high, _): + return String(localized: "Wer freut sich heute von dir zu hören?") + case (_, .high): + return String(localized: "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂") + default: + return String(localized: "Zeit, dich bei jemandem zu melden?") + } + } + + /// Body-Text für Nachwirkungs-Notification nach einem Treffen. + /// Zentralisiert die bisher in AftermathNotificationManager inline verwendete Persönlichkeitslogik. + /// - High Neuroticism → weich, optional einladend + /// - Default → direkt, warm + static func aftermathCopy(profile: PersonalityProfile?) -> String { + guard let profile else { + return String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute.") + } + switch profile.level(for: .neuroticism) { + case .high: + return String(localized: "Wenn du magst, kannst du das Treffen kurz reflektieren.") + default: + return String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute.") + } + } + // MARK: - Besuchsbewertungs-Timing /// Gibt an, ob der Besuchsfragebogen verzögert angezeigt werden soll. diff --git a/nahbar/nahbarTests/NahbarPersonalityTests.swift b/nahbar/nahbarTests/NahbarPersonalityTests.swift index 67c8ce1..8559ec9 100644 --- a/nahbar/nahbarTests/NahbarPersonalityTests.swift +++ b/nahbar/nahbarTests/NahbarPersonalityTests.swift @@ -519,6 +519,85 @@ struct PersonalityQuizGenderSkipTests { } } +// MARK: - Notification Copy Tests + +@Suite("PersonalityEngine – Notification Copy") +struct NotificationCopyTests { + + private func profile(e: TraitLevel = .medium, n: TraitLevel = .medium, + c: TraitLevel = .medium) -> PersonalityProfile { + func score(_ l: TraitLevel) -> Int { l == .low ? 0 : l == .medium ? 1 : 2 } + return PersonalityProfile(scores: [ + .extraversion: score(e), .neuroticism: score(n), + .conscientiousness: score(c), .agreeableness: 1, .openness: 1 + ], completedAt: Date()) + } + + // MARK: callWindowCopy + + @Test("callWindowCopy: nil-Profil liefert nicht-leeren Fallback") + func callWindowCopyNilProfile() { + let copy = PersonalityEngine.callWindowCopy(profile: nil) + #expect(!copy.isEmpty) + } + + @Test("callWindowCopy: hohe Extraversion → direkter Text") + func callWindowCopyHighExtraversion() { + let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .high)) + #expect(copy.contains("freut sich")) + } + + @Test("callWindowCopy: hoher Neurotizismus (nicht high E) → weicherer, ermutigender Text") + func callWindowCopyHighNeuroticism() { + let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .low, n: .high)) + #expect(copy.contains("Magst du")) + } + + @Test("callWindowCopy: Medium-Profil → Default-Text") + func callWindowCopyDefault() { + let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .medium, n: .medium)) + #expect(!copy.isEmpty) + } + + @Test("callWindowCopy: alle Varianten sind nicht leer") + func callWindowCopyAllVariantsNonEmpty() { + for e in TraitLevel.allCases { + for n in TraitLevel.allCases { + let copy = PersonalityEngine.callWindowCopy(profile: profile(e: e, n: n)) + #expect(!copy.isEmpty, "callWindowCopy leer für e=\(e), n=\(n)") + } + } + } + + // MARK: aftermathCopy + + @Test("aftermathCopy: nil-Profil liefert nicht-leeren Fallback") + func aftermathCopyNilProfile() { + let copy = PersonalityEngine.aftermathCopy(profile: nil) + #expect(!copy.isEmpty) + } + + @Test("aftermathCopy: hoher Neurotizismus → weicher, einladender Text") + func aftermathCopyHighNeuroticism() { + let copy = PersonalityEngine.aftermathCopy(profile: profile(n: .high)) + #expect(copy.contains("Wenn du magst")) + } + + @Test("aftermathCopy: niedriger Neurotizismus → direkter Text") + func aftermathCopyLowNeuroticism() { + let copy = PersonalityEngine.aftermathCopy(profile: profile(n: .low)) + #expect(copy.contains("Wie wirkt")) + } + + @Test("aftermathCopy: alle Varianten sind nicht leer") + func aftermathCopyAllVariantsNonEmpty() { + for n in TraitLevel.allCases { + let copy = PersonalityEngine.aftermathCopy(profile: profile(n: n)) + #expect(!copy.isEmpty, "aftermathCopy leer für n=\(n)") + } + } +} + // MARK: - OnboardingStep – Regressionswächter (nach Quiz-Erweiterung) @Suite("OnboardingStep – RawValues (Quiz-Erweiterung)")