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 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 // MARK: - Configuration
struct AIConfig: Decodable { struct AIConfig: Decodable {
@@ -225,21 +293,23 @@ class AIAnalysisService {
formatter.locale = Locale.current formatter.locale = Locale.current
let momentLines = person.sortedMoments.prefix(30).map { 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") }.joined(separator: "\n")
let logLines = person.sortedLogEntries.prefix(20).map { 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") }.joined(separator: "\n")
let moments = momentLines.isEmpty ? "" : "\(lang.momentsLabel) (\(person.sortedMoments.count)):\n\(momentLines)\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 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 let instruction = isGift ? lang.giftInstruction : lang.analysisInstruction
return "Person: \(person.firstName)\n" return "Person: \(person.firstName)\n"
+ birthYearContext(for: person, language: lang) + birthYearContext(for: person, language: lang)
+ interests + interests
+ culturalBg
+ "\n" + "\n"
+ moments + moments
+ "\n" + "\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() return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date()
}() }()
@State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag @State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag
@AppStorage("selectedCalendarID") private var defaultCalendarID: String = ""
@State private var availableCalendars: [EKCalendar] = [] @State private var availableCalendars: [EKCalendar] = []
@State private var selectedCalendarID: String = "" @State private var selectedCalendarID: String = ""
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung @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 isValid: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty }
private var showsCalendarSection: Bool { selectedType == .meeting } private var showsCalendarSection: Bool { selectedType != .thought }
private var showsReminderSection: Bool { selectedType == .intention } 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 { private var placeholder: String {
switch selectedType { switch selectedType {
case .meeting: return "Wo habt ihr euch getroffen?\nWas habt ihr unternommen?" case .meeting:
case .intention: return "Was möchtest du mit dieser Person machen?" return isFutureMeeting
case .thought: return "Welcher Gedanke kam dir in den Sinn?" ? "Wo trefft ihr euch?\nWas habt ihr vor?"
case .conversation: return "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?" : "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 availableCalendars = calendars
// Vorauswahl: gespeicherter Kalender oder der Standard // Vorauswahl: gespeicherter Kalender oder der Standard
if selectedCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(selectedCalendarID) { 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.moments?.append(moment)
person.touch() person.touch()
// Vorhaben: Erinnerung speichern + Notification planen // Erinnerung (lokale Notification) unabhängig vom Kalendereintrag
if selectedType == .intention && addReminder { if addReminder {
moment.reminderDate = reminderDate moment.reminderDate = reminderDate
scheduleIntentionReminder(for: moment) 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 { do {
try modelContext.save() try modelContext.save()
} catch { } catch {
@@ -335,25 +371,8 @@ struct AddMomentView: View {
) )
} }
// Treffen: Callback für Rating-Flow + evtl. Kalendertermin // Treffen: Callback für Rating-Flow nur wenn das Treffen bereits stattgefunden hat
if selectedType == .meeting { if selectedType == .meeting && !isFutureMeeting {
// 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)
}
}
dismiss() dismiss()
onMeetingCreated?(moment) onMeetingCreated?(moment)
return return
@@ -362,6 +381,18 @@ struct AddMomentView: View {
dismiss() 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 // MARK: - Vorhaben-Erinnerung
private func scheduleIntentionReminder(for moment: Moment) { private func scheduleIntentionReminder(for moment: Moment) {
@@ -403,7 +434,12 @@ struct AddMomentView: View {
} }
let calendarID = selectedCalendarID.isEmpty ? nil : selectedCalendarID 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 let alarmOffset: TimeInterval? = eventAlarmOffset == 0 ? nil : eventAlarmOffset
if let identifier = await CalendarManager.shared.createEvent( if let identifier = await CalendarManager.shared.createEvent(
+6
View File
@@ -16,6 +16,7 @@ struct AddPersonView: View {
@State private var location = "" @State private var location = ""
@State private var interests = "" @State private var interests = ""
@State private var generalNotes = "" @State private var generalNotes = ""
@State private var culturalBackground = ""
@State private var hasBirthday = false @State private var hasBirthday = false
@State private var birthday = Date() @State private var birthday = Date()
@State private var nudgeFrequency: NudgeFrequency = .monthly @State private var nudgeFrequency: NudgeFrequency = .monthly
@@ -85,6 +86,8 @@ struct AddPersonView: View {
RowDivider() RowDivider()
inlineField("Interessen", text: $interests) inlineField("Interessen", text: $interests)
RowDivider() RowDivider()
inlineField("Herkunft", text: $culturalBackground)
RowDivider()
inlineField("Notizen", text: $generalNotes) inlineField("Notizen", text: $generalNotes)
} }
.background(theme.surfaceCard) .background(theme.surfaceCard)
@@ -383,6 +386,7 @@ struct AddPersonView: View {
occupation = p.occupation ?? "" occupation = p.occupation ?? ""
location = p.location ?? "" location = p.location ?? ""
interests = p.interests ?? "" interests = p.interests ?? ""
culturalBackground = p.culturalBackground ?? ""
generalNotes = p.generalNotes ?? "" generalNotes = p.generalNotes ?? ""
hasBirthday = p.birthday != nil hasBirthday = p.birthday != nil
birthday = p.birthday ?? Date() birthday = p.birthday ?? Date()
@@ -405,6 +409,7 @@ struct AddPersonView: View {
p.occupation = occupation.isEmpty ? nil : occupation p.occupation = occupation.isEmpty ? nil : occupation
p.location = location.isEmpty ? nil : location p.location = location.isEmpty ? nil : location
p.interests = interests.isEmpty ? nil : interests p.interests = interests.isEmpty ? nil : interests
p.culturalBackground = culturalBackground.isEmpty ? nil : culturalBackground
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
p.birthday = hasBirthday ? birthday : nil p.birthday = hasBirthday ? birthday : nil
p.nudgeFrequency = nudgeFrequency p.nudgeFrequency = nudgeFrequency
@@ -419,6 +424,7 @@ struct AddPersonView: View {
location: location.isEmpty ? nil : location, location: location.isEmpty ? nil : location,
interests: interests.isEmpty ? nil : interests, interests: interests.isEmpty ? nil : interests,
generalNotes: generalNotes.isEmpty ? nil : generalNotes, generalNotes: generalNotes.isEmpty ? nil : generalNotes,
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
nudgeFrequency: nudgeFrequency nudgeFrequency: nudgeFrequency
) )
modelContext.insert(person) modelContext.insert(person)
+159 -3
View File
@@ -116,6 +116,7 @@
} }
}, },
"%lld ausgewählt" : { "%lld ausgewählt" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"variations" : { "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" : { "%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.", "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, "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" : { "Anstehende Unternehmungen" : {
"comment" : "TodayView section title for intention moments with reminder dates", "comment" : "TodayView legacy key, replaced by 'Anstehende Erinnerungen'",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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." : { "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", "comment" : "AIConsentSheet body text",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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?" : { "Diese Person wirklich löschen?" : {
"comment" : "AddPersonView delete person confirmation title", "comment" : "AddPersonView delete person confirmation title",
"localizations" : { "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: %@" : { "Fehler: %@" : {
"comment" : "TodayView GiftSuggestionRow error message with description", "comment" : "TodayView GiftSuggestionRow error message with description",
"localizations" : { "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?" : { "Fühlt sich die Beziehung gestärkt an?" : {
"comment" : "RatingQuestion relationship question text", "comment" : "RatingQuestion relationship question text",
"extractionState" : "stale", "extractionState" : "stale",
@@ -2245,6 +2312,17 @@
} }
} }
}, },
"Für wen?" : {
"comment" : "TodayPersonPickerSheet navigation title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "For whom?"
}
}
}
},
"Ganztag" : { "Ganztag" : {
"comment" : "AddMomentView all-day calendar event duration option", "comment" : "AddMomentView all-day calendar event duration option",
"localizations" : { "localizations" : {
@@ -2281,6 +2359,7 @@
}, },
"Geburtstage & Termine" : { "Geburtstage & Termine" : {
"comment" : "SettingsView look-ahead section row label", "comment" : "SettingsView look-ahead section row label",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2390,7 +2469,7 @@
} }
}, },
"Gespräch" : { "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", "extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "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" : { "Gesprächseinstieg" : {
"comment" : "CallSuggestionView conversation starter section header", "comment" : "CallSuggestionView conversation starter section header",
"localizations" : { "localizations" : {
@@ -2791,6 +2908,9 @@
} }
} }
} }
},
"Kalender-Einstellungen" : {
}, },
"Kauf wiederherstellen" : { "Kauf wiederherstellen" : {
"comment" : "PaywallView restore purchases button", "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." : { "Momente und abgeschlossene Schritte erscheinen hier." : {
"comment" : "LogbuchView empty state subtitle", "comment" : "LogbuchView empty state subtitle",
"localizations" : { "localizations" : {
@@ -4578,6 +4709,19 @@
} }
} }
}, },
"Termin mit %@" : {
},
"Termin mit %@ — %@" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "new",
"value" : "Termin mit %1$@ — %2$@"
}
}
}
},
"Tief & fokussiert · ND" : { "Tief & fokussiert · ND" : {
"comment" : "Theme tagline for Abyss", "comment" : "Theme tagline for Abyss",
"localizations" : { "localizations" : {
@@ -4823,7 +4967,7 @@
} }
}, },
"Unternehmung" : { "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" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -4990,6 +5134,7 @@
}, },
"Vorausschau" : { "Vorausschau" : {
"comment" : "SettingsView section header for look-ahead settings", "comment" : "SettingsView section header for look-ahead settings",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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" : { "Wähle deinen Plan" : {
"comment" : "PaywallView header title", "comment" : "PaywallView header title",
@@ -5065,6 +5219,7 @@
} }
}, },
"Wähle Menschen aus deinem Adressbuch, die dir wichtig sind." : { "Wähle Menschen aus deinem Adressbuch, die dir wichtig sind." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -5075,6 +5230,7 @@
} }
}, },
"Wähle Menschen aus, die dir wichtig sind." : { "Wähle Menschen aus, die dir wichtig sind." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
+10 -4
View File
@@ -624,10 +624,11 @@ private struct DeletableLogbuchRow<Content: View>: View {
.background(theme.surfaceCard) .background(theme.surfaceCard)
.offset(x: offset) .offset(x: offset)
.gesture( .gesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local) DragGesture(minimumDistance: 50, coordinateSpace: .local)
.onChanged { value in .onChanged { value in
let x = value.translation.width 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 { if x > 0 {
offset = min(x, actionWidth * 2 + 16) offset = min(x, actionWidth * 2 + 16)
} else { } else {
@@ -636,12 +637,17 @@ private struct DeletableLogbuchRow<Content: View>: View {
} }
.onEnded { value in .onEnded { value in
let x = value.translation.width 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 { if x > actionWidth * 2 + 20 {
onToggleImportant() onToggleImportant()
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } 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 } 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 } withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
} else { } else {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } 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 conversation = "Gespräch"
case meeting = "Treffen" // rawValue bleibt für Persistenz unverändert case meeting = "Treffen" // rawValue bleibt für Persistenz unverändert
case thought = "Gedanke" 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. /// Anzeigename im UI entkoppelt Persistenzschlüssel von der Darstellung.
var displayName: String { var displayName: String {
switch self { switch self {
case .intention: return "Unternehmung" case .conversation: return "Gespräch/Chat"
default: return rawValue case .intention: return "Unternehmung" // legacy
default: return rawValue
} }
} }
@@ -111,6 +115,7 @@ class Person {
var location: String? var location: String?
var interests: String? var interests: String?
var generalNotes: String? var generalNotes: String?
var culturalBackground: String? = nil // V6: kultureller Hintergrund
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
var nextStep: String? var nextStep: String?
var nextStepCompleted: Bool = false var nextStepCompleted: Bool = false
@@ -139,6 +144,7 @@ class Person {
location: String? = nil, location: String? = nil,
interests: String? = nil, interests: String? = nil,
generalNotes: String? = nil, generalNotes: String? = nil,
culturalBackground: String? = nil,
nudgeFrequency: NudgeFrequency = .monthly nudgeFrequency: NudgeFrequency = .monthly
) { ) {
self.id = UUID() self.id = UUID()
@@ -149,6 +155,7 @@ class Person {
self.location = location self.location = location
self.interests = interests self.interests = interests
self.generalNotes = generalNotes self.generalNotes = generalNotes
self.culturalBackground = culturalBackground
self.nudgeFrequencyRaw = nudgeFrequency.rawValue self.nudgeFrequencyRaw = nudgeFrequency.rawValue
self.photoData = nil self.photoData = nil
self.photo = nil self.photo = nil
+121 -6
View File
@@ -289,11 +289,11 @@ enum NahbarSchemaV4: VersionedSchema {
} }
} }
// MARK: - Schema V5 (aktuelles Schema) // MARK: - Schema V5 (eingefrorener Snapshot)
// Referenziert die Live-Typen aus Models.swift. // WICHTIG: Niemals nachträglich ändern dieser Snapshot muss dem gespeicherten
// Beim Hinzufügen von V6 muss V5 als eingefrorener Snapshot gesichert werden. // Schema-Hash von V5-Datenbanken auf Nutzer-Geräten entsprechen.
// //
// V5 fügt hinzu: // V5 fügte hinzu:
// Moment: ratings, healthSnapshot, statusRaw, aftermathNotificationScheduled, // Moment: ratings, healthSnapshot, statusRaw, aftermathNotificationScheduled,
// aftermathCompletedAt, reminderDate, isCompleted // aftermathCompletedAt, reminderDate, isCompleted
// Rating: moment-Relationship (neben legacy visit) // Rating: moment-Relationship (neben legacy visit)
@@ -301,6 +301,117 @@ enum NahbarSchemaV4: VersionedSchema {
enum NahbarSchemaV5: VersionedSchema { enum NahbarSchemaV5: VersionedSchema {
static var versionIdentifier = Schema.Version(5, 0, 0) 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] { static var models: [any PersistentModel.Type] {
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self, [nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self] nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self]
@@ -312,7 +423,7 @@ enum NahbarSchemaV5: VersionedSchema {
enum NahbarMigrationPlan: SchemaMigrationPlan { enum NahbarMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { static var schemas: [any VersionedSchema.Type] {
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self, [NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
NahbarSchemaV4.self, NahbarSchemaV5.self] NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self]
} }
static var stages: [MigrationStage] { static var stages: [MigrationStage] {
@@ -332,7 +443,11 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
// V4 V5: Moment bekommt rating/intention-Felder, Rating/HealthSnapshot // V4 V5: Moment bekommt rating/intention-Felder, Rating/HealthSnapshot
// bekommen moment-Relationship. Visit/HealthSnapshot bleiben im Schema // bekommen moment-Relationship. Visit/HealthSnapshot bleiben im Schema
// (CloudKit-Sicherheit). Alle neuen Felder haben Default-Werte lightweight. // (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 showingPicker = false
@State private var showSkipConfirmation: Bool = false @State private var showSkipConfirmation: Bool = false
private let maxContacts = 3
private var atLimit: Bool { coordinator.selectedContacts.count >= maxContacts }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -402,7 +405,7 @@ private struct OnboardingContactImportView: View {
Text("Kontakte hinzufügen") Text("Kontakte hinzufügen")
.font(.title.bold()) .font(.title.bold())
.multilineTextAlignment(.center) .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) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -492,7 +495,7 @@ private struct OnboardingContactImportView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
Text("Noch keine Kontakte") Text("Noch keine Kontakte")
.font(.title3.bold()) .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) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -540,13 +543,17 @@ private struct OnboardingContactImportView: View {
} }
} header: { } header: {
HStack { HStack {
Text("\(coordinator.selectedContacts.count) ausgewählt") Text(atLimit
? "\(coordinator.selectedContacts.count) von \(maxContacts) — Maximum erreicht"
: "\(coordinator.selectedContacts.count) von \(maxContacts) ausgewählt")
Spacer() Spacer()
Button { if !atLimit {
showingPicker = true Button {
} label: { showingPicker = true
Label("Weitere hinzufügen", systemImage: "plus") } label: {
.font(.caption.weight(.medium)) 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). /// Merges newly picked contacts into the existing selection (no duplicates).
private func mergeContacts(_ contacts: [CNContact]) { private func mergeContacts(_ contacts: [CNContact]) {
for contact in contacts { for contact in contacts {
guard coordinator.selectedContacts.count < maxContacts else { break }
let alreadySelected = coordinator.selectedContacts let alreadySelected = coordinator.selectedContacts
.contains { $0.cnIdentifier == contact.identifier } .contains { $0.cnIdentifier == contact.identifier }
if !alreadySelected { if !alreadySelected {
+9 -7
View File
@@ -326,6 +326,7 @@ struct PersonDetailView: View {
person.birthday != nil person.birthday != nil
|| !(person.location ?? "").isEmpty || !(person.location ?? "").isEmpty
|| !(person.interests ?? "").isEmpty || !(person.interests ?? "").isEmpty
|| !(person.culturalBackground ?? "").isEmpty
|| !(person.generalNotes ?? "").isEmpty || !(person.generalNotes ?? "").isEmpty
} }
@@ -349,6 +350,10 @@ struct PersonDetailView: View {
InfoRowView(label: "Interessen", value: interests) InfoRowView(label: "Interessen", value: interests)
RowDivider() RowDivider()
} }
if let bg = person.culturalBackground, !bg.isEmpty {
InfoRowView(label: "Herkunft", value: bg)
RowDivider()
}
if let notes = person.generalNotes, !notes.isEmpty { if let notes = person.generalNotes, !notes.isEmpty {
InfoRowView(label: "Notizen", value: notes) InfoRowView(label: "Notizen", value: notes)
} }
@@ -518,7 +523,7 @@ private struct DeletableMomentRow: View {
.background(theme.surfaceCard) .background(theme.surfaceCard)
.offset(x: offset) .offset(x: offset)
.simultaneousGesture( .simultaneousGesture(
DragGesture(minimumDistance: 20, coordinateSpace: .local) DragGesture(minimumDistance: 50, coordinateSpace: .local)
.onChanged { value in .onChanged { value in
let x = value.translation.width let x = value.translation.width
let y = value.translation.height let y = value.translation.height
@@ -542,13 +547,10 @@ private struct DeletableMomentRow: View {
// Vollständiges Rechts-Wischen Wichtig-Toggle, zurückspringen // Vollständiges Rechts-Wischen Wichtig-Toggle, zurückspringen
onToggleImportant() onToggleImportant()
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
} else if x > actionWidth { } else if x > actionWidth * 1.5 {
// Mehr als eine Button-Breite beide linken Buttons zeigen // Bewusstes Rechts-Wischen linke Buttons zeigen
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 } withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
} else if x > actionWidth / 2 { } else if x < -(actionWidth * 1.5) {
// Knapp eine Button-Breite beide linken Buttons zeigen
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
} else if x < -(actionWidth / 2) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth } withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
} else { } else {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
+1 -1
View File
@@ -133,7 +133,7 @@ struct AIConsentSheet: View {
.font(.title2.bold()) .font(.title2.bold())
.multilineTextAlignment(.center) .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) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
+42 -7
View File
@@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import LocalAuthentication import LocalAuthentication
import EventKit
struct SettingsView: View { struct SettingsView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@@ -10,6 +11,8 @@ struct SettingsView: View {
@EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor @EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 @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("aftermathNotificationsEnabled") private var aftermathNotificationsEnabled: Bool = true
@AppStorage("aftermathDelayOption") private var aftermathDelayRaw: String = AftermathDelayOption.hours36.rawValue @AppStorage("aftermathDelayOption") private var aftermathDelayRaw: String = AftermathDelayOption.hours36.rawValue
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false @AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
@@ -265,14 +268,14 @@ struct SettingsView: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
} }
// Vorausschau // Kalender-Einstellungen
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Vorausschau", icon: "calendar") SectionHeader(title: "Kalender-Einstellungen", icon: "calendar")
.padding(.horizontal, 20) .padding(.horizontal, 20)
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Text("Geburtstage & Termine") Text("Vorschau Geburtstage & Termine")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Spacer() Spacer()
@@ -286,10 +289,41 @@ struct SettingsView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .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) .background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20) .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 // Treffen & Bewertungen
@@ -769,10 +803,11 @@ enum AppLanguage: String, CaseIterable {
} }
} }
var momentsLabel: String { self == .english ? "Moments" : "Momente" } var momentsLabel: String { self == .english ? "Moments" : "Momente" }
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" } var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" } var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
var interestsLabel: String { self == .english ? "Interests" : "Interessen" } 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. /// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab.
/// Unterstützte Sprachen: de, en alle anderen fallen auf .german zurück. /// 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) }, sort: \Moment.createdAt, order: .reverse)
private var pendingAftermaths: [Moment] private var pendingAftermaths: [Moment]
@State private var selectedMomentForAftermath: Moment? = nil @State private var selectedMomentForAftermath: Moment? = nil
@State private var showPersonPicker = false
@State private var personForNewMoment: Person? = nil
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
private var needsAttention: [Person] { private var needsAttention: [Person] {
@@ -34,18 +36,20 @@ struct TodayView: View {
return allUnratedMeetings.filter { $0.createdAt > now && $0.createdAt <= horizon } 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> { @Query(filter: #Predicate<Moment> {
$0.typeRaw == "Vorhaben" && !$0.isCompleted && $0.reminderDate != nil $0.typeRaw != "Gedanke" && !$0.isCompleted && $0.reminderDate != nil
}, sort: \Moment.reminderDate) }, sort: \Moment.reminderDate)
private var allIntentionsWithReminder: [Moment] private var allWithReminder: [Moment]
private var upcomingReminders: [Moment] { private var upcomingReminders: [Moment] {
let now = Date() let now = Date()
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now 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 } 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 && upcomingReminders.isEmpty && pendingAftermaths.isEmpty && plannedMeetings.isEmpty
} }
private var activePeople: [Person] {
people.filter { !$0.isArchived }.sorted { $0.name < $1.name }
}
private var birthdaySectionTitle: LocalizedStringKey { private var birthdaySectionTitle: LocalizedStringKey {
switch daysAhead { switch daysAhead {
case 3: return "In 3 Tagen" case 3: return "In 3 Tagen"
@@ -135,11 +143,11 @@ struct TodayView: View {
} }
if !upcomingReminders.isEmpty { if !upcomingReminders.isEmpty {
TodaySection(title: "Anstehende Unternehmungen", icon: "calendar") { TodaySection(title: "Anstehende Erinnerungen", icon: "bell") {
ForEach(upcomingReminders) { moment in ForEach(upcomingReminders) { moment in
if let person = moment.person { if let person = moment.person {
NavigationLink(destination: PersonDetailView(person: person)) { NavigationLink(destination: PersonDetailView(person: person)) {
TodayRow(person: person, hint: intentionReminderHint(for: moment)) TodayRow(person: person, hint: reminderHint(for: moment))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -223,20 +231,58 @@ struct TodayView: View {
.sheet(item: $selectedMomentForAftermath) { moment in .sheet(item: $selectedMomentForAftermath) { moment in
AftermathRatingFlowView(moment: moment) AftermathRatingFlowView(moment: moment)
} }
.sheet(isPresented: $showPersonPicker) {
TodayPersonPickerSheet(people: activePeople) { person in
personForNewMoment = person
}
}
.sheet(item: $personForNewMoment) { person in
AddMomentView(person: person)
}
} }
} }
// MARK: - Empty State // MARK: - Empty State
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: 10) { VStack(spacing: 24) {
Spacer().frame(height: 48) Spacer().frame(height: 32)
Text("Ein ruhiger Tag.")
.font(.system(size: 20, weight: .light, design: theme.displayDesign)) VStack(spacing: 6) {
.foregroundStyle(theme.contentPrimary) Text("Ein ruhiger Tag.")
Text("Oder einer, der es noch wird.") .font(.system(size: 20, weight: .light, design: theme.displayDesign))
.font(.system(size: 15)) .foregroundStyle(theme.contentPrimary)
.foregroundStyle(theme.contentTertiary) 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) .frame(maxWidth: .infinity)
} }
@@ -265,10 +311,11 @@ struct TodayView: View {
return moment.text.isEmpty ? dateStr : "\(moment.text) · \(dateStr)" 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 } guard let reminder = moment.reminderDate else { return moment.text }
let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute()) 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 { 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 // MARK: - Today Section
struct TodaySection<Content: View>: View { 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") @Test("displayName ist von rawValue entkoppelt für .conversation und .intention")
func displayNameEqualsRawValueExceptIntention() { func displayNameDecoupledFromRawValue() {
for type_ in MomentType.allCases where type_ != .intention { // .conversation hat eigenen Anzeigenamen
#expect(type_.displayName == type_.rawValue, #expect(MomentType.conversation.displayName == "Gespräch/Chat")
"displayName sollte rawValue sein für \(type_)") #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)
@Test(".intention hat displayName 'Unternehmung' (entkoppelt von rawValue 'Vorhaben')") // .intention (legacy) hat ebenfalls eigenen Anzeigenamen
func intentionDisplayNameIsUnternehmung() {
#expect(MomentType.intention.displayName == "Unternehmung") #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") @Test("alle Types haben nicht-leeres displayName")
@@ -111,6 +110,21 @@ struct MomentTypeTests {
#expect(!type_.displayName.isEmpty) #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 // MARK: - MomentSource Tests