Issues 1–7: Kalender, Swipe, Momente, Onboarding, Herkunft, Planung

1. Kalender-Einstellungen: Picker für Standard-Kalender in Settings
2. Swipe-Gesten: Sensitivität reduziert (minimumDistance 50, Snap ×1.5)
3. Momente: Gespräch → Gespräch/Chat; Unternehmung aus Picker entfernt
   (Enum-Case bleibt für Rückwärtskompatibilität)
4. Onboarding: maximal 3 Kontakte auswählbar
5. Herkunft: kultureller Hintergrund auf Personenprofil + AI-Prompt
6. Planung: Erinnerung und Kalendereintrag unabhängig für alle Typen
   außer Gedanke; kein Survey bei zukünftigem Treffen
7. Heute-Ansicht: Erinnerungen für Treffen und Gespräch erscheinen
   in "Anstehende Erinnerungen"; Platzhalter passt sich Planungsstatus an

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 20:56:50 +02:00
parent e5cfb8b4ba
commit c30fb4e518
14 changed files with 825 additions and 100 deletions
+73 -3
View File
@@ -1,5 +1,73 @@
import Foundation
// MARK: - Payload Sanitizer
/// Bereinigt freie Texte vor dem Versand an den KI-Dienst.
/// Erkannte PII-Muster werden durch neutrale Platzhalter ersetzt,
/// damit keine eindeutig identifizierende Information das Gerät verlässt.
/// Vornamen sind bewusst erlaubt Nachnamen und andere eindeutige
/// Identifikatoren sollen vom Nutzer gar nicht erst eingetragen werden.
enum AIPayloadSanitizer {
/// Wendet alle Anonymisierungsregeln der Reihe nach auf einen Text an.
static func sanitize(_ text: String) -> String {
var s = text
s = maskEmails(s)
s = maskURLs(s)
s = maskIBANs(s)
s = maskPhoneNumbers(s)
s = maskLongDigitSequences(s)
return s
}
// E-Mail-Adressen: name@domain.tld
private static func maskEmails(_ text: String) -> String {
apply("[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}", replacement: "[E-Mail]", to: text)
}
// URLs: https://... oder www....
private static func maskURLs(_ text: String) -> String {
apply("https?://\\S+|www\\.\\S+", replacement: "[Link]", to: text)
}
// IBANs: DE89 3704 0044 ... (2 Buchstaben + 2 Ziffern + mind. 11 alphanumerische Zeichen)
private static func maskIBANs(_ text: String) -> String {
apply("\\b[A-Z]{2}\\d{2}[A-Z0-9]{4}[\\dA-Z\\s]{4,28}\\b", replacement: "[IBAN]", to: text)
}
// Telefonnummern:
// - International: +49 30 12345678, +1-555-123-4567
// - Deutsche Mobil: 0151 12345678 (1011 Ziffern am Stück)
// - Deutsche Festnetz mit Trenner: 030 12345678, 089/12345, 030-12345
private static func maskPhoneNumbers(_ text: String) -> String {
var s = text
// Internationale Rufnummern mit +
s = apply("\\+\\d{1,3}[\\s\\-]?[\\d\\s\\-\\.\\(\\)\\/]{7,20}", replacement: "[Telefon]", to: s)
// Deutsche Mobilnummern (z.B. 0151, 0160, 0170): 1011 Ziffern ohne Trenner
s = apply("\\b0\\d{9,10}\\b", replacement: "[Telefon]", to: s)
// Deutsche Festnetznummern mit Trenner (Leerzeichen, Slash oder Bindestrich)
s = apply("\\b0\\d{2,4}[\\s\\/\\-]\\d{3,8}\\b", replacement: "[Telefon]", to: s)
return s
}
// Lange Zahlenfolgen ( 8 Ziffern am Stück): Ausweisnummern, Kontonummern, etc.
private static func maskLongDigitSequences(_ text: String) -> String {
apply("\\b\\d{8,}\\b", replacement: "[Nummer]", to: text)
}
private static func apply(_ pattern: String, replacement: String, to text: String) -> String {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return text
}
return regex.stringByReplacingMatches(
in: text,
options: [],
range: NSRange(text.startIndex..., in: text),
withTemplate: replacement
)
}
}
// MARK: - Configuration
struct AIConfig: Decodable {
@@ -225,21 +293,23 @@ class AIAnalysisService {
formatter.locale = Locale.current
let momentLines = person.sortedMoments.prefix(30).map {
"- \(formatter.string(from: $0.createdAt)) [\($0.type.rawValue)]: \($0.text)"
"- \(formatter.string(from: $0.createdAt)) [\($0.type.rawValue)]: \(AIPayloadSanitizer.sanitize($0.text))"
}.joined(separator: "\n")
let logLines = person.sortedLogEntries.prefix(20).map {
"- \(formatter.string(from: $0.loggedAt)) [\($0.type.rawValue)]: \($0.title)"
"- \(formatter.string(from: $0.loggedAt)) [\($0.type.rawValue)]: \(AIPayloadSanitizer.sanitize($0.title))"
}.joined(separator: "\n")
let moments = momentLines.isEmpty ? "" : "\(lang.momentsLabel) (\(person.sortedMoments.count)):\n\(momentLines)\n"
let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n"
let interests = person.interests.map { "\(lang.interestsLabel): \($0)\n" } ?? ""
let interests = person.interests.map { "\(lang.interestsLabel): \(AIPayloadSanitizer.sanitize($0))\n" } ?? ""
let culturalBg = person.culturalBackground.map { "\(lang.culturalBackgroundLabel): \($0)\n" } ?? ""
let instruction = isGift ? lang.giftInstruction : lang.analysisInstruction
return "Person: \(person.firstName)\n"
+ birthYearContext(for: person, language: lang)
+ interests
+ culturalBg
+ "\n"
+ moments
+ "\n"
+65 -29
View File
@@ -26,6 +26,7 @@ struct AddMomentView: View {
return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date()
}()
@State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag
@AppStorage("selectedCalendarID") private var defaultCalendarID: String = ""
@State private var availableCalendars: [EKCalendar] = []
@State private var selectedCalendarID: String = ""
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
@@ -38,15 +39,30 @@ struct AddMomentView: View {
}()
private var isValid: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty }
private var showsCalendarSection: Bool { selectedType == .meeting }
private var showsReminderSection: Bool { selectedType == .intention }
private var showsCalendarSection: Bool { selectedType != .thought }
private var showsReminderSection: Bool { selectedType != .thought }
/// Treffen liegt in der Zukunft kein Rating-Flow nach dem Speichern.
private var isFutureMeeting: Bool {
if addToCalendar { return eventDate > Date() }
if addReminder { return reminderDate > Date() }
return false
}
private var placeholder: String {
switch selectedType {
case .meeting: return "Wo habt ihr euch getroffen?\nWas habt ihr unternommen?"
case .intention: return "Was möchtest du mit dieser Person machen?"
case .thought: return "Welcher Gedanke kam dir in den Sinn?"
case .conversation: return "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?"
case .meeting:
return isFutureMeeting
? "Wo trefft ihr euch?\nWas habt ihr vor?"
: "Wo habt ihr euch getroffen?\nWas habt ihr unternommen?"
case .thought:
return "Welcher Gedanke kam dir in den Sinn?"
case .intention: // legacy
return "Was möchtest du mit dieser Person machen?"
default: // .conversation
return isFutureMeeting
? "Worüber wollt ihr reden?\nWas möchtest du ansprechen?"
: "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?"
}
}
@@ -264,7 +280,12 @@ struct AddMomentView: View {
availableCalendars = calendars
// Vorauswahl: gespeicherter Kalender oder der Standard
if selectedCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(selectedCalendarID) {
selectedCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
let globalDefault = defaultCalendarID
if !globalDefault.isEmpty && calendars.map(\.calendarIdentifier).contains(globalDefault) {
selectedCalendarID = globalDefault
} else {
selectedCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
}
}
}
}
@@ -320,12 +341,27 @@ struct AddMomentView: View {
person.moments?.append(moment)
person.touch()
// Vorhaben: Erinnerung speichern + Notification planen
if selectedType == .intention && addReminder {
// Erinnerung (lokale Notification) unabhängig vom Kalendereintrag
if addReminder {
moment.reminderDate = reminderDate
scheduleIntentionReminder(for: moment)
}
// Kalendereintrag für alle planbaren Typen, unabhängig von der Erinnerung
if addToCalendar {
moment.createdAt = eventDate
let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute())
let calEntry = LogEntry(
type: .calendarEvent,
title: calendarLogTitle(date: dateStr),
person: person
)
modelContext.insert(calEntry)
person.logEntries?.append(calEntry)
let momentID = moment.id
Task { await createAndStoreCalendarEvent(for: momentID, notes: trimmed) }
}
do {
try modelContext.save()
} catch {
@@ -335,25 +371,8 @@ struct AddMomentView: View {
)
}
// Treffen: Callback für Rating-Flow + evtl. Kalendertermin
if selectedType == .meeting {
// createdAt = Zeitpunkt des Treffens; wenn ein Zukunftstermin gewählt,
// bleibt der Moment zunächst unbewertet (kein sofortiger Rating-Flow)
if addToCalendar {
moment.createdAt = eventDate
let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute())
let calEntry = LogEntry(
type: .calendarEvent,
title: String.localizedStringWithFormat(String(localized: "Treffen mit %@ — %@"), person.firstName, dateStr),
person: person
)
modelContext.insert(calEntry)
person.logEntries?.append(calEntry)
let momentID = moment.id
Task {
await createAndStoreCalendarEvent(for: momentID, notes: trimmed)
}
}
// Treffen: Callback für Rating-Flow nur wenn das Treffen bereits stattgefunden hat
if selectedType == .meeting && !isFutureMeeting {
dismiss()
onMeetingCreated?(moment)
return
@@ -362,6 +381,18 @@ struct AddMomentView: View {
dismiss()
}
/// Titel für den LogEntry-Kalender-Eintrag, abhängig vom Typ.
private func calendarLogTitle(date: String) -> String {
switch selectedType {
case .meeting:
return String.localizedStringWithFormat(String(localized: "Treffen mit %@ — %@"), person.firstName, date)
case .conversation:
return String.localizedStringWithFormat(String(localized: "Gespräch mit %@ — %@"), person.firstName, date)
default:
return String.localizedStringWithFormat(String(localized: "Termin mit %@ — %@"), person.firstName, date)
}
}
// MARK: - Vorhaben-Erinnerung
private func scheduleIntentionReminder(for moment: Moment) {
@@ -403,7 +434,12 @@ struct AddMomentView: View {
}
let calendarID = selectedCalendarID.isEmpty ? nil : selectedCalendarID
let title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), person.firstName)
let title: String
switch selectedType {
case .meeting: title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), person.firstName)
case .conversation: title = String.localizedStringWithFormat(String(localized: "Gespräch mit %@"), person.firstName)
default: title = String.localizedStringWithFormat(String(localized: "Termin mit %@"), person.firstName)
}
let alarmOffset: TimeInterval? = eventAlarmOffset == 0 ? nil : eventAlarmOffset
if let identifier = await CalendarManager.shared.createEvent(
+6
View File
@@ -16,6 +16,7 @@ struct AddPersonView: View {
@State private var location = ""
@State private var interests = ""
@State private var generalNotes = ""
@State private var culturalBackground = ""
@State private var hasBirthday = false
@State private var birthday = Date()
@State private var nudgeFrequency: NudgeFrequency = .monthly
@@ -85,6 +86,8 @@ struct AddPersonView: View {
RowDivider()
inlineField("Interessen", text: $interests)
RowDivider()
inlineField("Herkunft", text: $culturalBackground)
RowDivider()
inlineField("Notizen", text: $generalNotes)
}
.background(theme.surfaceCard)
@@ -383,6 +386,7 @@ struct AddPersonView: View {
occupation = p.occupation ?? ""
location = p.location ?? ""
interests = p.interests ?? ""
culturalBackground = p.culturalBackground ?? ""
generalNotes = p.generalNotes ?? ""
hasBirthday = p.birthday != nil
birthday = p.birthday ?? Date()
@@ -405,6 +409,7 @@ struct AddPersonView: View {
p.occupation = occupation.isEmpty ? nil : occupation
p.location = location.isEmpty ? nil : location
p.interests = interests.isEmpty ? nil : interests
p.culturalBackground = culturalBackground.isEmpty ? nil : culturalBackground
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
p.birthday = hasBirthday ? birthday : nil
p.nudgeFrequency = nudgeFrequency
@@ -419,6 +424,7 @@ struct AddPersonView: View {
location: location.isEmpty ? nil : location,
interests: interests.isEmpty ? nil : interests,
generalNotes: generalNotes.isEmpty ? nil : generalNotes,
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
nudgeFrequency: nudgeFrequency
)
modelContext.insert(person)
+159 -3
View File
@@ -116,6 +116,7 @@
}
},
"%lld ausgewählt" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"variations" : {
@@ -217,6 +218,26 @@
}
}
},
"%lld von %lld — Maximum erreicht" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld von %2$lld — Maximum erreicht"
}
}
}
},
"%lld von %lld ausgewählt" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld von %2$lld ausgewählt"
}
}
}
},
"%lld von %lld Kontakten Pro für mehr" : {
"comment" : "A text label that shows the number of contacts that can be made for free, followed by a call to action to upgrade to Pro.",
"isCommentAutoGenerated" : true,
@@ -771,8 +792,20 @@
}
}
},
"Anstehende Erinnerungen" : {
"comment" : "TodayView section title for all plannable moments (Treffen, Gespräch, Vorhaben) with upcoming reminder dates",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Upcoming Reminders"
}
}
}
},
"Anstehende Unternehmungen" : {
"comment" : "TodayView section title for intention moments with reminder dates",
"comment" : "TodayView legacy key, replaced by 'Anstehende Erinnerungen'",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1432,6 +1465,7 @@
},
"Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt aber Daten verlassen dein Gerät.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet." : {
"comment" : "AIConsentSheet body text",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -1441,6 +1475,17 @@
}
}
},
"Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt aber Daten verlassen dein Gerät.\n\nEs werden übertragen: Vorname, Geburtsjahr (nicht das vollständige Datum), Interessen sowie gespeicherte Momente und Logbucheinträge.\n\nVor dem Versand werden alle Texte automatisch bereinigt: Telefonnummern, E-Mail-Adressen, Links, IBANs und lange Zahlenfolgen werden durch Platzhalter ersetzt.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet." : {
"comment" : "AIConsentSheet body text",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This feature transfers information about the selected person to an external AI service (Anthropic Claude). The transfer is encrypted but data leaves your device.\n\nWhat is transferred: first name, birth year (not the full date), interests, and saved moments and log entries.\n\nBefore sending, all texts are automatically cleaned: phone numbers, email addresses, links, IBANs and long digit sequences are replaced with placeholders.\n\nYou decide at any time whether to use AI features. Without your confirmation, no data is sent."
}
}
}
},
"Diese Person wirklich löschen?" : {
"comment" : "AddPersonView delete person confirmation title",
"localizations" : {
@@ -2138,6 +2183,17 @@
}
}
},
"Fangen wir an" : {
"comment" : "TodayView empty state CTA button title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Let's get started"
}
}
}
},
"Fehler: %@" : {
"comment" : "TodayView GiftSuggestionRow error message with description",
"localizations" : {
@@ -2233,6 +2289,17 @@
}
}
},
"Füge zuerst Personen im Tab „Menschen“ hinzu." : {
"comment" : "TodayPersonPickerSheet empty state hint when no contacts exist yet",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "First add people in the “People” tab."
}
}
}
},
"Fühlt sich die Beziehung gestärkt an?" : {
"comment" : "RatingQuestion relationship question text",
"extractionState" : "stale",
@@ -2245,6 +2312,17 @@
}
}
},
"Für wen?" : {
"comment" : "TodayPersonPickerSheet navigation title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "For whom?"
}
}
}
},
"Ganztag" : {
"comment" : "AddMomentView all-day calendar event duration option",
"localizations" : {
@@ -2281,6 +2359,7 @@
},
"Geburtstage & Termine" : {
"comment" : "SettingsView look-ahead section row label",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -2390,7 +2469,7 @@
}
},
"Gespräch" : {
"comment" : "MomentType.conversation / RatingCategory.gespraech raw value",
"comment" : "MomentType.conversation rawValue / RatingCategory.gespraech raw value Persistenzschlüssel, nicht im Picker angezeigt",
"extractionState" : "stale",
"localizations" : {
"en" : {
@@ -2401,6 +2480,44 @@
}
}
},
"Gespräch mit %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conversation with %@"
}
}
}
},
"Gespräch mit %@ — %@" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "new",
"value" : "Gespräch mit %1$@ — %2$@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conversation with %1$@ — %2$@"
}
}
}
},
"Gespräch/Chat" : {
"comment" : "MomentType.conversation displayName shown in type picker",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conversation/Chat"
}
}
}
},
"Gesprächseinstieg" : {
"comment" : "CallSuggestionView conversation starter section header",
"localizations" : {
@@ -2791,6 +2908,9 @@
}
}
}
},
"Kalender-Einstellungen" : {
},
"Kauf wiederherstellen" : {
"comment" : "PaywallView restore purchases button",
@@ -3353,6 +3473,17 @@
}
}
},
"Momente planen und hinzufügen" : {
"comment" : "TodayView empty state CTA button subtitle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Plan and add moments"
}
}
}
},
"Momente und abgeschlossene Schritte erscheinen hier." : {
"comment" : "LogbuchView empty state subtitle",
"localizations" : {
@@ -4578,6 +4709,19 @@
}
}
},
"Termin mit %@" : {
},
"Termin mit %@ — %@" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "new",
"value" : "Termin mit %1$@ — %2$@"
}
}
}
},
"Tief & fokussiert · ND" : {
"comment" : "Theme tagline for Abyss",
"localizations" : {
@@ -4823,7 +4967,7 @@
}
},
"Unternehmung" : {
"comment" : "MomentType.intention displayName shown in type picker and feature tour",
"comment" : "MomentType.intention displayName legacy, no longer in picker; kept for backward compat and feature tour",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -4990,6 +5134,7 @@
},
"Vorausschau" : {
"comment" : "SettingsView section header for look-ahead settings",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -5041,6 +5186,15 @@
}
}
}
},
"Vorschau Geburtstage & Termine" : {
},
"Wähle bis zu 3 Menschen aus deinem Adressbuch, die dir wichtig sind." : {
},
"Wähle bis zu 3 Menschen aus, die dir wichtig sind." : {
},
"Wähle deinen Plan" : {
"comment" : "PaywallView header title",
@@ -5065,6 +5219,7 @@
}
},
"Wähle Menschen aus deinem Adressbuch, die dir wichtig sind." : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -5075,6 +5230,7 @@
}
},
"Wähle Menschen aus, die dir wichtig sind." : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
+10 -4
View File
@@ -624,10 +624,11 @@ private struct DeletableLogbuchRow<Content: View>: View {
.background(theme.surfaceCard)
.offset(x: offset)
.gesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local)
DragGesture(minimumDistance: 50, coordinateSpace: .local)
.onChanged { value in
let x = value.translation.width
guard abs(x) > abs(value.translation.height) * 0.6 else { return }
let y = value.translation.height
guard abs(x) > abs(y) * 2.5 else { return }
if x > 0 {
offset = min(x, actionWidth * 2 + 16)
} else {
@@ -636,12 +637,17 @@ private struct DeletableLogbuchRow<Content: View>: View {
}
.onEnded { value in
let x = value.translation.width
let y = value.translation.height
guard abs(x) > abs(y) * 2.5 else {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
return
}
if x > actionWidth * 2 + 20 {
onToggleImportant()
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
} else if x > actionWidth / 2 {
} else if x > actionWidth * 1.5 {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
} else if x < -(actionWidth / 2) {
} else if x < -(actionWidth * 1.5) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
} else {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
+11 -4
View File
@@ -39,17 +39,21 @@ enum NudgeFrequency: String, CaseIterable, Codable {
}
}
enum MomentType: String, CaseIterable, Codable {
enum MomentType: String, Codable {
case conversation = "Gespräch"
case meeting = "Treffen" // rawValue bleibt für Persistenz unverändert
case thought = "Gedanke"
case intention = "Vorhaben"
case intention = "Vorhaben" // legacy nicht mehr im Picker, aber für Persistenz erhalten
/// Typen, die im Picker angezeigt werden (.intention ist veraltet und ausgeblendet).
static var allCases: [MomentType] { [.conversation, .meeting, .thought] }
/// Anzeigename im UI entkoppelt Persistenzschlüssel von der Darstellung.
var displayName: String {
switch self {
case .intention: return "Unternehmung"
default: return rawValue
case .conversation: return "Gespräch/Chat"
case .intention: return "Unternehmung" // legacy
default: return rawValue
}
}
@@ -111,6 +115,7 @@ class Person {
var location: String?
var interests: String?
var generalNotes: String?
var culturalBackground: String? = nil // V6: kultureller Hintergrund
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
var nextStep: String?
var nextStepCompleted: Bool = false
@@ -139,6 +144,7 @@ class Person {
location: String? = nil,
interests: String? = nil,
generalNotes: String? = nil,
culturalBackground: String? = nil,
nudgeFrequency: NudgeFrequency = .monthly
) {
self.id = UUID()
@@ -149,6 +155,7 @@ class Person {
self.location = location
self.interests = interests
self.generalNotes = generalNotes
self.culturalBackground = culturalBackground
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
self.photoData = nil
self.photo = nil
+121 -6
View File
@@ -289,11 +289,11 @@ enum NahbarSchemaV4: VersionedSchema {
}
}
// MARK: - Schema V5 (aktuelles Schema)
// Referenziert die Live-Typen aus Models.swift.
// Beim Hinzufügen von V6 muss V5 als eingefrorener Snapshot gesichert werden.
// MARK: - Schema V5 (eingefrorener Snapshot)
// WICHTIG: Niemals nachträglich ändern dieser Snapshot muss dem gespeicherten
// Schema-Hash von V5-Datenbanken auf Nutzer-Geräten entsprechen.
//
// V5 fügt hinzu:
// V5 fügte hinzu:
// Moment: ratings, healthSnapshot, statusRaw, aftermathNotificationScheduled,
// aftermathCompletedAt, reminderDate, isCompleted
// Rating: moment-Relationship (neben legacy visit)
@@ -301,6 +301,117 @@ enum NahbarSchemaV4: VersionedSchema {
enum NahbarSchemaV5: VersionedSchema {
static var versionIdentifier = Schema.Version(5, 0, 0)
static var models: [any PersistentModel.Type] {
[PersonPhoto.self, Person.self, Moment.self, LogEntry.self,
Visit.self, Rating.self, HealthSnapshot.self]
}
@Model final class PersonPhoto {
var id: UUID = UUID()
@Attribute(.externalStorage) var imageData: Data = Data()
var createdAt: Date = Date()
init() {}
}
@Model final class Person {
var id: UUID = UUID()
var name: String = ""
var tagRaw: String = "Andere"
var birthday: Date? = nil
var occupation: String? = nil
var location: String? = nil
var interests: String? = nil
var generalNotes: String? = nil
var nudgeFrequencyRaw: String = "Monatlich"
var nextStep: String? = nil
var nextStepCompleted: Bool = false
var nextStepReminderDate: Date? = nil
var lastSuggestedForCall: Date? = nil
var createdAt: Date = Date()
var updatedAt: Date = Date()
var isArchived: Bool = false
@Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil
var photoData: Data? = nil
@Relationship(deleteRule: .cascade) var moments: [Moment]? = []
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = []
@Relationship(deleteRule: .cascade) var visits: [Visit]? = []
init() {}
}
@Model final class Moment {
var id: UUID = UUID()
var text: String = ""
var typeRaw: String = "Gespräch"
var sourceRaw: String? = nil
var createdAt: Date = Date()
var updatedAt: Date = Date()
var isImportant: Bool = false
var person: Person? = nil
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
var statusRaw: String? = nil
var aftermathNotificationScheduled: Bool = false
var aftermathCompletedAt: Date? = nil
var reminderDate: Date? = nil
var isCompleted: Bool = false
init() {}
}
@Model final class LogEntry {
var id: UUID = UUID()
var typeRaw: String = "Schritt abgeschlossen"
var title: String = ""
var loggedAt: Date = Date()
var updatedAt: Date = Date()
var person: Person? = nil
init() {}
}
@Model final class Visit {
var id: UUID = UUID()
var visitDate: Date = Date()
var statusRaw: String = "sofort_abgeschlossen"
var note: String? = nil
var aftermathNotificationScheduled: Bool = false
var aftermathCompletedAt: Date? = nil
var person: Person? = nil
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
init() {}
}
@Model final class Rating {
var id: UUID = UUID()
var categoryRaw: String = "Selbst"
var questionIndex: Int = 0
var value: Int? = nil
var isAftermath: Bool = false
var visit: Visit? = nil
var moment: Moment? = nil
init() {}
}
@Model final class HealthSnapshot {
var id: UUID = UUID()
var sleepHours: Double? = nil
var hrvMs: Double? = nil
var restingHR: Int? = nil
var steps: Int? = nil
var visit: Visit? = nil
var moment: Moment? = nil
init() {}
}
}
// MARK: - Schema V6 (aktuelles Schema)
// Referenziert die Live-Typen aus Models.swift.
// Beim Hinzufügen von V7 muss V6 als eingefrorener Snapshot gesichert werden.
//
// V6 fügt hinzu:
// Person: culturalBackground (optionaler Freitext für kulturellen Hintergrund)
enum NahbarSchemaV6: VersionedSchema {
static var versionIdentifier = Schema.Version(6, 0, 0)
static var models: [any PersistentModel.Type] {
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self]
@@ -312,7 +423,7 @@ enum NahbarSchemaV5: VersionedSchema {
enum NahbarMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
NahbarSchemaV4.self, NahbarSchemaV5.self]
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self]
}
static var stages: [MigrationStage] {
@@ -332,7 +443,11 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
// V4 V5: Moment bekommt rating/intention-Felder, Rating/HealthSnapshot
// bekommen moment-Relationship. Visit/HealthSnapshot bleiben im Schema
// (CloudKit-Sicherheit). Alle neuen Felder haben Default-Werte lightweight.
.lightweight(fromVersion: NahbarSchemaV4.self, toVersion: NahbarSchemaV5.self)
.lightweight(fromVersion: NahbarSchemaV4.self, toVersion: NahbarSchemaV5.self),
// V5 V6: Person bekommt culturalBackground = nil.
// Optionales Feld mit nil-Default lightweight-Migration reicht aus.
.lightweight(fromVersion: NahbarSchemaV5.self, toVersion: NahbarSchemaV6.self)
]
}
}
+16 -8
View File
@@ -394,6 +394,9 @@ private struct OnboardingContactImportView: View {
@State private var showingPicker = false
@State private var showSkipConfirmation: Bool = false
private let maxContacts = 3
private var atLimit: Bool { coordinator.selectedContacts.count >= maxContacts }
var body: some View {
VStack(spacing: 0) {
@@ -402,7 +405,7 @@ private struct OnboardingContactImportView: View {
Text("Kontakte hinzufügen")
.font(.title.bold())
.multilineTextAlignment(.center)
Text("Wähle Menschen aus, die dir wichtig sind.")
Text("Wähle bis zu 3 Menschen aus, die dir wichtig sind.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
@@ -492,7 +495,7 @@ private struct OnboardingContactImportView: View {
.accessibilityHidden(true)
Text("Noch keine Kontakte")
.font(.title3.bold())
Text("Wähle Menschen aus deinem Adressbuch, die dir wichtig sind.")
Text("Wähle bis zu 3 Menschen aus deinem Adressbuch, die dir wichtig sind.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -540,13 +543,17 @@ private struct OnboardingContactImportView: View {
}
} header: {
HStack {
Text("\(coordinator.selectedContacts.count) ausgewählt")
Text(atLimit
? "\(coordinator.selectedContacts.count) von \(maxContacts) — Maximum erreicht"
: "\(coordinator.selectedContacts.count) von \(maxContacts) ausgewählt")
Spacer()
Button {
showingPicker = true
} label: {
Label("Weitere hinzufügen", systemImage: "plus")
.font(.caption.weight(.medium))
if !atLimit {
Button {
showingPicker = true
} label: {
Label("Weitere hinzufügen", systemImage: "plus")
.font(.caption.weight(.medium))
}
}
}
}
@@ -559,6 +566,7 @@ private struct OnboardingContactImportView: View {
/// Merges newly picked contacts into the existing selection (no duplicates).
private func mergeContacts(_ contacts: [CNContact]) {
for contact in contacts {
guard coordinator.selectedContacts.count < maxContacts else { break }
let alreadySelected = coordinator.selectedContacts
.contains { $0.cnIdentifier == contact.identifier }
if !alreadySelected {
+9 -7
View File
@@ -326,6 +326,7 @@ struct PersonDetailView: View {
person.birthday != nil
|| !(person.location ?? "").isEmpty
|| !(person.interests ?? "").isEmpty
|| !(person.culturalBackground ?? "").isEmpty
|| !(person.generalNotes ?? "").isEmpty
}
@@ -349,6 +350,10 @@ struct PersonDetailView: View {
InfoRowView(label: "Interessen", value: interests)
RowDivider()
}
if let bg = person.culturalBackground, !bg.isEmpty {
InfoRowView(label: "Herkunft", value: bg)
RowDivider()
}
if let notes = person.generalNotes, !notes.isEmpty {
InfoRowView(label: "Notizen", value: notes)
}
@@ -518,7 +523,7 @@ private struct DeletableMomentRow: View {
.background(theme.surfaceCard)
.offset(x: offset)
.simultaneousGesture(
DragGesture(minimumDistance: 20, coordinateSpace: .local)
DragGesture(minimumDistance: 50, coordinateSpace: .local)
.onChanged { value in
let x = value.translation.width
let y = value.translation.height
@@ -542,13 +547,10 @@ private struct DeletableMomentRow: View {
// Vollständiges Rechts-Wischen Wichtig-Toggle, zurückspringen
onToggleImportant()
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
} else if x > actionWidth {
// Mehr als eine Button-Breite beide linken Buttons zeigen
} else if x > actionWidth * 1.5 {
// Bewusstes Rechts-Wischen linke Buttons zeigen
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
} else if x > actionWidth / 2 {
// Knapp eine Button-Breite beide linken Buttons zeigen
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
} else if x < -(actionWidth / 2) {
} else if x < -(actionWidth * 1.5) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
} else {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
+1 -1
View File
@@ -133,7 +133,7 @@ struct AIConsentSheet: View {
.font(.title2.bold())
.multilineTextAlignment(.center)
Text("Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt aber Daten verlassen dein Gerät.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet.")
Text("Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt aber Daten verlassen dein Gerät.\n\nEs werden übertragen: Vorname, Geburtsjahr (nicht das vollständige Datum), Interessen sowie gespeicherte Momente und Logbucheinträge.\n\nVor dem Versand werden alle Texte automatisch bereinigt: Telefonnummern, E-Mail-Adressen, Links, IBANs und lange Zahlenfolgen werden durch Platzhalter ersetzt.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
+42 -7
View File
@@ -1,6 +1,7 @@
import SwiftUI
import SwiftData
import LocalAuthentication
import EventKit
struct SettingsView: View {
@Environment(\.nahbarTheme) var theme
@@ -10,6 +11,8 @@ struct SettingsView: View {
@EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
@AppStorage("selectedCalendarID") private var defaultCalendarID: String = ""
@State private var settingsCalendars: [EKCalendar] = []
@AppStorage("aftermathNotificationsEnabled") private var aftermathNotificationsEnabled: Bool = true
@AppStorage("aftermathDelayOption") private var aftermathDelayRaw: String = AftermathDelayOption.hours36.rawValue
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
@@ -265,14 +268,14 @@ struct SettingsView: View {
.padding(.horizontal, 20)
}
// Vorausschau
// Kalender-Einstellungen
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Vorausschau", icon: "calendar")
SectionHeader(title: "Kalender-Einstellungen", icon: "calendar")
.padding(.horizontal, 20)
VStack(spacing: 0) {
HStack {
Text("Geburtstage & Termine")
Text("Vorschau Geburtstage & Termine")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
@@ -286,10 +289,41 @@ struct SettingsView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
if settingsCalendars.count > 1 {
RowDivider()
HStack {
Text("Kalender")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $defaultCalendarID) {
ForEach(settingsCalendars, id: \.calendarIdentifier) { cal in
HStack {
Image(systemName: "circle.fill")
.foregroundStyle(Color(cal.cgColor))
Text(cal.title)
}
.tag(cal.calendarIdentifier)
}
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
.task {
guard settingsCalendars.isEmpty else { return }
let calendars = await CalendarManager.shared.availableCalendars()
settingsCalendars = calendars
if defaultCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(defaultCalendarID) {
defaultCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
}
}
}
// Treffen & Bewertungen
@@ -769,10 +803,11 @@ enum AppLanguage: String, CaseIterable {
}
}
var momentsLabel: String { self == .english ? "Moments" : "Momente" }
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
var interestsLabel: String { self == .english ? "Interests" : "Interessen" }
var momentsLabel: String { self == .english ? "Moments" : "Momente" }
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
var interestsLabel: String { self == .english ? "Interests" : "Interessen" }
var culturalBackgroundLabel: String { self == .english ? "Cultural background": "Kultureller Hintergrund" }
/// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab.
/// Unterstützte Sprachen: de, en alle anderen fallen auf .german zurück.
+145 -17
View File
@@ -10,6 +10,8 @@ struct TodayView: View {
}, sort: \Moment.createdAt, order: .reverse)
private var pendingAftermaths: [Moment]
@State private var selectedMomentForAftermath: Moment? = nil
@State private var showPersonPicker = false
@State private var personForNewMoment: Person? = nil
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
private var needsAttention: [Person] {
@@ -34,18 +36,20 @@ struct TodayView: View {
return allUnratedMeetings.filter { $0.createdAt > now && $0.createdAt <= horizon }
}
// V5: Vorhaben-Momente mit Erinnerung innerhalb des Zeitfensters
// Alle planbaren Momente (Treffen, Gespräch, Vorhaben) mit gesetzter Erinnerung
@Query(filter: #Predicate<Moment> {
$0.typeRaw == "Vorhaben" && !$0.isCompleted && $0.reminderDate != nil
$0.typeRaw != "Gedanke" && !$0.isCompleted && $0.reminderDate != nil
}, sort: \Moment.reminderDate)
private var allIntentionsWithReminder: [Moment]
private var allWithReminder: [Moment]
private var upcomingReminders: [Moment] {
let now = Date()
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now
return allIntentionsWithReminder.filter { m in
// Momente, die bereits über das Kalenderzeiger in plannedMeetings erscheinen, ausblenden
let plannedIDs = Set(plannedMeetings.map { $0.id })
return allWithReminder.filter { m in
guard let r = m.reminderDate else { return false }
return r >= now && r <= horizon
return r >= now && r <= horizon && !plannedIDs.contains(m.id)
}
}
@@ -60,6 +64,10 @@ struct TodayView: View {
&& upcomingReminders.isEmpty && pendingAftermaths.isEmpty && plannedMeetings.isEmpty
}
private var activePeople: [Person] {
people.filter { !$0.isArchived }.sorted { $0.name < $1.name }
}
private var birthdaySectionTitle: LocalizedStringKey {
switch daysAhead {
case 3: return "In 3 Tagen"
@@ -135,11 +143,11 @@ struct TodayView: View {
}
if !upcomingReminders.isEmpty {
TodaySection(title: "Anstehende Unternehmungen", icon: "calendar") {
TodaySection(title: "Anstehende Erinnerungen", icon: "bell") {
ForEach(upcomingReminders) { moment in
if let person = moment.person {
NavigationLink(destination: PersonDetailView(person: person)) {
TodayRow(person: person, hint: intentionReminderHint(for: moment))
TodayRow(person: person, hint: reminderHint(for: moment))
}
.buttonStyle(.plain)
}
@@ -223,20 +231,58 @@ struct TodayView: View {
.sheet(item: $selectedMomentForAftermath) { moment in
AftermathRatingFlowView(moment: moment)
}
.sheet(isPresented: $showPersonPicker) {
TodayPersonPickerSheet(people: activePeople) { person in
personForNewMoment = person
}
}
.sheet(item: $personForNewMoment) { person in
AddMomentView(person: person)
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 10) {
Spacer().frame(height: 48)
Text("Ein ruhiger Tag.")
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Text("Oder einer, der es noch wird.")
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary)
VStack(spacing: 24) {
Spacer().frame(height: 32)
VStack(spacing: 6) {
Text("Ein ruhiger Tag.")
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Text("Oder einer, der es noch wird.")
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary)
}
Button {
showPersonPicker = true
} label: {
HStack(spacing: 14) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 24))
VStack(alignment: .leading, spacing: 2) {
Text("Fangen wir an")
.font(.system(size: 16, weight: .semibold, design: theme.displayDesign))
Text("Momente planen und hinzufügen")
.font(.system(size: 13))
.opacity(0.8)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.opacity(0.6)
}
.foregroundStyle(theme.backgroundPrimary)
.padding(.horizontal, 20)
.padding(.vertical, 18)
.background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
}
.frame(maxWidth: .infinity)
}
@@ -265,10 +311,11 @@ struct TodayView: View {
return moment.text.isEmpty ? dateStr : "\(moment.text) · \(dateStr)"
}
private func intentionReminderHint(for moment: Moment) -> String {
private func reminderHint(for moment: Moment) -> String {
guard let reminder = moment.reminderDate else { return moment.text }
let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute())
return "\(moment.text) · \(dateStr)"
let label = moment.text.isEmpty ? moment.type.displayName : moment.text
return "\(label) · \(dateStr)"
}
private func lastSeenHint(for person: Person) -> String {
@@ -480,6 +527,87 @@ struct GiftSuggestionRow: View {
}
}
// MARK: - Person Picker Sheet (für Quick-Moment-Erfassung)
private struct TodayPersonPickerSheet: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) var dismiss
let people: [Person]
let onSelect: (Person) -> Void
var body: some View {
NavigationStack {
Group {
if people.isEmpty {
VStack(spacing: 12) {
Image(systemName: "person.2")
.font(.system(size: 40))
.foregroundStyle(theme.contentTertiary)
Text("Noch keine Kontakte")
.font(.system(size: 18, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
Text("Füge zuerst Personen im Tab \u{201E}Menschen\u{201C} hinzu.")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
VStack(spacing: 0) {
ForEach(people) { person in
Button {
onSelect(person)
dismiss()
} label: {
HStack(spacing: 12) {
PersonAvatar(person: person, size: 40)
VStack(alignment: .leading, spacing: 2) {
Text(person.name)
.font(.system(size: 15, weight: .medium, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
if !person.tag.rawValue.isEmpty {
Text(person.tag.rawValue)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
if person.id != people.last?.id {
RowDivider()
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
.padding(.vertical, 8)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
}
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Für wen?")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
}
}
}
}
// MARK: - Today Section
struct TodaySection<Content: View>: View {
@@ -0,0 +1,142 @@
import Testing
@testable import nahbar
@Suite("AIPayloadSanitizer")
struct AIPayloadSanitizerTests {
// MARK: - Sauberer Text
@Test("Sauberer Text wird unverändert durchgereicht")
func cleanTextPassesThrough() {
let text = "Haben uns beim Konzert getroffen, war ein schöner Abend."
#expect(AIPayloadSanitizer.sanitize(text) == text)
}
@Test("Leerer String bleibt leer")
func emptyStringStaysEmpty() {
#expect(AIPayloadSanitizer.sanitize("") == "")
}
// MARK: - E-Mail
@Test("E-Mail-Adresse wird maskiert")
func emailIsMasked() {
let result = AIPayloadSanitizer.sanitize("Schreib mir an max.muster@example.com bitte.")
#expect(result.contains("[E-Mail]"))
#expect(!result.contains("max.muster@example.com"))
}
@Test("E-Mail mit Subdomain wird maskiert")
func emailWithSubdomainIsMasked() {
let result = AIPayloadSanitizer.sanitize("Kontakt: user@mail.company.de")
#expect(result.contains("[E-Mail]"))
#expect(!result.contains("@"))
}
@Test("Mehrere E-Mails werden alle maskiert")
func multipleEmailsAreMasked() {
let result = AIPayloadSanitizer.sanitize("a@b.de und c@d.com schreiben sich.")
#expect(!result.contains("@"))
}
// MARK: - URL
@Test("https-URL wird maskiert")
func httpsURLIsMasked() {
let result = AIPayloadSanitizer.sanitize("Schau mal: https://www.example.com/path?x=1")
#expect(result.contains("[Link]"))
#expect(!result.contains("https://"))
}
@Test("http-URL wird maskiert")
func httpURLIsMasked() {
let result = AIPayloadSanitizer.sanitize("Infos unter http://example.com")
#expect(result.contains("[Link]"))
}
@Test("www-URL ohne Protokoll wird maskiert")
func wwwURLIsMasked() {
let result = AIPayloadSanitizer.sanitize("Besuche www.example.com für Details.")
#expect(result.contains("[Link]"))
#expect(!result.contains("www.example.com"))
}
// MARK: - IBAN
@Test("Unformatierte IBAN wird maskiert")
func ibanWithoutSpacesIsMasked() {
let result = AIPayloadSanitizer.sanitize("Konto: DE89370400440532013000")
#expect(result.contains("[IBAN]"))
#expect(!result.contains("DE89"))
}
// MARK: - Telefonnummern
@Test("Internationale Rufnummer (+49) wird maskiert")
func internationalPhoneIsMasked() {
let result = AIPayloadSanitizer.sanitize("Ruf mich an: +49 30 12345678")
#expect(result.contains("[Telefon]"))
#expect(!result.contains("+49"))
}
@Test("Deutsche Mobilnummer wird maskiert")
func germanMobileIsMasked() {
let result = AIPayloadSanitizer.sanitize("Tel: 01512345678")
#expect(result.contains("[Telefon]"))
#expect(!result.contains("01512345678"))
}
@Test("Deutsche Festnetznummer mit Trenner wird maskiert")
func germanLandlineWithSeparatorIsMasked() {
let result = AIPayloadSanitizer.sanitize("Erreichbar unter 030 12345678.")
#expect(result.contains("[Telefon]"))
}
@Test("Jahreszahl (4-stellig) wird NICHT als Telefon maskiert")
func yearIsNotMasked() {
let result = AIPayloadSanitizer.sanitize("Wir kennen uns seit 2019.")
#expect(!result.contains("[Telefon]"))
#expect(result.contains("2019"))
}
// MARK: - Lange Zahlenfolgen
@Test("8-stellige Zahl wird als [Nummer] maskiert")
func eightDigitNumberIsMasked() {
let result = AIPayloadSanitizer.sanitize("Ausweis: 12345678")
#expect(result.contains("[Nummer]"))
#expect(!result.contains("12345678"))
}
@Test("5-stellige PLZ wird NICHT als [Nummer] maskiert")
func postalCodeIsNotMasked() {
let result = AIPayloadSanitizer.sanitize("Wohnt in 80331 München.")
#expect(!result.contains("[Nummer]"))
#expect(result.contains("80331"))
}
@Test("7-stellige Zahl wird NICHT maskiert")
func sevenDigitNumberIsNotMasked() {
let result = AIPayloadSanitizer.sanitize("Referenz: 1234567")
#expect(!result.contains("[Nummer]"))
}
// MARK: - Kombiniert
@Test("Mehrere PII-Typen im selben Text werden alle maskiert")
func multiplePIITypesAreMasked() {
let text = "Kontakt: hans@test.de, Tel: +49 151 12345678, IBAN: DE89370400440532013000"
let result = AIPayloadSanitizer.sanitize(text)
#expect(!result.contains("hans@test.de"))
#expect(!result.contains("+49"))
#expect(!result.contains("DE89"))
#expect(result.contains("[E-Mail]"))
#expect(result.contains("[Telefon]"))
}
@Test("Vorname bleibt erhalten")
func firstNameIsPreserved() {
let result = AIPayloadSanitizer.sanitize("Treffen mit Maria war schön.")
#expect(result.contains("Maria"))
}
}
+25 -11
View File
@@ -91,18 +91,17 @@ struct MomentTypeTests {
}
}
@Test("displayName entspricht rawValue außer für .intention")
func displayNameEqualsRawValueExceptIntention() {
for type_ in MomentType.allCases where type_ != .intention {
#expect(type_.displayName == type_.rawValue,
"displayName sollte rawValue sein für \(type_)")
}
}
@Test(".intention hat displayName 'Unternehmung' (entkoppelt von rawValue 'Vorhaben')")
func intentionDisplayNameIsUnternehmung() {
@Test("displayName ist von rawValue entkoppelt für .conversation und .intention")
func displayNameDecoupledFromRawValue() {
// .conversation hat eigenen Anzeigenamen
#expect(MomentType.conversation.displayName == "Gespräch/Chat")
#expect(MomentType.conversation.rawValue == "Gespräch") // Persistenz-Key bleibt
// .meeting und .thought bleiben gleich
#expect(MomentType.meeting.displayName == MomentType.meeting.rawValue)
#expect(MomentType.thought.displayName == MomentType.thought.rawValue)
// .intention (legacy) hat ebenfalls eigenen Anzeigenamen
#expect(MomentType.intention.displayName == "Unternehmung")
#expect(MomentType.intention.rawValue == "Vorhaben") // Persistenz-Key bleibt
#expect(MomentType.intention.rawValue == "Vorhaben") // Persistenz-Key bleibt
}
@Test("alle Types haben nicht-leeres displayName")
@@ -111,6 +110,21 @@ struct MomentTypeTests {
#expect(!type_.displayName.isEmpty)
}
}
@Test("allCases enthält genau die 3 aktiven Typen .intention ist ausgeblendet")
func allCasesExcludesIntention() {
let cases = MomentType.allCases
#expect(cases.count == 3)
#expect(cases.contains(.conversation))
#expect(cases.contains(.meeting))
#expect(cases.contains(.thought))
#expect(!cases.contains(.intention))
}
@Test(".intention rawValue round-trip Rückwärtskompatibilität")
func intentionRawValueRoundTrip() {
#expect(MomentType(rawValue: "Vorhaben") == .intention)
}
}
// MARK: - MomentSource Tests