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:
@@ -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 = [
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user