Weitere Konsolidierung Momente, Splash Absicherung. Übersetzung...

This commit is contained in:
2026-04-20 19:12:13 +02:00
parent 53812e4924
commit e5cfb8b4ba
17 changed files with 2272 additions and 248 deletions
+8 -2
View File
@@ -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 = "<group>"; };
269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = "<group>"; };
26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncMonitor.swift; sourceTree = "<group>"; };
26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileStore.swift; sourceTree = "<group>"; };
@@ -284,6 +286,7 @@
26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */,
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */,
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
2670595B2F96640E00956084 /* CalendarManager.swift */,
);
path = nahbar;
sourceTree = "<group>";
@@ -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;
+88 -47
View File
@@ -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)
}
}
}
+175
View File
@@ -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
}
}
+10 -2
View File
@@ -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 = ""
}
+32 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+83 -4
View File
@@ -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<Content: View>: 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<Content: View>: 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<Content: View>: 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 {
+9 -3
View File
@@ -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.
@@ -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 {
+356 -81
View File
@@ -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 {
+88 -1
View File
@@ -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
}
}
+27 -7
View File
@@ -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
@@ -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))
}
}
@@ -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()
@@ -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")
+38
View File
@@ -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)
}
}