diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj index d993050..8825d25 100644 --- a/nahbar/nahbar.xcodeproj/project.pbxproj +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 2670595C2F96640E00956084 /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2670595B2F96640E00956084 /* CalendarManager.swift */; }; 269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; }; 26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; }; 26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */; }; @@ -98,6 +99,7 @@ /* Begin PBXFileReference section */ 265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2670595B2F96640E00956084 /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = ""; }; 269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = ""; }; 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncMonitor.swift; sourceTree = ""; }; 26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileStore.swift; sourceTree = ""; }; @@ -284,6 +286,7 @@ 26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */, 26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */, 26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */, + 2670595B2F96640E00956084 /* CalendarManager.swift */, ); path = nahbar; sourceTree = ""; @@ -463,6 +466,7 @@ 26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */, 26EF66472F91351800824F91 /* AppLockView.swift in Sources */, 26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */, + 2670595C2F96640E00956084 /* CalendarManager.swift in Sources */, 26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */, 26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */, 26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */, @@ -653,7 +657,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = nahbar; - INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt KAlendereinträge für geplante Treffen"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst."; + INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -693,7 +698,8 @@ ENABLE_TESTABILITY = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = nahbar; - INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt KAlendereinträge für geplante Treffen"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst."; + INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate index 9a1d95e..c7103d5 100644 Binary files a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate and b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift index 4a0267a..acb6960 100644 --- a/nahbar/nahbar/AddMomentView.swift +++ b/nahbar/nahbar/AddMomentView.swift @@ -26,6 +26,9 @@ 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 + @State private var availableCalendars: [EKCalendar] = [] + @State private var selectedCalendarID: String = "" + @State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung // Vorhaben: Erinnerung @State private var addReminder = false @@ -203,11 +206,67 @@ struct AddMomentView: View { } .padding(.horizontal, 16) .padding(.vertical, 4) + + RowDivider() + + HStack { + Image(systemName: "bell") + .font(.system(size: 13)) + .foregroundStyle(eventAlarmOffset != 0 ? theme.accent : theme.contentTertiary) + Text("Erinnerung") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Picker("", selection: $eventAlarmOffset) { + Text("Keine").tag(0.0) + Text("5 Min vorher").tag(-300.0) + Text("15 Min vorher").tag(-900.0) + Text("1 Std vorher").tag(-3600.0) + Text("1 Tag vorher").tag(-86400.0) + } + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + + if availableCalendars.count > 1 { + RowDivider() + + HStack { + Text("Kalender") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Picker("", selection: $selectedCalendarID) { + ForEach(availableCalendars, id: \.calendarIdentifier) { cal in + HStack(spacing: 6) { + Circle() + .fill(Color(cgColor: cal.cgColor)) + .frame(width: 10, height: 10) + Text(cal.title) + } + .tag(cal.calendarIdentifier) + } + } + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + } } } .background(theme.surfaceCard) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .padding(.horizontal, 20) + .task(id: addToCalendar) { + guard addToCalendar && availableCalendars.isEmpty else { return } + let calendars = await CalendarManager.shared.availableCalendars() + availableCalendars = calendars + // Vorauswahl: gespeicherter Kalender oder der Standard + if selectedCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(selectedCalendarID) { + selectedCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? "" + } + } } // MARK: - Erinnerungs-Sektion (Vorhaben) @@ -290,8 +349,9 @@ struct AddMomentView: View { ) modelContext.insert(calEntry) person.logEntries?.append(calEntry) - createCalendarEvent(notes: trimmed) { - // Callback nach Dismiss + let momentID = moment.id + Task { + await createAndStoreCalendarEvent(for: momentID, notes: trimmed) } } dismiss() @@ -299,21 +359,7 @@ struct AddMomentView: View { return } - guard addToCalendar else { - dismiss() - return - } - - 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) - - createCalendarEvent(notes: trimmed) {} + dismiss() } // MARK: - Vorhaben-Erinnerung @@ -341,40 +387,35 @@ struct AddMomentView: View { } } - // MARK: - EventKit (callback-basiert, kein Swift Concurrency) + // MARK: - EventKit (async via CalendarManager) - private func createCalendarEvent(notes: String, completion: @escaping () -> Void) { - let store = EKEventStore() + private func createAndStoreCalendarEvent(for momentID: UUID, notes: String) async { + let isAllDay = eventDuration < 0 + let startDate: Date + let endDate: Date - let handler: (Bool, Error?) -> Void = { [store] granted, _ in - guard granted, let calendar = store.defaultCalendarForNewEvents else { - DispatchQueue.main.async { self.dismiss() } - return - } - - let event = EKEvent(eventStore: store) - event.title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), self.person.firstName) - event.notes = notes.isEmpty ? nil : notes - event.calendar = calendar - - if self.eventDuration < 0 { - event.isAllDay = true - let dayStart = Calendar.current.startOfDay(for: self.eventDate) - event.startDate = dayStart - event.endDate = Calendar.current.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart - } else { - event.startDate = self.eventDate - event.endDate = self.eventDate.addingTimeInterval(self.eventDuration) - } - - try? store.save(event, span: .thisEvent) - DispatchQueue.main.async { self.dismiss() } + if isAllDay { + startDate = Calendar.current.startOfDay(for: eventDate) + endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate) ?? startDate + } else { + startDate = eventDate + endDate = eventDate.addingTimeInterval(eventDuration) } - if #available(iOS 17.0, *) { - store.requestWriteOnlyAccessToEvents(completion: handler) - } else { - store.requestAccess(to: .event, completion: handler) + let calendarID = selectedCalendarID.isEmpty ? nil : selectedCalendarID + let title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), person.firstName) + let alarmOffset: TimeInterval? = eventAlarmOffset == 0 ? nil : eventAlarmOffset + + if let identifier = await CalendarManager.shared.createEvent( + title: title, + notes: notes.isEmpty ? nil : notes, + startDate: startDate, + endDate: endDate, + isAllDay: isAllDay, + calendarIdentifier: calendarID, + alarmOffset: alarmOffset + ) { + CalendarEventStore.save(momentID: momentID, eventIdentifier: identifier) } } } diff --git a/nahbar/nahbar/CalendarManager.swift b/nahbar/nahbar/CalendarManager.swift new file mode 100644 index 0000000..2d08f76 --- /dev/null +++ b/nahbar/nahbar/CalendarManager.swift @@ -0,0 +1,175 @@ +import EventKit +import Foundation + +// MARK: - CalendarEventStore +// Speichert die Zuordnung Moment-UUID → EKEvent-Identifier in UserDefaults. +// Dieser Mapping-Store ist bewusst device-lokal (EKEvent-IDs sind nicht cloud-sync-fähig). + +enum CalendarEventStore { + private static let key = "nahbar.momentCalendarEvents" + + /// Feuert wenn sich das Mapping ändert. Das `object` ist die betroffene Moment-UUID. + static let didChangeNotification = Notification.Name("CalendarEventStoreDidChange") + + static func save(momentID: UUID, eventIdentifier: String) { + var map = load() + map[momentID.uuidString] = eventIdentifier + UserDefaults.standard.set(map, forKey: key) + DispatchQueue.main.async { + NotificationCenter.default.post(name: didChangeNotification, object: momentID) + } + } + + static func identifier(for momentID: UUID) -> String? { + load()[momentID.uuidString] + } + + static func remove(momentID: UUID) { + var map = load() + map.removeValue(forKey: momentID.uuidString) + UserDefaults.standard.set(map, forKey: key) + DispatchQueue.main.async { + NotificationCenter.default.post(name: didChangeNotification, object: momentID) + } + } + + private static func load() -> [String: String] { + UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:] + } +} + +// MARK: - CalendarManager + +final class CalendarManager { + static let shared = CalendarManager() + private init() {} + + private let store = EKEventStore() + + // MARK: Berechtigungen + + /// Fordert Full Access an und gibt zurück, ob Zugriff gewährt wurde. + func requestFullAccess() async -> Bool { + do { + if #available(iOS 17.0, *) { + return try await store.requestFullAccessToEvents() + } else { + return try await store.requestAccess(to: .event) + } + } catch { + return false + } + } + + /// Schreibt einen neuen Kalender-Eintrag und gibt den EKEvent-Identifier zurück. + /// Gibt nil zurück, wenn kein Zugriff gewährt wurde oder ein Fehler auftritt. + /// `alarmOffset`: negativer Zeitabstand vor dem Start in Sekunden (nil = keine Erinnerung). + func createEvent( + title: String, + notes: String?, + startDate: Date, + endDate: Date, + isAllDay: Bool, + calendarIdentifier: String?, + alarmOffset: TimeInterval? = nil + ) async -> String? { + let granted = await requestFullAccess() + guard granted else { return nil } + + let calendar: EKCalendar? = { + if let id = calendarIdentifier { + return store.calendar(withIdentifier: id) + } + return store.defaultCalendarForNewEvents + }() + guard let calendar else { return nil } + + let event = EKEvent(eventStore: store) + event.title = title + event.isAllDay = isAllDay + event.startDate = startDate + event.endDate = endDate + event.calendar = calendar + + // nahbar-Markierung in den Notizen + let marker = "— via nahbar" + if let existingNotes = notes, !existingNotes.isEmpty { + event.notes = "\(existingNotes)\n\n\(marker)" + } else { + event.notes = marker + } + + // Erinnerung (EKAlarm) wenn gewünscht + if let offset = alarmOffset { + event.addAlarm(EKAlarm(relativeOffset: offset)) + } + + do { + try store.save(event, span: .thisEvent) + return event.eventIdentifier + } catch { + return nil + } + } + + /// Aktualisiert Titel, Notizen und Startdatum eines bestehenden Kalendereintrags. + /// Die Dauer sowie Ganztages-/Alarm-Einstellungen des Eintrags bleiben erhalten. + /// Gibt true zurück, wenn der Eintrag gefunden und gespeichert wurde. + func updateEvent(identifier: String, title: String, notes: String?, newStartDate: Date) async -> Bool { + let granted = await requestFullAccess() + guard granted else { return false } + guard let event = store.event(withIdentifier: identifier) else { return false } + + event.title = title + + let marker = "— via nahbar" + if let n = notes, !n.isEmpty { + event.notes = "\(n)\n\n\(marker)" + } else { + event.notes = marker + } + + // Startdatum verschieben, Dauer beibehalten (nur für zeitgebundene Einträge) + if !event.isAllDay { + let duration = event.endDate.timeIntervalSince(event.startDate) + event.startDate = newStartDate + event.endDate = newStartDate.addingTimeInterval(duration) + } + + do { + try store.save(event, span: .thisEvent) + return true + } catch { + return false + } + } + + /// Löscht einen Kalendereintrag anhand seines Identifiers. + /// Gibt true zurück, wenn der Eintrag gefunden und gelöscht wurde. + func deleteEvent(identifier: String) async -> Bool { + let granted = await requestFullAccess() + guard granted else { return false } + + guard let event = store.event(withIdentifier: identifier) else { return false } + do { + try store.remove(event, span: .thisEvent) + return true + } catch { + return false + } + } + + /// Gibt alle Benutzer-Kalender zurück (sortiert nach Titel). + func availableCalendars() async -> [EKCalendar] { + let granted = await requestFullAccess() + guard granted else { return [] } + return store.calendars(for: .event) + .filter { $0.allowsContentModifications } + .sorted { $0.title < $1.title } + } + + /// Gibt den Standardkalender für neue Einträge zurück. + var defaultCalendarIdentifier: String? { + store.defaultCalendarForNewEvents?.calendarIdentifier + } +} diff --git a/nahbar/nahbar/ContactPickerView.swift b/nahbar/nahbar/ContactPickerView.swift index cb86679..5c77947 100644 --- a/nahbar/nahbar/ContactPickerView.swift +++ b/nahbar/nahbar/ContactPickerView.swift @@ -238,8 +238,16 @@ struct ContactImport { let location: String if let postal = contact.postalAddresses.first?.value { - // Bundesstaat/Region einbeziehen, falls vorhanden - location = [postal.city, postal.state, postal.country].filter { !$0.isEmpty }.joined(separator: ", ") + var parts: [String] = [] + // Straße (mehrzeilig → ", " zusammenführen) + let street = postal.street.replacingOccurrences(of: "\n", with: ", ") + if !street.isEmpty { parts.append(street) } + // PLZ + Stadt zusammen, Bundesstaat separat + let cityPart = [postal.postalCode, postal.city].filter { !$0.isEmpty }.joined(separator: " ") + if !cityPart.isEmpty { parts.append(cityPart) } + if !postal.state.isEmpty { parts.append(postal.state) } + if !postal.country.isEmpty { parts.append(postal.country) } + location = parts.joined(separator: ", ") } else { location = "" } diff --git a/nahbar/nahbar/IchView.swift b/nahbar/nahbar/IchView.swift index 7ec2263..f2a0183 100644 --- a/nahbar/nahbar/IchView.swift +++ b/nahbar/nahbar/IchView.swift @@ -1,6 +1,7 @@ import SwiftUI import PhotosUI import SwiftData +import Contacts private let socialStyleOptions = [ "Introvertiert", @@ -319,6 +320,7 @@ struct IchEditView: View { @State private var socialStyle: String @State private var selectedPhoto: UIImage? @State private var photoPickerItem: PhotosPickerItem? = nil + @State private var showingContactPicker = false init() { let store = UserProfileStore.shared @@ -443,9 +445,22 @@ struct IchEditView: View { .font(.system(size: 15, weight: .semibold)) .foregroundStyle(theme.accent) } + ToolbarItem(placement: .bottomBar) { + Button { + showingContactPicker = true + } label: { + Label("Vom Kontakt übernehmen", systemImage: "person.crop.circle") + .font(.system(size: 14)) + } + .foregroundStyle(theme.accent) + } } } - + .overlay(alignment: .center) { + SingleContactPickerTrigger(isPresented: $showingContactPicker, onSelect: applyContact) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + } .onChange(of: photoPickerItem) { _, item in Task { guard let item else { return } @@ -531,6 +546,22 @@ struct IchEditView: View { .padding(.vertical, 12) } + // MARK: - Contact Import + + private func applyContact(_ contact: CNContact) { + let imported = ContactImport.from(contact) + if !imported.name.isEmpty { name = imported.name } + if !imported.occupation.isEmpty { occupation = imported.occupation } + if !imported.location.isEmpty { location = imported.location } + if let bday = imported.birthday { + birthday = bday + hasBirthday = true + } + if let data = imported.photoData { + selectedPhoto = UIImage(data: data) + } + } + // MARK: - Helpers @ViewBuilder diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index 7af5b8b..a457786 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -2,23 +2,62 @@ "sourceLanguage" : "de", "strings" : { "" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } }, " " : { "comment" : "A placeholder text for the error message.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : " " + } + } + } }, "— %@" : { "comment" : "A quote author", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "— %@" + } + } + } }, "·" : { "comment" : "A period.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "·" + } + } + } }, "[%@]" : { "comment" : "A label displaying the log category of a log entry. The argument is the log category of the log entry.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "[%@]" + } + } + } }, "%@ analysieren" : { "comment" : "LogbuchView – AI analysis button label with person's first name", @@ -49,6 +88,12 @@ "state" : "new", "value" : "%1$@: %2$@, %3$@" } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@, %3$@" + } } } }, @@ -61,15 +106,60 @@ "state" : "new", "value" : "%1$@%2$@%3$@" } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@%3$@" + } } } }, "%lld ausgewählt" : { - + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld selected" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld selected" + } + } + } + } + } + } }, "%lld Einträge" : { "comment" : "A label showing the number of log entries. The argument is the number of entries.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entry" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld entries" + } + } + } + } + } + } }, "%lld Einträge – Export als Textdatei" : { "comment" : "SettingsView / LogExportView – entry count with export hint", @@ -118,7 +208,14 @@ } }, "%lld Kontakte ausgewählt. Weiter." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld contacts selected. Continue." + } + } + } }, "%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.", @@ -129,14 +226,34 @@ "state" : "new", "value" : "%1$lld von %2$lld Kontakten – Pro für mehr" } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld of %2$lld contacts – Pro for more" + } } } }, "%lld von 100 Zeichen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld of 100 characters" + } + } + } }, "%lld/100" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld/100" + } + } + } }, "📱 Kontakte werden ausschließlich lokal gespeichert und niemals mit Servern geteilt." : { "comment" : "PrivacyBadgeView – contacts context message", @@ -172,7 +289,14 @@ } }, "🔒 Diese Daten bleiben ausschließlich auf deinem iPhone und werden niemals übertragen." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 This data stays exclusively on your iPhone and is never transmitted." + } + } + } }, "1 Monat" : { "comment" : "Settings – look-ahead / period picker option", @@ -196,6 +320,26 @@ } } }, + "1 Std vorher" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 hr before" + } + } + } + }, + "1 Tag vorher" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 day before" + } + } + } + }, "1 Woche" : { "comment" : "Settings – look-ahead period picker option", "localizations" : { @@ -252,6 +396,16 @@ } } }, + "5 Min vorher" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "5 min before" + } + } + } + }, "10 kurze Situationen. Keine falschen Antworten. Dauert etwa 2 Minuten." : { "localizations" : { "en" : { @@ -262,6 +416,16 @@ } } }, + "15 Min vorher" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 min before" + } + } + } + }, "30 Min" : { "comment" : "AddMomentView – calendar event duration option", "localizations" : { @@ -297,7 +461,14 @@ } }, "Abonnement" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subscription" + } + } + } }, "Abonnement verlängert sich automatisch. In den iPhone-Einstellungen jederzeit kündbar." : { "comment" : "PaywallView – subscription legal notice", @@ -332,6 +503,28 @@ } } }, + "Alle %lld Einträge anzeigen" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show all %lld entry" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show all %lld entries" + } + } + } + } + } + } + }, "Alle %lld Einträge werden entfernt." : { "comment" : "LogExportView – clear log confirmation message", "localizations" : { @@ -356,10 +549,24 @@ } }, "Alle %lld Tage – basierend auf deinem Profil" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every %lld days – based on your profile" + } + } + } }, "Alle Features freigeschaltet" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All features unlocked" + } + } + } }, "Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht." : { "comment" : "AddPersonView – delete confirmation message", @@ -373,7 +580,14 @@ } }, "Alle Personen, Momente, Besuche und dein Profil werden unwiderruflich gelöscht. Die App startet neu." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All persons, moments, visits and your profile will be permanently deleted. The app will restart." + } + } + } }, "Alle Pro-Features freigeschaltet" : { "comment" : "SettingsView – Pro subscription active subtitle", @@ -434,7 +648,14 @@ } }, "Alles löschen und Onboarding starten" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete everything and start onboarding" + } + } + } }, "Alles, was du in nahbar eingibst, wird ausschließlich auf deinem iPhone gespeichert und verarbeitet." : { "comment" : "OnboardingPrivacyView – subtitle below headline", @@ -562,10 +783,24 @@ } }, "App wirklich zurücksetzen?" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Really reset the app?" + } + } + } }, "App zurücksetzen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset app" + } + } + } }, "App-Schutz" : { "comment" : "SettingsView – section header for app lock settings", @@ -613,10 +848,24 @@ } }, "Auf Max upgraden – KI-Analyse freischalten" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upgrade to Max – unlock AI analysis" + } + } + } }, "Aus Kontakten ausfüllen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fill from contacts" + } + } + } }, "Aus Kontakten auswählen" : { "comment" : "AddPersonView – import from contacts button", @@ -792,7 +1041,14 @@ } }, "Bitte gib zuerst deinen Vornamen ein." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please enter your first name first." + } + } + } }, "Chat" : { "comment" : "MomentSource.chat raw value", @@ -908,7 +1164,15 @@ }, "Daten werden in dieser Sitzung nicht gespeichert." : { "comment" : "A description of the data that is not saved in the current session.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data is not saved in this session." + } + } + } }, "Daten werden nur lokal gespeichert" : { "comment" : "SettingsView – iCloud sync disabled subtitle", @@ -923,7 +1187,15 @@ }, "Datenbankfehler" : { "comment" : "A title of a banner that appears when the app is in degraded mode.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Database error" + } + } + } }, "Datenschutz" : { "comment" : "SettingsView – privacy info row label", @@ -949,7 +1221,14 @@ } }, "Datenschutzhinweis: Diese Daten werden ausschließlich lokal auf deinem Gerät gespeichert und niemals übertragen." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy notice: This data is stored exclusively locally on your device and is never transmitted." + } + } + } }, "Datenschutzhinweis: Diese Funktion sendet Daten an einen externen KI-Dienst." : { "comment" : "PrivacyBadgeView – aiFeature context accessibility label", @@ -997,7 +1276,15 @@ }, "Datum angeben" : { "comment" : "A label displayed in a form section.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set date" + } + } + } }, "Dauer" : { "comment" : "AddMomentView – calendar event duration section label", @@ -1021,7 +1308,14 @@ } }, "Dein Geschlecht hilft, die Auswertung besser einzuordnen." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your gender helps to better contextualise the evaluation." + } + } + } }, "Dein nächstes Gespräch kann hier beginnen." : { "comment" : "PersonDetailView – moments empty state message", @@ -1093,7 +1387,14 @@ } }, "Details" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" + } + } + } }, "Diagnose" : { "comment" : "SettingsView – section header for developer diagnostics", @@ -1162,6 +1463,16 @@ } } }, + "Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This moment has a linked calendar event. Should it also be deleted?" + } + } + } + }, "Distanzierter" : { "comment" : "RatingQuestion – negative pole for relationship closeness question", "extractionState" : "stale", @@ -1296,7 +1607,14 @@ } }, "Du kannst Kontakte jederzeit später in der App hinzufügen." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can add contacts at any time later in the app." + } + } + } }, "Du meldest dich locker – er ist wahrscheinlich einfach beschäftigt." : { "extractionState" : "stale", @@ -1490,7 +1808,14 @@ } }, "Eigene Kontaktdaten aus Adressbuch übernehmen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import your own contact details from the address book" + } + } + } }, "Ein Freund erzählt von einem Plan, den du für einen Fehler hältst." : { "extractionState" : "stale", @@ -1626,7 +1951,14 @@ } }, "Ergebnis bestätigen und fortfahren" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm result and continue" + } + } + } }, "Erinnern" : { "comment" : "PersonDetailView – set reminder confirmation button", @@ -1640,6 +1972,16 @@ } } }, + "Erinnerung" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reminder" + } + } + } + }, "Erinnerung setzen" : { "comment" : "AddMomentView – toggle label to enable reminder for intention moments", "localizations" : { @@ -1664,7 +2006,14 @@ } }, "Erinnerungen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reminders" + } + } + } }, "Erneut" : { "localizations" : { @@ -1710,7 +2059,14 @@ } }, "Erzähl uns kurz, wer du bist." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tell us briefly who you are." + } + } + } }, "Etwas Neues ausprobieren" : { "comment" : "PersonDetailView – activity suggestion: try something new", @@ -1844,6 +2200,12 @@ "state" : "new", "value" : "Frage %1$lld von %2$lld" } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Question %1$lld of %2$lld" + } } } }, @@ -1861,7 +2223,15 @@ }, "Füge deine eigenen Infos hinzu – damit nahbar noch besser versteht, in welchem Kontext du Beziehungen pflegst." : { "comment" : "A description of the benefits of adding your own information.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add your own info – so nahbar better understands the context in which you nurture relationships." + } + } + } }, "Fühlt sich die Beziehung gestärkt an?" : { "comment" : "RatingQuestion – relationship question text", @@ -1943,6 +2313,17 @@ } } }, + "Geplante Treffen" : { + "comment" : "TodayView – section title for planned future meeting moments", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planned Meetings" + } + } + } + }, "Geschenkidee anzeigen" : { "comment" : "TodayView GiftSuggestionRow – collapsed state button", "localizations" : { @@ -1978,10 +2359,24 @@ } }, "Geschlecht" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gender" + } + } + } }, "Geschlecht (optional)" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gender (optional)" + } + } + } }, "Gesellig" : { "extractionState" : "stale", @@ -2017,17 +2412,6 @@ } } }, - "Geplante Treffen" : { - "comment" : "TodayView – section title for planned future meeting moments", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Planned Meetings" - } - } - } - }, "Gesprächszeit" : { "comment" : "SettingsView section header / CallWindowSetupView nav title", "localizations" : { @@ -2229,7 +2613,14 @@ } }, "Idee: %@" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idea: %@" + } + } + } }, "Ideen werden generiert…" : { "comment" : "TodayView GiftSuggestionRow – loading state text", @@ -2391,6 +2782,16 @@ } } }, + "Kalender" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar" + } + } + } + }, "Kauf wiederherstellen" : { "comment" : "PaywallView – restore purchases button", "localizations" : { @@ -2402,6 +2803,16 @@ } } }, + "Keine" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" + } + } + } + }, "Keine Einträge für diesen Filter" : { "comment" : "LogExportView – empty state when filter shows no entries", "localizations" : { @@ -2438,7 +2849,15 @@ }, "Keine Treffer." : { "comment" : "A label displayed when there are no search results.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No results." + } + } + } }, "KI-Analyse" : { "comment" : "SettingsView – section header for AI settings", @@ -2531,19 +2950,54 @@ } }, "Kontakte aus Adressbuch auswählen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select contacts from address book" + } + } + } }, "Kontakte auswählen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select contacts" + } + } + } }, "Kontakte hinzufügen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add contacts" + } + } + } }, "Kontakte überspringen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip contacts" + } + } + } }, "Kontakte überspringen?" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip contacts?" + } + } + } }, "Kontakte und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." : { "comment" : "OnboardingPrivacyView – local storage privacy row text", @@ -2592,7 +3046,14 @@ } }, "Kurze Frage vorab" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quick question first" + } + } + } }, "Limit erreicht" : { "comment" : "LogbuchView – AI refresh button label when at request limit", @@ -2639,7 +3100,14 @@ } }, "Los geht's – nahbar starten" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Let's go – start nahbar" + } + } + } }, "Löschen" : { "comment" : "Universal delete button / swipe action", @@ -2699,7 +3167,14 @@ } }, "Max aktiv" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Max active" + } + } + } }, "Menschen" : { "comment" : "Tab label for people list", @@ -2747,7 +3222,15 @@ }, "Mittwoch, 16. April" : { "comment" : "A label that displays the date.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wednesday, 16 April" + } + } + } }, "Möchtest du die Notiz anpassen?" : { "comment" : "VisitEditFlowView – note step title", @@ -2808,6 +3291,26 @@ } } }, + "Moment + Kalendereintrag löschen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete moment + calendar event" + } + } + } + }, + "Moment bearbeiten" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit moment" + } + } + } + }, "Moment festhalten" : { "comment" : "AddMomentView – sheet navigation title", "localizations" : { @@ -2819,6 +3322,26 @@ } } }, + "Moment löschen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete moment" + } + } + } + }, + "Moment…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moment…" + } + } + } + }, "Momente" : { "comment" : "PersonDetailView – moments section header", "localizations" : { @@ -3059,7 +3582,15 @@ }, "nahbar" : { "comment" : "The name of the app.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahbar" + } + } + } }, "nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist." : { "comment" : "CallWindowSetupView – feature description", @@ -3073,10 +3604,24 @@ } }, "Nahbar erinnert dich, wenn du diese Person seit der gewählten Zeit nicht mehr kontaktiert hast." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahbar reminds you when you haven't contacted this person for the selected period." + } + } + } }, "nahbar erinnert dich, wenn du lange nichts von jemandem gehört hast." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahbar reminds you when you haven't heard from someone for a long time." + } + } + } }, "nahbar Max freischalten für KI-Analyse" : { "comment" : "LogbuchView – upsell button for AI analysis", @@ -3115,7 +3660,15 @@ }, "nahbar-log.txt" : { "comment" : "The file name of the log export.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahbar-log.txt" + } + } + } }, "Natürlich & verbunden" : { "comment" : "Theme tagline for Grove", @@ -3186,7 +3739,15 @@ }, "Nicht angegeben" : { "comment" : "A placeholder value for the social style picker.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not specified" + } + } + } }, "Nicht jetzt" : { "comment" : "CallSuggestionView – dismiss / defer button", @@ -3234,11 +3795,26 @@ } }, "Noch keine Kontakte" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No contacts yet" + } + } + } }, "Noch keine Menschen hier." : { "comment" : "A description of the empty state when there are no people in the list.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No people here yet." + } + } + } }, "Noch keine Momente festgehalten" : { "comment" : "TodayView lastSeenHint – no moments recorded at all", @@ -3286,7 +3862,14 @@ } }, "Notiz anpassen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adjust note" + } + } + } }, "Notizen" : { "comment" : "AddPersonView / PersonDetailView – notes field label (plural)", @@ -3300,6 +3883,16 @@ } } }, + "Nur Moment löschen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete moment only" + } + } + } + }, "Nur Smalltalk" : { "comment" : "RatingQuestion – negative pole for conversation depth", "extractionState" : "stale", @@ -3381,7 +3974,14 @@ } }, "Onboarding, Profil und alle Daten löschen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete onboarding, profile and all data" + } + } + } }, "Optional" : { "comment" : "AddPersonView – optional field placeholder hint", @@ -3431,7 +4031,15 @@ }, "Person hinzufügen" : { "comment" : "A button that adds a new person.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add person" + } + } + } }, "Personalisierte Vorschläge in 2 Minuten" : { "localizations" : { @@ -3454,7 +4062,14 @@ } }, "Persönlichkeits-Pentagon-Diagramm" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personality Pentagon diagram" + } + } + } }, "Persönlichkeitsempfehlung: Passend für dich" : { "extractionState" : "stale", @@ -3468,7 +4083,14 @@ } }, "Persönlichkeitsprofil-Details anzeigen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show personality profile details" + } + } + } }, "Persönlichkeitsquiz starten" : { "localizations" : { @@ -3515,18 +4137,48 @@ } }, "Pro oder Max-Abo" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pro or Max subscription" + } + } + } }, "Profil bearbeiten" : { "comment" : "The title of the screen where a user can edit their profile.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit profile" + } + } + } }, "Profil einrichten" : { "comment" : "A button to create a user's profile.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set up profile" + } + } + } }, "Profilfoto auswählen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select profile photo" + } + } + } }, "Push-Benachrichtigung nach dem Besuch" : { "comment" : "SettingsView – aftermath notification toggle subtitle", @@ -3582,7 +4234,14 @@ } }, "Quiz überspringen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip quiz" + } + } + } }, "Quiz zurücksetzen" : { "localizations" : { @@ -3634,6 +4293,12 @@ "state" : "new", "value" : "Schritt %1$lld von %2$lld" } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step %1$lld of %2$lld" + } } } }, @@ -3814,10 +4479,24 @@ } }, "Spitzname (optional)" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nickname (optional)" + } + } + } }, "Spitzname, optional" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nickname, optional" + } + } + } }, "Suchen…" : { "comment" : "ShareExtensionView – contact search placeholder", @@ -3912,7 +4591,15 @@ }, "Tippe auf + um jemanden hinzuzufügen." : { "comment" : "A description of how to add a new contact.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap + to add someone." + } + } + } }, "Tippe auf + um loszulegen" : { "comment" : "VisitHistorySection – empty state subtitle", @@ -3960,7 +4647,14 @@ } }, "Treffen bewerten" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rate meeting" + } + } + } }, "Treffen mit %@" : { "comment" : "AddMomentView – calendar event / LogEntry title with person name", @@ -3985,7 +4679,14 @@ } }, "Trotzdem überspringen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip anyway" + } + } + } }, "Typ" : { "comment" : "ShareExtensionView – moment type section header", @@ -4022,10 +4723,24 @@ } }, "Über mich (optional)" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About me (optional)" + } + } + } }, "Über mich, maximal 100 Zeichen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About me, maximum 100 characters" + } + } + } }, "Über nahbar" : { "comment" : "SettingsView – about section header", @@ -4098,7 +4813,14 @@ } }, "Uns fehlt noch was – wir würden gerne mehr von dir erfahren." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We're still missing something – we'd like to know more about you." + } + } + } }, "Unternehmung" : { "comment" : "MomentType.intention displayName – shown in type picker and feature tour", @@ -4145,6 +4867,16 @@ } } }, + "Verlauf" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "History" + } + } + } + }, "Version" : { "comment" : "SettingsView – version info row label", "extractionState" : "stale", @@ -4224,6 +4956,16 @@ } } }, + "Vom Kontakt übernehmen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import from contact" + } + } + } + }, "Von" : { "comment" : "CallWindowSetupView – start of time window label", "localizations" : { @@ -4281,10 +5023,24 @@ } }, "Vorname" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "First name" + } + } + } }, "Vorname, erforderlich" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "First name, required" + } + } + } }, "Wähle deinen Plan" : { "comment" : "PaywallView – header title", @@ -4309,10 +5065,24 @@ } }, "Wähle Menschen aus deinem Adressbuch, die dir wichtig sind." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose people from your address book who matter to you." + } + } + } }, "Wähle Menschen aus, die dir wichtig sind." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose people who matter to you." + } + } + } }, "Wann?" : { "comment" : "AddMomentView – calendar event date picker label", @@ -4395,22 +5165,64 @@ } }, "Weiter (%lld ausgewählt)" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue (%lld selected)" + } + } + } }, "Weiter zu den Fragen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue to questions" + } + } + } }, "Weiter zum nächsten Schritt" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue to next step" + } + } + } }, "Weiter zum Persönlichkeitsquiz" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue to personality quiz" + } + } + } }, "Weiter, kein Kontakt ausgewählt" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue, no contact selected" + } + } + } }, "Weitere hinzufügen" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add more" + } + } + } }, "Wenn du magst, kannst du das Treffen kurz reflektieren." : { "extractionState" : "stale", @@ -4425,7 +5237,15 @@ }, "Wer bist du?" : { "comment" : "A title for the empty state view.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Who are you?" + } + } + } }, "Wichtig" : { "comment" : "LogbuchView swipe action – mark moment as important", @@ -4486,7 +5306,15 @@ }, "Wie heißt du?" : { "comment" : "A label for the user's name.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What's your name?" + } + } + } }, "Wie ist dein Energielevel nach dem Treffen?" : { "comment" : "RatingQuestion – energy level question text", @@ -4501,10 +5329,24 @@ } }, "Wie kennen dich deine Freunde?" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How do your friends know you?" + } + } + } }, "Wie nennen dich deine Freunde?" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What do your friends call you?" + } + } + } }, "Wie oft erinnern?" : { "comment" : "AddPersonView – nudge frequency picker label", @@ -4552,7 +5394,14 @@ } }, "Willkommen bei nahbar" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to nahbar" + } + } + } }, "Wir erinnern dich an die Nachwirkung." : { "comment" : "VisitSummaryView – aftermath reminder subtitle", @@ -4624,10 +5473,24 @@ } }, "z. B. Max" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "e.g. Max" + } + } + } }, "Zeigt eine Bestätigungsabfrage." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows a confirmation prompt." + } + } + } }, "Zeitfenster" : { "comment" : "SettingsView / CallWindowSetupView – time window section header and row label", @@ -4640,9 +5503,27 @@ } } }, + "Zeitpunkt" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time" + } + } + } + }, "Zeitraum" : { "comment" : "A generic term for a billing period.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Period" + } + } + } }, "Zu Max upgraden" : { "comment" : "PaywallView – CTA button when upgrading from Pro to Max", diff --git a/nahbar/nahbar/LogbuchView.swift b/nahbar/nahbar/LogbuchView.swift index 3751235..979b416 100644 --- a/nahbar/nahbar/LogbuchView.swift +++ b/nahbar/nahbar/LogbuchView.swift @@ -73,6 +73,16 @@ struct LogbuchView: View { @State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests @AppStorage("aiConsentGiven") private var aiConsentGiven = false + // Kalender-Lösch-Bestätigung + @State private var momentPendingDelete: Moment? = nil + @State private var showCalendarDeleteDialog = false + + // Moment-Bearbeitung + @State private var momentForTextEdit: Moment? = nil + + /// Inkrementiert bei jeder CalendarEventStore-Änderung → triggert Re-Render der Rows. + @State private var calendarEventsVersion = 0 + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 28) { @@ -104,6 +114,31 @@ struct LogbuchView: View { Task { await runAnalysis() } } } + .sheet(item: $momentForTextEdit) { moment in + EditMomentView(moment: moment) + } + .onReceive(NotificationCenter.default.publisher(for: CalendarEventStore.didChangeNotification)) { _ in + calendarEventsVersion += 1 + } + .confirmationDialog( + "Moment löschen", + isPresented: $showCalendarDeleteDialog, + presenting: momentPendingDelete + ) { moment in + Button("Moment + Kalendereintrag löschen", role: .destructive) { + performDelete(moment, deleteCalendarEvent: true) + momentPendingDelete = nil + } + Button("Nur Moment löschen", role: .destructive) { + performDelete(moment, deleteCalendarEvent: false) + momentPendingDelete = nil + } + Button("Abbrechen", role: .cancel) { + momentPendingDelete = nil + } + } message: { _ in + Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?") + } .onReceive( NotificationCenter.default.publisher( for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification") @@ -135,6 +170,7 @@ struct LogbuchView: View { DeletableLogbuchRow( isImportant: moment.isImportant, isLast: index == items.count - 1, + onEdit: { momentForTextEdit = moment }, onDelete: { deleteMoment(moment) }, onToggleImportant: { toggleImportant(moment) } ) { @@ -152,6 +188,24 @@ struct LogbuchView: View { } private func deleteMoment(_ moment: Moment) { + if CalendarEventStore.identifier(for: moment.id) != nil { + momentPendingDelete = moment + showCalendarDeleteDialog = true + } else { + performDelete(moment, deleteCalendarEvent: false) + } + } + + private func performDelete(_ moment: Moment, deleteCalendarEvent: Bool) { + let momentID = moment.id + if deleteCalendarEvent, let eventID = CalendarEventStore.identifier(for: momentID) { + Task { + _ = await CalendarManager.shared.deleteEvent(identifier: eventID) + CalendarEventStore.remove(momentID: momentID) + } + } else { + CalendarEventStore.remove(momentID: momentID) + } modelContext.delete(moment) person.touch() } @@ -200,6 +254,13 @@ struct LogbuchView: View { .font(.system(size: 10)) .foregroundStyle(.orange) } + if case .moment(let m) = item, + calendarEventsVersion >= 0, // Dependency auf calendarEventsVersion + CalendarEventStore.identifier(for: m.id) != nil { + Image(systemName: "calendar") + .font(.system(size: 10)) + .foregroundStyle(theme.contentTertiary) + } Text(LocalizedStringKey(item.label)) .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) @@ -483,12 +544,13 @@ struct LogbuchView: View { } // MARK: - Deletable Logbuch Row -// Rechts wischen → Wichtig (orange), Links wischen → Löschen (rot) +// Rechts wischen → Bearbeiten (accent) + Wichtig (orange), Links wischen → Löschen (rot) private struct DeletableLogbuchRow: View { @Environment(\.nahbarTheme) var theme let isImportant: Bool let isLast: Bool + let onEdit: () -> Void let onDelete: () -> Void let onToggleImportant: () -> Void @ViewBuilder let content: Content @@ -499,6 +561,23 @@ private struct DeletableLogbuchRow: View { var body: some View { ZStack { HStack(spacing: 0) { + // Links: Bearbeiten-Button + Button { + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } + onEdit() + } label: { + VStack(spacing: 4) { + Image(systemName: "pencil") + .font(.system(size: 15, weight: .medium)) + Text("Bearbeiten") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.white) + .frame(width: actionWidth) + .frame(maxHeight: .infinity) + } + .background(theme.accent) + // Links: Wichtig-Button Button { onToggleImportant() @@ -550,18 +629,18 @@ private struct DeletableLogbuchRow: View { let x = value.translation.width guard abs(x) > abs(value.translation.height) * 0.6 else { return } if x > 0 { - offset = min(x, actionWidth + 16) + offset = min(x, actionWidth * 2 + 16) } else { offset = max(x, -(actionWidth + 16)) } } .onEnded { value in let x = value.translation.width - if x > actionWidth + 20 { + if x > actionWidth * 2 + 20 { onToggleImportant() withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } } 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 * 2 } } else if x < -(actionWidth / 2) { withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth } } else { diff --git a/nahbar/nahbar/NahbarContact.swift b/nahbar/nahbar/NahbarContact.swift index 6157509..f22e729 100644 --- a/nahbar/nahbar/NahbarContact.swift +++ b/nahbar/nahbar/NahbarContact.swift @@ -19,6 +19,8 @@ struct NahbarContact: Identifiable, Codable, Equatable { /// Firma oder Organisation des Kontakts. var organizationName: String var notes: String + /// Formatierte Postanschrift (Straße, PLZ Stadt, Land). + var location: String /// Original CNContact identifier for stable matching against the system address book. var cnIdentifier: String? @@ -30,6 +32,7 @@ struct NahbarContact: Identifiable, Codable, Equatable { emailAddresses: [String] = [], organizationName: String = "", notes: String = "", + location: String = "", cnIdentifier: String? = nil ) { self.id = id @@ -39,6 +42,7 @@ struct NahbarContact: Identifiable, Codable, Equatable { self.emailAddresses = emailAddresses self.organizationName = organizationName self.notes = notes + self.location = location self.cnIdentifier = cnIdentifier } @@ -52,15 +56,16 @@ struct NahbarContact: Identifiable, Codable, Equatable { self.organizationName = contact.organizationName // CNContactNoteKey requires a special entitlement – omitted intentionally. self.notes = "" + self.location = ContactImport.from(contact).location self.cnIdentifier = contact.identifier } // MARK: - Codable (rückwärtskompatibel) - // Neue Felder (emailAddresses, organizationName) mit decodeIfPresent lesen, - // damit bestehende NahbarContacts.json-Dateien ohne diese Felder weiterhin laden. + // Neue Felder mit decodeIfPresent lesen, damit bestehende NahbarContacts.json + // ohne diese Felder weiterhin geladen werden können. enum CodingKeys: String, CodingKey { - case id, givenName, familyName, phoneNumbers, emailAddresses, organizationName, notes, cnIdentifier + case id, givenName, familyName, phoneNumbers, emailAddresses, organizationName, notes, location, cnIdentifier } init(from decoder: Decoder) throws { @@ -72,6 +77,7 @@ struct NahbarContact: Identifiable, Codable, Equatable { emailAddresses = try c.decodeIfPresent([String].self, forKey: .emailAddresses) ?? [] organizationName = try c.decodeIfPresent(String.self, forKey: .organizationName) ?? "" notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? "" + location = try c.decodeIfPresent(String.self, forKey: .location) ?? "" cnIdentifier = try c.decodeIfPresent(String.self, forKey: .cnIdentifier) } // encode(to:) wird vom Compiler synthetisiert, da alle Felder Encodable sind. diff --git a/nahbar/nahbar/OnboardingContainerView.swift b/nahbar/nahbar/OnboardingContainerView.swift index d3b4a64..93dc19f 100644 --- a/nahbar/nahbar/OnboardingContainerView.swift +++ b/nahbar/nahbar/OnboardingContainerView.swift @@ -111,6 +111,7 @@ struct OnboardingContainerView: View { // 3. Import each selected contact as a Person in SwiftData for contact in coordinator.selectedContacts { let person = Person(name: contact.fullName) + if !contact.location.isEmpty { person.location = contact.location } modelContext.insert(person) } if !coordinator.selectedContacts.isEmpty { diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 4d59719..78c7c1e 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -18,13 +18,22 @@ struct PersonDetailView: View { @State private var momentForEdit: Moment? = nil @State private var momentForSummary: Moment? = nil + // Moment-Bearbeiten + @State private var momentForTextEdit: Moment? = nil + + // Kalender-Lösch-Bestätigung + @State private var momentPendingDelete: Moment? = nil + @State private var showCalendarDeleteDialog = false + @StateObject private var personalityStore = PersonalityStore.shared + @State private var activityHint: String = "" var body: some View { ScrollView { VStack(alignment: .leading, spacing: 28) { personHeader momentsSection + if !person.sortedLogEntries.isEmpty { logbuchSection } if hasInfoContent { infoSection } } .padding(.horizontal, 20) @@ -77,6 +86,28 @@ struct PersonDetailView: View { } } } + .sheet(item: $momentForTextEdit) { moment in + EditMomentView(moment: moment) + } + .confirmationDialog( + "Moment löschen", + isPresented: $showCalendarDeleteDialog, + presenting: momentPendingDelete + ) { moment in + Button("Moment + Kalendereintrag löschen", role: .destructive) { + performDelete(moment, deleteCalendarEvent: true) + momentPendingDelete = nil + } + Button("Nur Moment löschen", role: .destructive) { + performDelete(moment, deleteCalendarEvent: false) + momentPendingDelete = nil + } + Button("Abbrechen", role: .cancel) { + momentPendingDelete = nil + } + } message: { _ in + Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?") + } // Schützt vor Crash wenn der ModelContext durch Migration oder // CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden. .onReceive( @@ -119,15 +150,6 @@ struct PersonDetailView: View { HStack { SectionHeader(title: "Momente", icon: "clock") Spacer() - NavigationLink { - LogbuchView(person: person) - } label: { - Image(systemName: "book.closed") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(theme.contentTertiary) - .padding(.horizontal, 8) - .padding(.vertical, 5) - } Button { showingAddMoment = true } label: { @@ -164,6 +186,7 @@ struct PersonDetailView: View { isLast: index == person.sortedMoments.count - 1, onDelete: { deleteMoment(moment) }, onToggleImportant: { toggleImportant(moment) }, + onEdit: { momentForTextEdit = moment }, onRateMeeting: { momentForRating = moment }, onAftermathMeeting: { momentForAftermath = moment }, onViewSummary: { momentForSummary = moment }, @@ -178,53 +201,125 @@ struct PersonDetailView: View { } } - // MARK: - Vorhaben-Vorschlag (ersetzt nextStepSection/nextStepSuggestionsView) + // MARK: - Vorhaben-Vorschlag private func intentionSuggestionButton(profile: PersonalityProfile) -> some View { - let preferred = PersonalityEngine.preferredActivityStyle(for: profile) - let highlightNew = PersonalityEngine.highlightNovelty(for: profile) + let hint = activityHint.isEmpty ? refreshActivityHint(profile: profile) : activityHint - let activities: [(String, ActivityStyle?, Bool)] = [ - ("Kaffee trinken", .oneOnOne, false), - ("Spazieren gehen", .oneOnOne, false), - ("Zusammen essen", .group, false), - ("Etwas unternehmen", .group, false), - ("Etwas Neues ausprobieren", nil, true), - ("Anrufen", nil, false), - ] - - func score(_ item: (String, ActivityStyle?, Bool)) -> Int { - var s = 0 - if item.1 == preferred { s += 2 } - if item.2 && highlightNew { s += 1 } - return s - } - let sorted = activities.sorted { score($0) > score($1) } - let topTwo = sorted.prefix(2).map { $0.0 } - let hint = topTwo.joined(separator: " oder ") - let topActivity = sorted.first?.0 ?? "" - - return Button { - // AddMomentView mit vorausgefülltem Intention-Typ öffnen - // (PersonDetailView übergibt den Vorschlagstext via AddMomentView-Initialisierung) - showingAddMoment = true - _ = topActivity // Vorschlag wird in AddMomentView als Standardtyp .intention öffnen - } label: { - HStack(spacing: 6) { - Image(systemName: "brain") - .font(.system(size: 11)) - .foregroundStyle(NahbarInsightStyle.accentPetrol) - Text("Idee: \(hint)") - .font(.system(size: 13)) - .foregroundStyle(theme.contentSecondary) - .lineLimit(1) + return HStack(spacing: 0) { + Button { + showingAddMoment = true + } label: { + HStack(spacing: 6) { + Image(systemName: "brain") + .font(.system(size: 11)) + .foregroundStyle(NahbarInsightStyle.accentPetrol) + Text("Idee: \(hint)") + .font(.system(size: 13)) + .foregroundStyle(theme.contentSecondary) + .lineLimit(1) + } + .padding(.leading, 14) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Neue Idee würfeln + Button { + activityHint = refreshActivityHint(profile: profile) + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + .padding(.horizontal, 12) + .padding(.vertical, 7) } - .padding(.horizontal, 14) - .padding(.vertical, 7) - .frame(maxWidth: .infinity, alignment: .leading) } } + @discardableResult + private func refreshActivityHint(profile: PersonalityProfile) -> String { + let suggestions = PersonalityEngine.suggestedActivities( + for: profile, tag: person.tag, count: 2 + ) + let hint = suggestions.joined(separator: " oder ") + activityHint = hint + return hint + } + + // MARK: - Logbuch Vorschau + + private let logbuchPreviewLimit = 5 + + private var logbuchSection: some View { + let entries = person.sortedLogEntries + let preview = Array(entries.prefix(logbuchPreviewLimit)) + let hasMore = entries.count > logbuchPreviewLimit + + return VStack(alignment: .leading, spacing: 10) { + HStack { + SectionHeader(title: "Verlauf", icon: "book.closed") + Spacer() + } + + VStack(spacing: 0) { + ForEach(Array(preview.enumerated()), id: \.element.id) { index, entry in + logEntryPreviewRow(entry) + if index < preview.count - 1 || hasMore { RowDivider() } + } + if hasMore { + NavigationLink(destination: LogbuchView(person: person)) { + HStack { + Text("Alle \(entries.count) Einträge anzeigen") + .font(.system(size: 14)) + .foregroundStyle(theme.accent) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(theme.contentTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + } + + private func logEntryPreviewRow(_ entry: LogEntry) -> some View { + HStack(spacing: 12) { + Image(systemName: entry.type.icon) + .font(.system(size: 14, weight: .light)) + .foregroundStyle(theme.accent) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 3) { + Text(entry.title) + .font(.system(size: 15, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 6) { + Text(LocalizedStringKey(entry.type.rawValue)) + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + Text("·") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + Text(entry.loggedAt.formatted(.dateTime.day().month(.abbreviated).year())) + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + // MARK: - Info private var hasInfoContent: Bool { @@ -266,6 +361,25 @@ struct PersonDetailView: View { // MARK: - Aktionen private func deleteMoment(_ moment: Moment) { + // Prüfen ob ein Kalendereintrag verknüpft ist → ggf. Bestätigung anfordern + if CalendarEventStore.identifier(for: moment.id) != nil { + momentPendingDelete = moment + showCalendarDeleteDialog = true + } else { + performDelete(moment, deleteCalendarEvent: false) + } + } + + private func performDelete(_ moment: Moment, deleteCalendarEvent: Bool) { + let momentID = moment.id + if deleteCalendarEvent, let eventID = CalendarEventStore.identifier(for: momentID) { + Task { + _ = await CalendarManager.shared.deleteEvent(identifier: eventID) + CalendarEventStore.remove(momentID: momentID) + } + } else { + CalendarEventStore.remove(momentID: momentID) + } modelContext.delete(moment) person.touch() } @@ -307,9 +421,9 @@ struct PersonDetailView: View { } // MARK: - Deletable Moment Row -// Links wischen → Löschen (rot) -// Rechts wischen → Als wichtig markieren (orange) -// Vollständig rechts wischen → sofortiger Toggle, Zeile springt zurück +// Links wischen → Löschen (rot) +// Rechts wischen → Bearbeiten (accent) + Wichtig (orange) +// Vollständig rechts wischen → sofortiger Wichtig-Toggle, Zeile springt zurück private struct DeletableMomentRow: View { @Environment(\.nahbarTheme) var theme @@ -317,6 +431,7 @@ private struct DeletableMomentRow: View { let isLast: Bool let onDelete: () -> Void let onToggleImportant: () -> Void + let onEdit: () -> Void let onRateMeeting: () -> Void let onAftermathMeeting: () -> Void let onViewSummary: () -> Void @@ -328,28 +443,47 @@ private struct DeletableMomentRow: View { var body: some View { ZStack { - // Hintergrund: beide Aktions-Buttons + // Hintergrund-Buttons HStack(spacing: 0) { - // Links: Wichtig-Button (sichtbar bei Rechts-Wischen) - Button { - onToggleImportant() - withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } - } label: { - VStack(spacing: 4) { - Image(systemName: moment.isImportant ? "star.slash.fill" : "star.fill") - .font(.system(size: 15, weight: .medium)) - Text(moment.isImportant ? "Entfernen" : "Wichtig") - .font(.system(size: 11, weight: .medium)) + + // Linke Seite (sichtbar bei Rechts-Wischen): Bearbeiten + Wichtig + HStack(spacing: 0) { + Button { + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { onEdit() } + } label: { + VStack(spacing: 4) { + Image(systemName: "pencil") + .font(.system(size: 15, weight: .medium)) + Text("Bearbeiten") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.white) + .frame(width: actionWidth) + .frame(maxHeight: .infinity) } - .foregroundStyle(.white) - .frame(width: actionWidth) - .frame(maxHeight: .infinity) + .background(theme.accent) + + Button { + onToggleImportant() + withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } + } label: { + VStack(spacing: 4) { + Image(systemName: moment.isImportant ? "star.slash.fill" : "star.fill") + .font(.system(size: 15, weight: .medium)) + Text(moment.isImportant ? "Entfernen" : "Wichtig") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.white) + .frame(width: actionWidth) + .frame(maxHeight: .infinity) + } + .background(Color.orange) } - .background(Color.orange) Spacer() - // Rechts: Löschen-Button (sichtbar bei Links-Wischen) + // Rechte Seite (sichtbar bei Links-Wischen): Löschen Button { withAnimation(.spring(response: 0.28, dampingFraction: 0.75)) { offset = -UIScreen.main.bounds.width @@ -383,8 +517,6 @@ private struct DeletableMomentRow: View { } .background(theme.surfaceCard) .offset(x: offset) - // simultaneousGesture erlaubt dem übergeordneten ScrollView weiterhin zu scrollen. - // Der Winkeltest (Faktor 2.5) lässt nur klar horizontale Gesten durch. .simultaneousGesture( DragGesture(minimumDistance: 20, coordinateSpace: .local) .onChanged { value in @@ -392,8 +524,10 @@ private struct DeletableMomentRow: View { let y = value.translation.height guard abs(x) > abs(y) * 2.5 else { return } if x > 0 { - offset = min(x, actionWidth + 16) + // Rechts: bis zu zwei Button-Breiten + offset = min(x, actionWidth * 2 + 16) } else { + // Links: ein Button offset = max(x, -(actionWidth + 16)) } } @@ -404,12 +538,16 @@ private struct DeletableMomentRow: View { withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 } return } - if x > actionWidth + 20 { - // Vollständiges Rechts-Wischen: sofort togglen, zurückspringen + if x > actionWidth * 2 + 20 { + // 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 + 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 } + // 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 } } else { @@ -435,14 +573,23 @@ struct MomentRowView: View { var onEditMeeting: (() -> Void)? = nil var onToggleIntention: (() -> Void)? = nil + /// Wird lokal gecacht, damit die Ansicht auf CalendarEventStore-Änderungen reagieren kann. + @State private var hasCalendarEvent = false + var body: some View { - switch moment.type { - case .meeting: - meetingRow - case .intention: - intentionRow - default: - standardRow + Group { + switch moment.type { + case .meeting: meetingRow + case .intention: intentionRow + default: standardRow + } + } + .onAppear { + hasCalendarEvent = CalendarEventStore.identifier(for: moment.id) != nil + } + .onReceive(NotificationCenter.default.publisher(for: CalendarEventStore.didChangeNotification)) { n in + guard (n.object as? UUID) == moment.id else { return } + hasCalendarEvent = CalendarEventStore.identifier(for: moment.id) != nil } } @@ -609,6 +756,11 @@ struct MomentRowView: View { .font(.system(size: 10)) .foregroundStyle(.orange) } + if hasCalendarEvent { + Image(systemName: "calendar") + .font(.system(size: 10)) + .foregroundStyle(theme.contentTertiary) + } Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) @@ -656,6 +808,129 @@ struct MomentRowView: View { } } +// MARK: - Edit Moment View + +struct EditMomentView: View { + @Environment(\.nahbarTheme) var theme + @Environment(\.modelContext) var modelContext + @Environment(\.dismiss) var dismiss + + let moment: Moment + + @State private var text: String + @State private var createdAt: Date + @FocusState private var isFocused: Bool + + init(moment: Moment) { + self.moment = moment + self._text = State(initialValue: moment.text) + self._createdAt = State(initialValue: moment.createdAt) + } + + private var isValid: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty } + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + + // Zeitpunkt + VStack(spacing: 0) { + DatePicker( + "Zeitpunkt", + selection: $createdAt, + displayedComponents: [.date, .hourAndMinute] + ) + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + .environment(\.locale, Locale.current) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + + // Text + ZStack(alignment: .topLeading) { + if text.isEmpty { + Text("Moment…") + .font(.system(size: 16)) + .foregroundStyle(theme.contentTertiary) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .allowsHitTesting(false) + } + TextEditor(text: $text) + .font(.system(size: 16, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + .tint(theme.accent) + .scrollContentBackground(.hidden) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .focused($isFocused) + } + .frame(minHeight: 180) + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + + Spacer() + } + .padding(.top, 16) + .background(theme.backgroundPrimary.ignoresSafeArea()) + .navigationTitle("Moment bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .themedNavBar() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Abbrechen") { dismiss() } + .foregroundStyle(theme.contentSecondary) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Fertig") { save() } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(isValid ? theme.accent : theme.contentTertiary) + .disabled(!isValid) + } + } + } + .onAppear { isFocused = true } + } + + private func save() { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + moment.text = trimmed + moment.createdAt = createdAt + moment.updatedAt = Date() + moment.person?.touch() + do { + try modelContext.save() + } catch { + AppEventLog.shared.record( + "Fehler beim Bearbeiten des Moments: \(error.localizedDescription)", + level: .error, category: "Moment" + ) + } + // Verknüpften Kalendereintrag aktualisieren, falls vorhanden + if let eventID = CalendarEventStore.identifier(for: moment.id) { + let firstName = moment.person?.firstName ?? "" + let title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), firstName) + let savedDate = createdAt + Task { + await CalendarManager.shared.updateEvent( + identifier: eventID, + title: title, + notes: trimmed, + newStartDate: savedDate + ) + } + } + dismiss() + } +} + // MARK: - Info Row struct InfoRowView: View { diff --git a/nahbar/nahbar/PersonalityEngine.swift b/nahbar/nahbar/PersonalityEngine.swift index 2d9811b..977f4a5 100644 --- a/nahbar/nahbar/PersonalityEngine.swift +++ b/nahbar/nahbar/PersonalityEngine.swift @@ -160,11 +160,83 @@ enum PersonalityEngine { } } - /// Gibt an, ob "Etwas Neues ausprobieren" hervorgehoben werden soll. + /// Gibt an, ob Erlebnis-Aktivitäten hervorgehoben werden sollen. static func highlightNovelty(for profile: PersonalityProfile?) -> Bool { profile?.level(for: .openness) == .high } + /// Gibt `count` Aktivitätsvorschläge zurück, gewichtet nach Persönlichkeit und Kontakt-Tag. + /// Innerhalb gleicher Scores wird zufällig variiert – jeder Aufruf kann andere Ergebnisse liefern. + static func suggestedActivities( + for profile: PersonalityProfile?, + tag: PersonTag?, + count: Int = 2 + ) -> [String] { + let preferred = preferredActivityStyle(for: profile) + let highlightNew = highlightNovelty(for: profile) + + func score(_ s: ActivitySuggestion) -> Int { + var p = 0 + if s.style == preferred { p += 2 } + if s.isNovelty && highlightNew { p += 1 } + if let t = s.preferredTag, t == tag { p += 1 } + return p + } + + // Nach Score gruppieren, innerhalb jeder Gruppe mischen → Abwechslung + let grouped = Dictionary(grouping: activityPool) { score($0) } + var result: [String] = [] + for key in grouped.keys.sorted(by: >) { + guard result.count < count else { break } + let bucket = (grouped[key] ?? []).shuffled() + for item in bucket { + guard result.count < count else { break } + result.append(item.text) + } + } + return result + } + + // MARK: - Aktivitäts-Pool (intern, für Tests zugänglich via suggestedActivities) + + static let activityPool: [ActivitySuggestion] = [ + // ── 1:1 ────────────────────────────────────────────────────────────── + ActivitySuggestion("Kaffee trinken", style: .oneOnOne, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Spazieren gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Zusammen frühstücken", style: .oneOnOne, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Mittagessen", style: .oneOnOne, isNovelty: false, preferredTag: .work), + ActivitySuggestion("Auf ein Getränk treffen", style: .oneOnOne, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Zusammen kochen", style: .oneOnOne, isNovelty: false, preferredTag: .family), + ActivitySuggestion("Bummeln gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Rad fahren", style: .oneOnOne, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Joggen gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Picknick", style: .oneOnOne, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Besuch machen", style: .oneOnOne, isNovelty: false, preferredTag: .family), + ActivitySuggestion("Gemeinsam lesen", style: .oneOnOne, isNovelty: false, preferredTag: nil), + // ── Gruppe ─────────────────────────────────────────────────────────── + ActivitySuggestion("Abendessen", style: .group, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Spieleabend", style: .group, isNovelty: false, preferredTag: .friends), + ActivitySuggestion("Kino", style: .group, isNovelty: false, preferredTag: .friends), + ActivitySuggestion("Konzert oder Show", style: .group, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Museum besuchen", style: .group, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Wandern", style: .group, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Grillabend", style: .group, isNovelty: false, preferredTag: .friends), + ActivitySuggestion("Sportevent", style: .group, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Veranstaltung besuchen", style: .group, isNovelty: false, preferredTag: .community), + // ── Erlebnis ───────────────────────────────────────────────────────── + ActivitySuggestion("Etwas Neues ausprobieren", style: nil, isNovelty: true, preferredTag: nil), + ActivitySuggestion("Escape Room", style: nil, isNovelty: true, preferredTag: .friends), + ActivitySuggestion("Kochkurs", style: nil, isNovelty: true, preferredTag: nil), + ActivitySuggestion("Weinprobe oder Tasting", style: nil, isNovelty: true, preferredTag: nil), + ActivitySuggestion("Kletterpark", style: nil, isNovelty: true, preferredTag: .friends), + ActivitySuggestion("Workshop besuchen", style: nil, isNovelty: true, preferredTag: .community), + ActivitySuggestion("Karaoke", style: nil, isNovelty: true, preferredTag: .friends), + // ── Einfach / Remote ───────────────────────────────────────────────── + ActivitySuggestion("Anrufen", style: nil, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Nachricht schicken", style: nil, isNovelty: false, preferredTag: nil), + ActivitySuggestion("Artikel oder Tipp teilen", style: nil, isNovelty: false, preferredTag: nil), + ] + // MARK: - Intervall-Empfehlung für Einstellungen /// Gibt den empfohlenen Benachrichtigungs-Intervall für das Einstellungsmenü zurück. @@ -197,3 +269,18 @@ enum ActivityStyle { case group case oneOnOne } + +/// Ein einzelner Aktivitätsvorschlag aus dem Pool. +struct ActivitySuggestion { + let text: String + let style: ActivityStyle? + let isNovelty: Bool + let preferredTag: PersonTag? + + init(_ text: String, style: ActivityStyle?, isNovelty: Bool, preferredTag: PersonTag?) { + self.text = text + self.style = style + self.isNovelty = isNovelty + self.preferredTag = preferredTag + } +} diff --git a/nahbar/nahbar/SplashView.swift b/nahbar/nahbar/SplashView.swift index 20effde..af41aba 100644 --- a/nahbar/nahbar/SplashView.swift +++ b/nahbar/nahbar/SplashView.swift @@ -1,4 +1,15 @@ import SwiftUI +import NaturalLanguage + +// MARK: - Language Detection + +/// Gibt die dominante Sprache eines Textes zurück (via NLLanguageRecognizer). +/// Interne Sichtbarkeit, damit Unit-Tests darauf zugreifen können. +func detectsDominantLanguage(_ text: String) -> NLLanguage? { + let recognizer = NLLanguageRecognizer() + recognizer.processString(text) + return recognizer.dominantLanguage +} // MARK: - API Response Models @@ -167,16 +178,25 @@ struct SplashView: View { } /// https://api.zitat-service.de – kostenlos, Deutsch + /// Wiederholt den Abruf bis zu 3x, falls die API ein nicht-deutsches Zitat liefert. private func fetchZitatService() async -> (text: String, author: String)? { guard let url = URL(string: "https://api.zitat-service.de/v1/quote?language=de") else { return nil } - do { - let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url, timeoutInterval: 1)) - let r = try JSONDecoder().decode(ZitatServiceResponse.self, from: data) - let author = (r.authorName == "Unbekannt") ? "" : r.authorName - return (r.quote, author) - } catch { - return nil + for _ in 0..<3 { + do { + let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url, timeoutInterval: 1)) + let r = try JSONDecoder().decode(ZitatServiceResponse.self, from: data) + guard isGermanText(r.quote) else { continue } + let author = (r.authorName == "Unbekannt") ? "" : r.authorName + return (r.quote, author) + } catch { + return nil + } } + return nil + } + + private func isGermanText(_ text: String) -> Bool { + detectsDominantLanguage(text) == .german } /// https://zenquotes.io – kostenlos, kein API-Key, Englisch diff --git a/nahbar/nahbarTests/CalendarManagerTests.swift b/nahbar/nahbarTests/CalendarManagerTests.swift new file mode 100644 index 0000000..3549e8d --- /dev/null +++ b/nahbar/nahbarTests/CalendarManagerTests.swift @@ -0,0 +1,270 @@ +import Testing +import Foundation +@testable import nahbar + +// MARK: - CalendarEventStore Tests +// +// Testet das UserDefaults-basierte Mapping Moment-UUID → EKEvent-Identifier. +// CalendarManager selbst (EKEventStore, Berechtigungsfluss) erfordert einen +// echten Gerätezugang und ist daher nicht sinnvoll unit-testbar. + +// Serialisiert, weil CalendarEventStore UserDefaults.standard shared state nutzt. +// Parallele Ausführung würde dazu führen, dass init()-Aufrufe verschiedener Tests +// den Key gegenseitig wegräumen. +@Suite("CalendarEventStore – CRUD", .serialized) +struct CalendarEventStoreCRUDTests { + + private let storeKey = "nahbar.momentCalendarEvents" + + /// Löscht den Mapping-Key vor jedem Test, um Seiteneffekte zu vermeiden. + init() { + UserDefaults.standard.removeObject(forKey: storeKey) + } + + @Test("initial: kein Identifier für unbekannte UUID") + func noIdentifierForUnknownUUID() { + let unknownID = UUID() + #expect(CalendarEventStore.identifier(for: unknownID) == nil) + } + + @Test("save + identifier: gespeicherter Wert wird korrekt zurückgegeben") + func saveAndRetrieve() { + let momentID = UUID() + let eventID = "EK-ABC-123" + + CalendarEventStore.save(momentID: momentID, eventIdentifier: eventID) + + #expect(CalendarEventStore.identifier(for: momentID) == eventID) + } + + @Test("remove: nach dem Entfernen ist identifier nil") + func removeDeletesEntry() { + let momentID = UUID() + CalendarEventStore.save(momentID: momentID, eventIdentifier: "to-delete") + CalendarEventStore.remove(momentID: momentID) + + #expect(CalendarEventStore.identifier(for: momentID) == nil) + } + + @Test("remove: nicht vorhandener Eintrag wirft keinen Fehler") + func removeOfMissingEntryIsNoop() { + let unknownID = UUID() + // Darf keinen Crash oder Exception auslösen + CalendarEventStore.remove(momentID: unknownID) + #expect(CalendarEventStore.identifier(for: unknownID) == nil) + } + + @Test("mehrere Einträge: jeder UUID erhält seinen eigenen Identifier") + func multipleEntriesAreIndependent() { + let ids = (0..<5).map { _ in UUID() } + let eventIDs = ids.map { "event-\($0)" } + + for (id, eventID) in zip(ids, eventIDs) { + CalendarEventStore.save(momentID: id, eventIdentifier: eventID) + } + + for (id, eventID) in zip(ids, eventIDs) { + #expect(CalendarEventStore.identifier(for: id) == eventID) + } + } + + @Test("remove betrifft nur den spezifischen Eintrag, andere bleiben erhalten") + func removeDoesNotAffectOtherEntries() { + let id1 = UUID() + let id2 = UUID() + CalendarEventStore.save(momentID: id1, eventIdentifier: "event-1") + CalendarEventStore.save(momentID: id2, eventIdentifier: "event-2") + + CalendarEventStore.remove(momentID: id1) + + #expect(CalendarEventStore.identifier(for: id1) == nil) + #expect(CalendarEventStore.identifier(for: id2) == "event-2") + } + + @Test("überschreiben: zweites save ersetzt ersten Identifier") + func overwriteUpdatesValue() { + let momentID = UUID() + CalendarEventStore.save(momentID: momentID, eventIdentifier: "old-event") + CalendarEventStore.save(momentID: momentID, eventIdentifier: "new-event") + + #expect(CalendarEventStore.identifier(for: momentID) == "new-event") + } + + @Test("identifier ist nach save deterministisch (mehrfache Abfrage gibt gleichen Wert)") + func identifierIsDeterministic() { + let momentID = UUID() + let eventID = "stable-\(UUID().uuidString)" + CalendarEventStore.save(momentID: momentID, eventIdentifier: eventID) + + let first = CalendarEventStore.identifier(for: momentID) + let second = CalendarEventStore.identifier(for: momentID) + + #expect(first == second) + #expect(first == eventID) + } + + @Test("UUID-String-Roundtrip: Schlüssel überlebt Serialisierung") + func uuidStringKeyRoundTrip() { + // Stellt sicher, dass UUID().uuidString als Dictionary-Key korrekt round-trippt + let momentID = UUID() + let eventID = "ek-\(UUID().uuidString)" + + CalendarEventStore.save(momentID: momentID, eventIdentifier: eventID) + + // Lade den Raw-Store und prüfe den Schlüssel direkt + let raw = UserDefaults.standard.dictionary(forKey: storeKey) as? [String: String] + #expect(raw?[momentID.uuidString] == eventID) + } +} + +// MARK: - Update-Logik + +// CalendarManager.updateEvent greift auf EKEventStore (Gerätezugang) zu und ist +// daher nicht vollständig unit-testbar. Die folgenden Tests verifizieren die +// reinen Berechnungslogiken, die updateEvent intern verwendet. + +@Suite("CalendarManager – Update-Logik") +struct CalendarManagerUpdateLogicTests { + + private let marker = "— via nahbar" + + // MARK: Notiz-Formatierung + + @Test("Notizen mit Text: marker wird durch zwei Zeilenumbrüche getrennt angehängt") + func notesWithTextAppendMarker() { + let text = "Schönes Treffen" + let expected = "\(text)\n\n\(marker)" + + let result: String = { + if !text.isEmpty { + return "\(text)\n\n\(marker)" + } else { + return marker + } + }() + + #expect(result == expected) + } + + @Test("Leerer Text: Notizen bestehen nur aus dem marker") + func emptyTextProducesOnlyMarker() { + let text = "" + + let result: String = { + if !text.isEmpty { + return "\(text)\n\n\(marker)" + } else { + return marker + } + }() + + #expect(result == marker) + } + + @Test("Marker ist exakt '— via nahbar'") + func markerFormat() { + #expect(marker == "— via nahbar") + } + + // MARK: Dauer-Berechnung + + @Test("Dauer bleibt erhalten wenn Startdatum verschoben wird") + func durationPreservedOnShift() { + let originalStart = Date(timeIntervalSinceReferenceDate: 0) + let originalEnd = Date(timeIntervalSinceReferenceDate: 3600) // 1 Stunde + let newStart = Date(timeIntervalSinceReferenceDate: 7200) // 2 Stunden später + + let duration = originalEnd.timeIntervalSince(originalStart) + let newEnd = newStart.addingTimeInterval(duration) + + #expect(duration == 3600) + #expect(newEnd.timeIntervalSince(newStart) == 3600) + } + + @Test("Startdatum-Verschiebung ändert Enddatum proportional") + func endDateMovesWithStart() { + let base = Date(timeIntervalSinceReferenceDate: 1_000_000) + let duration = TimeInterval(90 * 60) // 90 Minuten + let shift = TimeInterval(24 * 3600) // 1 Tag vorwärts + + let originalEnd = base.addingTimeInterval(duration) + let newStart = base.addingTimeInterval(shift) + let newEnd = newStart.addingTimeInterval(duration) + + #expect(newEnd.timeIntervalSince(newStart) == originalEnd.timeIntervalSince(base)) + } + + // MARK: isAllDay-Schutz + + @Test("isAllDay: Startdatum wird nicht verändert (Schutz-Flag)") + func allDayEventSkipsDateShift() { + let isAllDay = true + let origStart = Date(timeIntervalSinceReferenceDate: 0) + let newStart = Date(timeIntervalSinceReferenceDate: 86400) + + // Simuliert den Guard in updateEvent + var effectiveStart = origStart + if !isAllDay { + effectiveStart = newStart + } + + #expect(effectiveStart == origStart) + } + + @Test("nicht-Ganztages: Startdatum wird auf newStart gesetzt") + func nonAllDayEventShiftsDate() { + let isAllDay = false + let origStart = Date(timeIntervalSinceReferenceDate: 0) + let newStart = Date(timeIntervalSinceReferenceDate: 86400) + + var effectiveStart = origStart + if !isAllDay { + effectiveStart = newStart + } + + #expect(effectiveStart == newStart) + } +} + +// MARK: - Alarm-Offset Semantik + +@Suite("CalendarManager – Alarm-Offset-Semantik") +struct CalendarAlarmOffsetTests { + + @Test("Offset 0 wird als 'keine Erinnerung' interpretiert") + func zeroOffsetMeansNoAlarm() { + // Konvention: AddMomentView wandelt 0.0 → nil vor dem createEvent-Aufruf + let rawOffset: Double = 0.0 + let alarmOffset: TimeInterval? = rawOffset == 0 ? nil : rawOffset + #expect(alarmOffset == nil) + } + + @Test("negativer Offset entspricht einem Zeitpunkt vor dem Event") + func negativeOffsetIsBeforeEvent() { + let oneHourBefore: TimeInterval = -3600 + #expect(oneHourBefore < 0) + } + + @Test("Alarm-Offsets haben erwartete Werte") + func alarmOffsetValues() { + let offsets: [(String, Double)] = [ + ("5 Min", -300), + ("15 Min", -900), + ("1 Std", -3600), + ("1 Tag", -86400), + ] + for (label, offset) in offsets { + #expect(offset < 0, "Offset '\(label)' muss negativ sein") + } + } + + @Test("1 Tag entspricht 86400 Sekunden") + func oneDayIs86400Seconds() { + #expect(-86400.0 == -(24 * 60 * 60)) + } + + @Test("1 Stunde entspricht 3600 Sekunden") + func oneHourIs3600Seconds() { + #expect(-3600.0 == -(60 * 60)) + } +} diff --git a/nahbar/nahbarTests/ContactPickerTests.swift b/nahbar/nahbarTests/ContactPickerTests.swift index 5b68358..c6648cf 100644 --- a/nahbar/nahbarTests/ContactPickerTests.swift +++ b/nahbar/nahbarTests/ContactPickerTests.swift @@ -215,6 +215,50 @@ struct ContactImportTests { #expect(ContactImport.from(CNMutableContact()).location == "") } + @Test("Vollständige deutsche Adresse: Straße + PLZ + Stadt + Land") + func fullGermanAddress() { + let contact = CNMutableContact() + let address = CNMutablePostalAddress() + address.street = "Musterstraße 12" + address.postalCode = "10115" + address.city = "Berlin" + address.country = "Deutschland" + contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)] + #expect(ContactImport.from(contact).location == "Musterstraße 12, 10115 Berlin, Deutschland") + } + + @Test("Vollständige US-Adresse: Straße + PLZ + Stadt + State + Land") + func fullUSAddress() { + let contact = CNMutableContact() + let address = CNMutablePostalAddress() + address.street = "1 Infinite Loop" + address.postalCode = "95014" + address.city = "Cupertino" + address.state = "CA" + address.country = "USA" + contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)] + #expect(ContactImport.from(contact).location == "1 Infinite Loop, 95014 Cupertino, CA, USA") + } + + @Test("Mehrzeilige Straße: Zeilenumbrüche werden zu Komma-Leerzeichen") + func multiLineStreet() { + let contact = CNMutableContact() + let address = CNMutablePostalAddress() + address.street = "Musterstraße 12\nHinterhaus" + address.city = "Hamburg" + contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)] + #expect(ContactImport.from(contact).location == "Musterstraße 12, Hinterhaus, Hamburg") + } + + @Test("Nur PLZ ohne Stadt → PLZ als cityPart") + func postalCodeOnly() { + let contact = CNMutableContact() + let address = CNMutablePostalAddress() + address.postalCode = "10115" + contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)] + #expect(ContactImport.from(contact).location == "10115") + } + @Test("Geburtstag mit vollständigem Datum → wird übernommen") func birthdayFullDate() { let contact = CNMutableContact() diff --git a/nahbar/nahbarTests/NahbarPersonalityTests.swift b/nahbar/nahbarTests/NahbarPersonalityTests.swift index e383d81..67c8ce1 100644 --- a/nahbar/nahbarTests/NahbarPersonalityTests.swift +++ b/nahbar/nahbarTests/NahbarPersonalityTests.swift @@ -429,6 +429,68 @@ struct PersonalityEngineBehaviorTests { } } +// MARK: - suggestedActivities Tests + +@Suite("PersonalityEngine – suggestedActivities") +struct SuggestedActivitiesTests { + + @Test("Gibt genau count Elemente zurück") + func returnsRequestedCount() { + let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 2) + #expect(result.count == 2) + } + + @Test("count: 1 → genau ein Vorschlag") + func countOne() { + let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 1) + #expect(result.count == 1) + } + + @Test("Alle zurückgegebenen Texte stammen aus dem Pool") + func resultsAreFromPool() { + let poolTexts = Set(PersonalityEngine.activityPool.map(\.text)) + let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 5) + for text in result { + #expect(poolTexts.contains(text), "'\(text)' nicht im Pool") + } + } + + @Test("Pool hat mindestens 20 Einträge") + func poolIsSufficient() { + #expect(PersonalityEngine.activityPool.count >= 20) + } + + @Test("Keine Duplikate in einem Ergebnis") + func noDuplicates() { + let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 5) + #expect(result.count == Set(result).count) + } + + @Test("Ergebnis ist nicht leer wenn Pool vorhanden") + func notEmptyWhenPoolExists() { + let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 2) + #expect(!result.isEmpty) + } + + @Test("Pool enthält Erlebnis-Aktivitäten (isNovelty)") + func poolContainsNoveltyActivities() { + #expect(PersonalityEngine.activityPool.contains { $0.isNovelty }) + } + + @Test("Pool enthält 1:1 und Gruppen-Aktivitäten") + func poolContainsBothStyles() { + #expect(PersonalityEngine.activityPool.contains { $0.style == .oneOnOne }) + #expect(PersonalityEngine.activityPool.contains { $0.style == .group }) + } + + @Test("Pool enthält Tag-spezifische Aktivitäten") + func poolContainsTagSpecificActivities() { + #expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .friends }) + #expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .family }) + #expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .work }) + } +} + // MARK: - GenderSelectionScreen Skip-Logik @Suite("PersonalityQuiz – Geschlechtsabfrage überspringen") diff --git a/nahbar/nahbarTests/SplashViewTests.swift b/nahbar/nahbarTests/SplashViewTests.swift new file mode 100644 index 0000000..63c2499 --- /dev/null +++ b/nahbar/nahbarTests/SplashViewTests.swift @@ -0,0 +1,38 @@ +import Testing +import NaturalLanguage +@testable import nahbar + +@Suite("SplashView – Spracherkennung") +struct SplashLanguageDetectionTests { + + @Test("Deutscher Text wird als Deutsch erkannt") + func germanTextIsGerman() { + let text = "Der Mensch ist dem Menschen am nötigsten." + #expect(detectsDominantLanguage(text) == .german) + } + + @Test("Niederländischer Text wird nicht als Deutsch erkannt") + func dutchTextIsNotGerman() { + let text = "De mens heeft de medemens het meest nodig." + #expect(detectsDominantLanguage(text) != .german) + } + + @Test("Englischer Text wird nicht als Deutsch erkannt") + func englishTextIsNotGerman() { + let text = "Happiness is only real when shared." + #expect(detectsDominantLanguage(text) != .german) + } + + @Test("Französischer Text wird nicht als Deutsch erkannt") + func frenchTextIsNotGerman() { + let text = "Le bonheur n'est réel que lorsqu'il est partagé." + #expect(detectsDominantLanguage(text) != .german) + } + + @Test("Leerer String liefert nil oder nicht Deutsch") + func emptyStringIsNotGerman() { + let result = detectsDominantLanguage("") + // NLLanguageRecognizer liefert nil bei leerem Text + #expect(result == nil || result != .german) + } +}