Weitere Konsolidierung Momente, Splash Absicherung. Übersetzung...
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; };
|
||||||
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; };
|
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; };
|
||||||
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */; };
|
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */; };
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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>"; };
|
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>"; };
|
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>"; };
|
26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileStore.swift; sourceTree = "<group>"; };
|
||||||
@@ -284,6 +286,7 @@
|
|||||||
26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */,
|
26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */,
|
||||||
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */,
|
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */,
|
||||||
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
|
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
|
||||||
|
2670595B2F96640E00956084 /* CalendarManager.swift */,
|
||||||
);
|
);
|
||||||
path = nahbar;
|
path = nahbar;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -463,6 +466,7 @@
|
|||||||
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */,
|
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */,
|
||||||
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
|
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
|
||||||
26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */,
|
26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */,
|
||||||
|
2670595C2F96640E00956084 /* CalendarManager.swift in Sources */,
|
||||||
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */,
|
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */,
|
||||||
26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */,
|
26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */,
|
||||||
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */,
|
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */,
|
||||||
@@ -653,7 +657,8 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -693,7 +698,8 @@
|
|||||||
ENABLE_TESTABILITY = NO;
|
ENABLE_TESTABILITY = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -26,6 +26,9 @@ struct AddMomentView: View {
|
|||||||
return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date()
|
return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date()
|
||||||
}()
|
}()
|
||||||
@State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag
|
@State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag
|
||||||
|
@State private var availableCalendars: [EKCalendar] = []
|
||||||
|
@State private var selectedCalendarID: String = ""
|
||||||
|
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
|
||||||
|
|
||||||
// Vorhaben: Erinnerung
|
// Vorhaben: Erinnerung
|
||||||
@State private var addReminder = false
|
@State private var addReminder = false
|
||||||
@@ -203,11 +206,67 @@ struct AddMomentView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 4)
|
.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)
|
.background(theme.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
.padding(.horizontal, 20)
|
.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)
|
// MARK: - Erinnerungs-Sektion (Vorhaben)
|
||||||
@@ -290,8 +349,9 @@ struct AddMomentView: View {
|
|||||||
)
|
)
|
||||||
modelContext.insert(calEntry)
|
modelContext.insert(calEntry)
|
||||||
person.logEntries?.append(calEntry)
|
person.logEntries?.append(calEntry)
|
||||||
createCalendarEvent(notes: trimmed) {
|
let momentID = moment.id
|
||||||
// Callback nach Dismiss
|
Task {
|
||||||
|
await createAndStoreCalendarEvent(for: momentID, notes: trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -299,21 +359,7 @@ struct AddMomentView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard addToCalendar else {
|
dismiss()
|
||||||
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) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Vorhaben-Erinnerung
|
// 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) {
|
private func createAndStoreCalendarEvent(for momentID: UUID, notes: String) async {
|
||||||
let store = EKEventStore()
|
let isAllDay = eventDuration < 0
|
||||||
|
let startDate: Date
|
||||||
|
let endDate: Date
|
||||||
|
|
||||||
let handler: (Bool, Error?) -> Void = { [store] granted, _ in
|
if isAllDay {
|
||||||
guard granted, let calendar = store.defaultCalendarForNewEvents else {
|
startDate = Calendar.current.startOfDay(for: eventDate)
|
||||||
DispatchQueue.main.async { self.dismiss() }
|
endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate) ?? startDate
|
||||||
return
|
} else {
|
||||||
}
|
startDate = eventDate
|
||||||
|
endDate = eventDate.addingTimeInterval(eventDuration)
|
||||||
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 #available(iOS 17.0, *) {
|
let calendarID = selectedCalendarID.isEmpty ? nil : selectedCalendarID
|
||||||
store.requestWriteOnlyAccessToEvents(completion: handler)
|
let title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), person.firstName)
|
||||||
} else {
|
let alarmOffset: TimeInterval? = eventAlarmOffset == 0 ? nil : eventAlarmOffset
|
||||||
store.requestAccess(to: .event, completion: handler)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,8 +238,16 @@ struct ContactImport {
|
|||||||
|
|
||||||
let location: String
|
let location: String
|
||||||
if let postal = contact.postalAddresses.first?.value {
|
if let postal = contact.postalAddresses.first?.value {
|
||||||
// Bundesstaat/Region einbeziehen, falls vorhanden
|
var parts: [String] = []
|
||||||
location = [postal.city, postal.state, postal.country].filter { !$0.isEmpty }.joined(separator: ", ")
|
// 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 {
|
} else {
|
||||||
location = ""
|
location = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import Contacts
|
||||||
|
|
||||||
private let socialStyleOptions = [
|
private let socialStyleOptions = [
|
||||||
"Introvertiert",
|
"Introvertiert",
|
||||||
@@ -319,6 +320,7 @@ struct IchEditView: View {
|
|||||||
@State private var socialStyle: String
|
@State private var socialStyle: String
|
||||||
@State private var selectedPhoto: UIImage?
|
@State private var selectedPhoto: UIImage?
|
||||||
@State private var photoPickerItem: PhotosPickerItem? = nil
|
@State private var photoPickerItem: PhotosPickerItem? = nil
|
||||||
|
@State private var showingContactPicker = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let store = UserProfileStore.shared
|
let store = UserProfileStore.shared
|
||||||
@@ -443,9 +445,22 @@ struct IchEditView: View {
|
|||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.foregroundStyle(theme.accent)
|
.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
|
.onChange(of: photoPickerItem) { _, item in
|
||||||
Task {
|
Task {
|
||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
@@ -531,6 +546,22 @@ struct IchEditView: View {
|
|||||||
.padding(.vertical, 12)
|
.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
|
// MARK: - Helpers
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
+981
-100
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,16 @@ struct LogbuchView: View {
|
|||||||
@State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests
|
@State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests
|
||||||
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
|
@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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 28) {
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
@@ -104,6 +114,31 @@ struct LogbuchView: View {
|
|||||||
Task { await runAnalysis() }
|
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(
|
.onReceive(
|
||||||
NotificationCenter.default.publisher(
|
NotificationCenter.default.publisher(
|
||||||
for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification")
|
for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification")
|
||||||
@@ -135,6 +170,7 @@ struct LogbuchView: View {
|
|||||||
DeletableLogbuchRow(
|
DeletableLogbuchRow(
|
||||||
isImportant: moment.isImportant,
|
isImportant: moment.isImportant,
|
||||||
isLast: index == items.count - 1,
|
isLast: index == items.count - 1,
|
||||||
|
onEdit: { momentForTextEdit = moment },
|
||||||
onDelete: { deleteMoment(moment) },
|
onDelete: { deleteMoment(moment) },
|
||||||
onToggleImportant: { toggleImportant(moment) }
|
onToggleImportant: { toggleImportant(moment) }
|
||||||
) {
|
) {
|
||||||
@@ -152,6 +188,24 @@ struct LogbuchView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func deleteMoment(_ moment: Moment) {
|
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)
|
modelContext.delete(moment)
|
||||||
person.touch()
|
person.touch()
|
||||||
}
|
}
|
||||||
@@ -200,6 +254,13 @@ struct LogbuchView: View {
|
|||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(.orange)
|
.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))
|
Text(LocalizedStringKey(item.label))
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
@@ -483,12 +544,13 @@ struct LogbuchView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Deletable Logbuch Row
|
// 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 {
|
private struct DeletableLogbuchRow<Content: View>: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
let isImportant: Bool
|
let isImportant: Bool
|
||||||
let isLast: Bool
|
let isLast: Bool
|
||||||
|
let onEdit: () -> Void
|
||||||
let onDelete: () -> Void
|
let onDelete: () -> Void
|
||||||
let onToggleImportant: () -> Void
|
let onToggleImportant: () -> Void
|
||||||
@ViewBuilder let content: Content
|
@ViewBuilder let content: Content
|
||||||
@@ -499,6 +561,23 @@ private struct DeletableLogbuchRow<Content: View>: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
HStack(spacing: 0) {
|
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
|
// Links: Wichtig-Button
|
||||||
Button {
|
Button {
|
||||||
onToggleImportant()
|
onToggleImportant()
|
||||||
@@ -550,18 +629,18 @@ private struct DeletableLogbuchRow<Content: View>: View {
|
|||||||
let x = value.translation.width
|
let x = value.translation.width
|
||||||
guard abs(x) > abs(value.translation.height) * 0.6 else { return }
|
guard abs(x) > abs(value.translation.height) * 0.6 else { return }
|
||||||
if x > 0 {
|
if x > 0 {
|
||||||
offset = min(x, actionWidth + 16)
|
offset = min(x, actionWidth * 2 + 16)
|
||||||
} else {
|
} else {
|
||||||
offset = max(x, -(actionWidth + 16))
|
offset = max(x, -(actionWidth + 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let x = value.translation.width
|
let x = value.translation.width
|
||||||
if x > actionWidth + 20 {
|
if x > actionWidth * 2 + 20 {
|
||||||
onToggleImportant()
|
onToggleImportant()
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
} else if x > actionWidth / 2 {
|
} else if x > actionWidth / 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) {
|
} else if x < -(actionWidth / 2) {
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ struct NahbarContact: Identifiable, Codable, Equatable {
|
|||||||
/// Firma oder Organisation des Kontakts.
|
/// Firma oder Organisation des Kontakts.
|
||||||
var organizationName: String
|
var organizationName: String
|
||||||
var notes: 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.
|
/// Original CNContact identifier for stable matching against the system address book.
|
||||||
var cnIdentifier: String?
|
var cnIdentifier: String?
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ struct NahbarContact: Identifiable, Codable, Equatable {
|
|||||||
emailAddresses: [String] = [],
|
emailAddresses: [String] = [],
|
||||||
organizationName: String = "",
|
organizationName: String = "",
|
||||||
notes: String = "",
|
notes: String = "",
|
||||||
|
location: String = "",
|
||||||
cnIdentifier: String? = nil
|
cnIdentifier: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -39,6 +42,7 @@ struct NahbarContact: Identifiable, Codable, Equatable {
|
|||||||
self.emailAddresses = emailAddresses
|
self.emailAddresses = emailAddresses
|
||||||
self.organizationName = organizationName
|
self.organizationName = organizationName
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
|
self.location = location
|
||||||
self.cnIdentifier = cnIdentifier
|
self.cnIdentifier = cnIdentifier
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,15 +56,16 @@ struct NahbarContact: Identifiable, Codable, Equatable {
|
|||||||
self.organizationName = contact.organizationName
|
self.organizationName = contact.organizationName
|
||||||
// CNContactNoteKey requires a special entitlement – omitted intentionally.
|
// CNContactNoteKey requires a special entitlement – omitted intentionally.
|
||||||
self.notes = ""
|
self.notes = ""
|
||||||
|
self.location = ContactImport.from(contact).location
|
||||||
self.cnIdentifier = contact.identifier
|
self.cnIdentifier = contact.identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Codable (rückwärtskompatibel)
|
// MARK: - Codable (rückwärtskompatibel)
|
||||||
// Neue Felder (emailAddresses, organizationName) mit decodeIfPresent lesen,
|
// Neue Felder mit decodeIfPresent lesen, damit bestehende NahbarContacts.json
|
||||||
// damit bestehende NahbarContacts.json-Dateien ohne diese Felder weiterhin laden.
|
// ohne diese Felder weiterhin geladen werden können.
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
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 {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -72,6 +77,7 @@ struct NahbarContact: Identifiable, Codable, Equatable {
|
|||||||
emailAddresses = try c.decodeIfPresent([String].self, forKey: .emailAddresses) ?? []
|
emailAddresses = try c.decodeIfPresent([String].self, forKey: .emailAddresses) ?? []
|
||||||
organizationName = try c.decodeIfPresent(String.self, forKey: .organizationName) ?? ""
|
organizationName = try c.decodeIfPresent(String.self, forKey: .organizationName) ?? ""
|
||||||
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
|
||||||
|
location = try c.decodeIfPresent(String.self, forKey: .location) ?? ""
|
||||||
cnIdentifier = try c.decodeIfPresent(String.self, forKey: .cnIdentifier)
|
cnIdentifier = try c.decodeIfPresent(String.self, forKey: .cnIdentifier)
|
||||||
}
|
}
|
||||||
// encode(to:) wird vom Compiler synthetisiert, da alle Felder Encodable sind.
|
// 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
|
// 3. Import each selected contact as a Person in SwiftData
|
||||||
for contact in coordinator.selectedContacts {
|
for contact in coordinator.selectedContacts {
|
||||||
let person = Person(name: contact.fullName)
|
let person = Person(name: contact.fullName)
|
||||||
|
if !contact.location.isEmpty { person.location = contact.location }
|
||||||
modelContext.insert(person)
|
modelContext.insert(person)
|
||||||
}
|
}
|
||||||
if !coordinator.selectedContacts.isEmpty {
|
if !coordinator.selectedContacts.isEmpty {
|
||||||
|
|||||||
@@ -18,13 +18,22 @@ struct PersonDetailView: View {
|
|||||||
@State private var momentForEdit: Moment? = nil
|
@State private var momentForEdit: Moment? = nil
|
||||||
@State private var momentForSummary: 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
|
@StateObject private var personalityStore = PersonalityStore.shared
|
||||||
|
@State private var activityHint: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 28) {
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
personHeader
|
personHeader
|
||||||
momentsSection
|
momentsSection
|
||||||
|
if !person.sortedLogEntries.isEmpty { logbuchSection }
|
||||||
if hasInfoContent { infoSection }
|
if hasInfoContent { infoSection }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.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
|
// Schützt vor Crash wenn der ModelContext durch Migration oder
|
||||||
// CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden.
|
// CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden.
|
||||||
.onReceive(
|
.onReceive(
|
||||||
@@ -119,15 +150,6 @@ struct PersonDetailView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
SectionHeader(title: "Momente", icon: "clock")
|
SectionHeader(title: "Momente", icon: "clock")
|
||||||
Spacer()
|
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 {
|
Button {
|
||||||
showingAddMoment = true
|
showingAddMoment = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -164,6 +186,7 @@ struct PersonDetailView: View {
|
|||||||
isLast: index == person.sortedMoments.count - 1,
|
isLast: index == person.sortedMoments.count - 1,
|
||||||
onDelete: { deleteMoment(moment) },
|
onDelete: { deleteMoment(moment) },
|
||||||
onToggleImportant: { toggleImportant(moment) },
|
onToggleImportant: { toggleImportant(moment) },
|
||||||
|
onEdit: { momentForTextEdit = moment },
|
||||||
onRateMeeting: { momentForRating = moment },
|
onRateMeeting: { momentForRating = moment },
|
||||||
onAftermathMeeting: { momentForAftermath = moment },
|
onAftermathMeeting: { momentForAftermath = moment },
|
||||||
onViewSummary: { momentForSummary = 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 {
|
private func intentionSuggestionButton(profile: PersonalityProfile) -> some View {
|
||||||
let preferred = PersonalityEngine.preferredActivityStyle(for: profile)
|
let hint = activityHint.isEmpty ? refreshActivityHint(profile: profile) : activityHint
|
||||||
let highlightNew = PersonalityEngine.highlightNovelty(for: profile)
|
|
||||||
|
|
||||||
let activities: [(String, ActivityStyle?, Bool)] = [
|
return HStack(spacing: 0) {
|
||||||
("Kaffee trinken", .oneOnOne, false),
|
Button {
|
||||||
("Spazieren gehen", .oneOnOne, false),
|
showingAddMoment = true
|
||||||
("Zusammen essen", .group, false),
|
} label: {
|
||||||
("Etwas unternehmen", .group, false),
|
HStack(spacing: 6) {
|
||||||
("Etwas Neues ausprobieren", nil, true),
|
Image(systemName: "brain")
|
||||||
("Anrufen", nil, false),
|
.font(.system(size: 11))
|
||||||
]
|
.foregroundStyle(NahbarInsightStyle.accentPetrol)
|
||||||
|
Text("Idee: \(hint)")
|
||||||
func score(_ item: (String, ActivityStyle?, Bool)) -> Int {
|
.font(.system(size: 13))
|
||||||
var s = 0
|
.foregroundStyle(theme.contentSecondary)
|
||||||
if item.1 == preferred { s += 2 }
|
.lineLimit(1)
|
||||||
if item.2 && highlightNew { s += 1 }
|
}
|
||||||
return s
|
.padding(.leading, 14)
|
||||||
}
|
.padding(.vertical, 7)
|
||||||
let sorted = activities.sorted { score($0) > score($1) }
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
let topTwo = sorted.prefix(2).map { $0.0 }
|
}
|
||||||
let hint = topTwo.joined(separator: " oder ")
|
|
||||||
let topActivity = sorted.first?.0 ?? ""
|
// Neue Idee würfeln
|
||||||
|
Button {
|
||||||
return Button {
|
activityHint = refreshActivityHint(profile: profile)
|
||||||
// AddMomentView mit vorausgefülltem Intention-Typ öffnen
|
} label: {
|
||||||
// (PersonDetailView übergibt den Vorschlagstext via AddMomentView-Initialisierung)
|
Image(systemName: "arrow.clockwise")
|
||||||
showingAddMoment = true
|
.font(.system(size: 12))
|
||||||
_ = topActivity // Vorschlag wird in AddMomentView als Standardtyp .intention öffnen
|
.foregroundStyle(theme.contentTertiary)
|
||||||
} label: {
|
.padding(.horizontal, 12)
|
||||||
HStack(spacing: 6) {
|
.padding(.vertical, 7)
|
||||||
Image(systemName: "brain")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(NahbarInsightStyle.accentPetrol)
|
|
||||||
Text("Idee: \(hint)")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(theme.contentSecondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
.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
|
// MARK: - Info
|
||||||
|
|
||||||
private var hasInfoContent: Bool {
|
private var hasInfoContent: Bool {
|
||||||
@@ -266,6 +361,25 @@ struct PersonDetailView: View {
|
|||||||
// MARK: - Aktionen
|
// MARK: - Aktionen
|
||||||
|
|
||||||
private func deleteMoment(_ moment: Moment) {
|
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)
|
modelContext.delete(moment)
|
||||||
person.touch()
|
person.touch()
|
||||||
}
|
}
|
||||||
@@ -307,9 +421,9 @@ struct PersonDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Deletable Moment Row
|
// MARK: - Deletable Moment Row
|
||||||
// Links wischen → Löschen (rot)
|
// Links wischen → Löschen (rot)
|
||||||
// Rechts wischen → Als wichtig markieren (orange)
|
// Rechts wischen → Bearbeiten (accent) + Wichtig (orange)
|
||||||
// Vollständig rechts wischen → sofortiger Toggle, Zeile springt zurück
|
// Vollständig rechts wischen → sofortiger Wichtig-Toggle, Zeile springt zurück
|
||||||
|
|
||||||
private struct DeletableMomentRow: View {
|
private struct DeletableMomentRow: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@@ -317,6 +431,7 @@ private struct DeletableMomentRow: View {
|
|||||||
let isLast: Bool
|
let isLast: Bool
|
||||||
let onDelete: () -> Void
|
let onDelete: () -> Void
|
||||||
let onToggleImportant: () -> Void
|
let onToggleImportant: () -> Void
|
||||||
|
let onEdit: () -> Void
|
||||||
let onRateMeeting: () -> Void
|
let onRateMeeting: () -> Void
|
||||||
let onAftermathMeeting: () -> Void
|
let onAftermathMeeting: () -> Void
|
||||||
let onViewSummary: () -> Void
|
let onViewSummary: () -> Void
|
||||||
@@ -328,28 +443,47 @@ private struct DeletableMomentRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Hintergrund: beide Aktions-Buttons
|
// Hintergrund-Buttons
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Links: Wichtig-Button (sichtbar bei Rechts-Wischen)
|
|
||||||
Button {
|
// Linke Seite (sichtbar bei Rechts-Wischen): Bearbeiten + Wichtig
|
||||||
onToggleImportant()
|
HStack(spacing: 0) {
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
Button {
|
||||||
} label: {
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
VStack(spacing: 4) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { onEdit() }
|
||||||
Image(systemName: moment.isImportant ? "star.slash.fill" : "star.fill")
|
} label: {
|
||||||
.font(.system(size: 15, weight: .medium))
|
VStack(spacing: 4) {
|
||||||
Text(moment.isImportant ? "Entfernen" : "Wichtig")
|
Image(systemName: "pencil")
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
Text("Bearbeiten")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: actionWidth)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
.background(theme.accent)
|
||||||
.frame(width: actionWidth)
|
|
||||||
.frame(maxHeight: .infinity)
|
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()
|
Spacer()
|
||||||
|
|
||||||
// Rechts: Löschen-Button (sichtbar bei Links-Wischen)
|
// Rechte Seite (sichtbar bei Links-Wischen): Löschen
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.28, dampingFraction: 0.75)) {
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.75)) {
|
||||||
offset = -UIScreen.main.bounds.width
|
offset = -UIScreen.main.bounds.width
|
||||||
@@ -383,8 +517,6 @@ private struct DeletableMomentRow: View {
|
|||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.offset(x: offset)
|
.offset(x: offset)
|
||||||
// simultaneousGesture erlaubt dem übergeordneten ScrollView weiterhin zu scrollen.
|
|
||||||
// Der Winkeltest (Faktor 2.5) lässt nur klar horizontale Gesten durch.
|
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
DragGesture(minimumDistance: 20, coordinateSpace: .local)
|
DragGesture(minimumDistance: 20, coordinateSpace: .local)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
@@ -392,8 +524,10 @@ private struct DeletableMomentRow: View {
|
|||||||
let y = value.translation.height
|
let y = value.translation.height
|
||||||
guard abs(x) > abs(y) * 2.5 else { return }
|
guard abs(x) > abs(y) * 2.5 else { return }
|
||||||
if x > 0 {
|
if x > 0 {
|
||||||
offset = min(x, actionWidth + 16)
|
// Rechts: bis zu zwei Button-Breiten
|
||||||
|
offset = min(x, actionWidth * 2 + 16)
|
||||||
} else {
|
} else {
|
||||||
|
// Links: ein Button
|
||||||
offset = max(x, -(actionWidth + 16))
|
offset = max(x, -(actionWidth + 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,12 +538,16 @@ private struct DeletableMomentRow: View {
|
|||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if x > actionWidth + 20 {
|
if x > actionWidth * 2 + 20 {
|
||||||
// Vollständiges Rechts-Wischen: sofort togglen, zurückspringen
|
// Vollständiges Rechts-Wischen → Wichtig-Toggle, zurückspringen
|
||||||
onToggleImportant()
|
onToggleImportant()
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
|
} else if x > actionWidth {
|
||||||
|
// 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 {
|
} 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) {
|
} else if x < -(actionWidth / 2) {
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
||||||
} else {
|
} else {
|
||||||
@@ -435,14 +573,23 @@ struct MomentRowView: View {
|
|||||||
var onEditMeeting: (() -> Void)? = nil
|
var onEditMeeting: (() -> Void)? = nil
|
||||||
var onToggleIntention: (() -> 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 {
|
var body: some View {
|
||||||
switch moment.type {
|
Group {
|
||||||
case .meeting:
|
switch moment.type {
|
||||||
meetingRow
|
case .meeting: meetingRow
|
||||||
case .intention:
|
case .intention: intentionRow
|
||||||
intentionRow
|
default: standardRow
|
||||||
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))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(.orange)
|
.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")))
|
Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE")))
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.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
|
// MARK: - Info Row
|
||||||
|
|
||||||
struct InfoRowView: View {
|
struct InfoRowView: View {
|
||||||
|
|||||||
@@ -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 {
|
static func highlightNovelty(for profile: PersonalityProfile?) -> Bool {
|
||||||
profile?.level(for: .openness) == .high
|
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
|
// MARK: - Intervall-Empfehlung für Einstellungen
|
||||||
|
|
||||||
/// Gibt den empfohlenen Benachrichtigungs-Intervall für das Einstellungsmenü zurück.
|
/// Gibt den empfohlenen Benachrichtigungs-Intervall für das Einstellungsmenü zurück.
|
||||||
@@ -197,3 +269,18 @@ enum ActivityStyle {
|
|||||||
case group
|
case group
|
||||||
case oneOnOne
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import SwiftUI
|
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
|
// MARK: - API Response Models
|
||||||
|
|
||||||
@@ -167,16 +178,25 @@ struct SplashView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// https://api.zitat-service.de – kostenlos, Deutsch
|
/// 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)? {
|
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 }
|
guard let url = URL(string: "https://api.zitat-service.de/v1/quote?language=de") else { return nil }
|
||||||
do {
|
for _ in 0..<3 {
|
||||||
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url, timeoutInterval: 1))
|
do {
|
||||||
let r = try JSONDecoder().decode(ZitatServiceResponse.self, from: data)
|
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url, timeoutInterval: 1))
|
||||||
let author = (r.authorName == "Unbekannt") ? "" : r.authorName
|
let r = try JSONDecoder().decode(ZitatServiceResponse.self, from: data)
|
||||||
return (r.quote, author)
|
guard isGermanText(r.quote) else { continue }
|
||||||
} catch {
|
let author = (r.authorName == "Unbekannt") ? "" : r.authorName
|
||||||
return nil
|
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
|
/// 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 == "")
|
#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")
|
@Test("Geburtstag mit vollständigem Datum → wird übernommen")
|
||||||
func birthdayFullDate() {
|
func birthdayFullDate() {
|
||||||
let contact = CNMutableContact()
|
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
|
// MARK: - GenderSelectionScreen Skip-Logik
|
||||||
|
|
||||||
@Suite("PersonalityQuiz – Geschlechtsabfrage überspringen")
|
@Suite("PersonalityQuiz – Geschlechtsabfrage überspringen")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user