From c30fb4e5188121c3e4aeda9403eb5c6b78998de0 Mon Sep 17 00:00:00 2001 From: Sven Date: Tue, 21 Apr 2026 20:56:50 +0200 Subject: [PATCH] =?UTF-8?q?Issues=201=E2=80=937:=20Kalender,=20Swipe,=20Mo?= =?UTF-8?q?mente,=20Onboarding,=20Herkunft,=20Planung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nahbar/nahbar/AIAnalysisService.swift | 76 +++++++- nahbar/nahbar/AddMomentView.swift | 94 ++++++---- nahbar/nahbar/AddPersonView.swift | 6 + nahbar/nahbar/Localizable.xcstrings | 162 +++++++++++++++++- nahbar/nahbar/LogbuchView.swift | 14 +- nahbar/nahbar/Models.swift | 15 +- nahbar/nahbar/NahbarMigration.swift | 127 +++++++++++++- nahbar/nahbar/OnboardingContainerView.swift | 24 ++- nahbar/nahbar/PersonDetailView.swift | 16 +- nahbar/nahbar/PrivacyBadgeView.swift | 2 +- nahbar/nahbar/SettingsView.swift | 49 +++++- nahbar/nahbar/TodayView.swift | 162 ++++++++++++++++-- .../nahbarTests/AIPayloadSanitizerTests.swift | 142 +++++++++++++++ nahbar/nahbarTests/ModelTests.swift | 36 ++-- 14 files changed, 825 insertions(+), 100 deletions(-) create mode 100644 nahbar/nahbarTests/AIPayloadSanitizerTests.swift diff --git a/nahbar/nahbar/AIAnalysisService.swift b/nahbar/nahbar/AIAnalysisService.swift index 08be210..132f190 100644 --- a/nahbar/nahbar/AIAnalysisService.swift +++ b/nahbar/nahbar/AIAnalysisService.swift @@ -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 (10–11 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): 10–11 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" diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift index acb6960..7d6c32b 100644 --- a/nahbar/nahbar/AddMomentView.swift +++ b/nahbar/nahbar/AddMomentView.swift @@ -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( diff --git a/nahbar/nahbar/AddPersonView.swift b/nahbar/nahbar/AddPersonView.swift index a5c108b..02b15c7 100644 --- a/nahbar/nahbar/AddPersonView.swift +++ b/nahbar/nahbar/AddPersonView.swift @@ -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) diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index a457786..eec20e1 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -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" : { diff --git a/nahbar/nahbar/LogbuchView.swift b/nahbar/nahbar/LogbuchView.swift index 979b416..f6f7d67 100644 --- a/nahbar/nahbar/LogbuchView.swift +++ b/nahbar/nahbar/LogbuchView.swift @@ -624,10 +624,11 @@ private struct DeletableLogbuchRow: 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: 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 } diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift index 10c8f8b..c3ff25b 100644 --- a/nahbar/nahbar/Models.swift +++ b/nahbar/nahbar/Models.swift @@ -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 diff --git a/nahbar/nahbar/NahbarMigration.swift b/nahbar/nahbar/NahbarMigration.swift index 722c209..5dcce3e 100644 --- a/nahbar/nahbar/NahbarMigration.swift +++ b/nahbar/nahbar/NahbarMigration.swift @@ -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) ] } } diff --git a/nahbar/nahbar/OnboardingContainerView.swift b/nahbar/nahbar/OnboardingContainerView.swift index 93dc19f..05a16e4 100644 --- a/nahbar/nahbar/OnboardingContainerView.swift +++ b/nahbar/nahbar/OnboardingContainerView.swift @@ -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 { diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 78c7c1e..30de3b3 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -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 } diff --git a/nahbar/nahbar/PrivacyBadgeView.swift b/nahbar/nahbar/PrivacyBadgeView.swift index 12a4b4e..b2e7e3f 100644 --- a/nahbar/nahbar/PrivacyBadgeView.swift +++ b/nahbar/nahbar/PrivacyBadgeView.swift @@ -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) diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift index 9975d54..a91d8a8 100644 --- a/nahbar/nahbar/SettingsView.swift +++ b/nahbar/nahbar/SettingsView.swift @@ -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. diff --git a/nahbar/nahbar/TodayView.swift b/nahbar/nahbar/TodayView.swift index 6c3d3bd..9cabcc8 100644 --- a/nahbar/nahbar/TodayView.swift +++ b/nahbar/nahbar/TodayView.swift @@ -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 { - $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: View { diff --git a/nahbar/nahbarTests/AIPayloadSanitizerTests.swift b/nahbar/nahbarTests/AIPayloadSanitizerTests.swift new file mode 100644 index 0000000..8cf7b6d --- /dev/null +++ b/nahbar/nahbarTests/AIPayloadSanitizerTests.swift @@ -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")) + } +} diff --git a/nahbar/nahbarTests/ModelTests.swift b/nahbar/nahbarTests/ModelTests.swift index f412faa..e04676a 100644 --- a/nahbar/nahbarTests/ModelTests.swift +++ b/nahbar/nahbarTests/ModelTests.swift @@ -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