Fix #15: Push-Nachrichten – warmer Ton + Persönlichkeitsanpassung

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 18:29:51 +02:00
parent 17f4dbd3ab
commit 9ca54e6a82
8 changed files with 207 additions and 25 deletions
+4 -9
View File
@@ -58,15 +58,10 @@ final class AftermathNotificationManager {
private func createNotification(momentID: UUID, personName: String, delay: TimeInterval) { private func createNotification(momentID: UUID, personName: String, delay: TimeInterval) {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName) // Wärmerer, gesprächigerer Titel statt klinischem "Nachwirkung: [Name]"
// Persönlichkeitsgerechter Body-Text (softer für hohen Neurotizismus) content.title = String.localizedStringWithFormat(String(localized: "Wie war's mit %@?"), personName)
let defaultBody = String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen dauert 1 Minute.") // Persönlichkeitsgerechter Body-Text via PersonalityEngine
if let profile = PersonalityStore.shared.profile, content.body = PersonalityEngine.aftermathCopy(profile: PersonalityStore.shared.profile)
case .delayed(_, let softerCopy?) = PersonalityEngine.ratingPromptTiming(for: profile) {
content.body = softerCopy
} else {
content.body = defaultBody
}
content.sound = .default content.sound = .default
content.categoryIdentifier = Self.categoryID content.categoryIdentifier = Self.categoryID
content.userInfo = [ content.userInfo = [
+20 -3
View File
@@ -2,6 +2,9 @@ import SwiftUI
import SwiftData import SwiftData
import EventKit import EventKit
import UserNotifications import UserNotifications
import OSLog
private let intentionNotificationLogger = Logger(subsystem: "nahbar", category: "IntentionNotification")
struct AddMomentView: View { struct AddMomentView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@@ -419,13 +422,21 @@ struct AddMomentView: View {
private func scheduleIntentionReminder(for moment: Moment) { private func scheduleIntentionReminder(for moment: Moment) {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in center.requestAuthorization(options: [.alert, .sound]) { granted, error in
guard granted else { return } if let error {
intentionNotificationLogger.error("Berechtigung-Fehler: \(error.localizedDescription)")
}
guard granted else {
intentionNotificationLogger.warning("Notification-Berechtigung abgelehnt keine Vorhaben-Erinnerung.")
return
}
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = person.firstName content.title = person.firstName
content.subtitle = String(localized: "Dein Vorhaben")
content.body = moment.text content.body = moment.text
content.sound = .default content.sound = .default
content.userInfo = ["momentID": moment.id.uuidString]
let components = Calendar.current.dateComponents( let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute], from: reminderDate [.year, .month, .day, .hour, .minute], from: reminderDate
@@ -436,7 +447,13 @@ struct AddMomentView: View {
content: content, content: content,
trigger: trigger 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)")
}
}
} }
} }
+1
View File
@@ -181,6 +181,7 @@ struct AddTodoView: View {
} }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = person.firstName content.title = person.firstName
content.subtitle = String(localized: "Dein Vorhaben")
content.body = todo.title content.body = todo.title
content.sound = .default content.sound = .default
content.userInfo = ["todoID": todo.id.uuidString] content.userInfo = ["todoID": todo.id.uuidString]
+20 -11
View File
@@ -1,6 +1,9 @@
import Foundation import Foundation
import Combine import Combine
import UserNotifications import UserNotifications
import OSLog
private let logger = Logger(subsystem: "nahbar", category: "CallWindowNotification")
class CallWindowManager: ObservableObject { class CallWindowManager: ObservableObject {
static let shared = CallWindowManager() static let shared = CallWindowManager()
@@ -99,18 +102,20 @@ class CallWindowManager: ObservableObject {
cancelNotifications() cancelNotifications()
guard isEnabled else { return } guard isEnabled else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
guard granted else { return } 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 { for weekday in self.selectedWeekdays {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Gesprächszeit" content.title = String(localized: "Gesprächszeit")
// Persönlichkeitsgerechter Body-Text (wärmer bei hohem Neurotizismus) content.body = body
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.sound = .default content.sound = .default
content.categoryIdentifier = "CALL_WINDOW" content.categoryIdentifier = "CALL_WINDOW"
@@ -124,7 +129,11 @@ class CallWindowManager: ObservableObject {
content: content, content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: dc, repeats: true) 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)")
}
}
} }
} }
} }
+45 -2
View File
@@ -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" : { "Deine Daten gehören dir" : {
"comment" : "OnboardingPrivacyView headline", "comment" : "OnboardingPrivacyView headline",
"localizations" : { "localizations" : {
@@ -3368,7 +3379,6 @@
} }
}, },
"Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂" : { "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -3816,6 +3826,7 @@
}, },
"Nachwirkung: %@" : { "Nachwirkung: %@" : {
"comment" : "AftermathNotificationManager notification title with person name", "comment" : "AftermathNotificationManager notification title with person name",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5678,7 +5689,6 @@
} }
}, },
"Wenn du magst, kannst du das Treffen kurz reflektieren." : { "Wenn du magst, kannst du das Treffen kurz reflektieren." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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" : { "Wichtig" : {
"comment" : "LogbuchView swipe action mark moment as important", "comment" : "LogbuchView swipe action mark moment as important",
"localizations" : { "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." : { "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen dauert 1 Minute." : {
"comment" : "AftermathNotificationManager notification body text", "comment" : "AftermathNotificationManager notification body text",
"localizations" : { "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" : { "Zeitfenster" : {
"comment" : "SettingsView / CallWindowSetupView time window section header and row label", "comment" : "SettingsView / CallWindowSetupView time window section header and row label",
"localizations" : { "localizations" : {
+1
View File
@@ -1260,6 +1260,7 @@ struct EditTodoView: View {
} }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = todo.person?.firstName ?? "" content.title = todo.person?.firstName ?? ""
content.subtitle = String(localized: "Dein Vorhaben")
content.body = todo.title content.body = todo.title
content.sound = .default content.sound = .default
content.userInfo = ["todoID": todo.id.uuidString] content.userInfo = ["todoID": todo.id.uuidString]
+37
View File
@@ -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 // MARK: - Besuchsbewertungs-Timing
/// Gibt an, ob der Besuchsfragebogen verzögert angezeigt werden soll. /// Gibt an, ob der Besuchsfragebogen verzögert angezeigt werden soll.
@@ -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) // MARK: - OnboardingStep Regressionswächter (nach Quiz-Erweiterung)
@Suite("OnboardingStep RawValues (Quiz-Erweiterung)") @Suite("OnboardingStep RawValues (Quiz-Erweiterung)")