From bbf347b5081a0d275a6bc1c1d0679187c7bc3901 Mon Sep 17 00:00:00 2001 From: Sven Date: Sun, 19 Apr 2026 19:54:20 +0200 Subject: [PATCH] Komplettumbau auf "Momente" --- nahbar/AftermathNotificationManager.swift | 34 +- nahbar/AftermathRatingFlowView.swift | 28 +- nahbar/VisitEditFlowView.swift | 34 +- nahbar/VisitHistorySection.swift | 171 ------ nahbar/VisitRatingFlowView.swift | 99 +-- nahbar/VisitSummaryView.swift | 34 +- nahbar/nahbar/AddMomentView.swift | 164 ++++- nahbar/nahbar/PersonDetailView.swift | 699 ++++++++++------------ 8 files changed, 567 insertions(+), 696 deletions(-) delete mode 100644 nahbar/VisitHistorySection.swift diff --git a/nahbar/AftermathNotificationManager.swift b/nahbar/AftermathNotificationManager.swift index f7a67b1..d8d903e 100644 --- a/nahbar/AftermathNotificationManager.swift +++ b/nahbar/AftermathNotificationManager.swift @@ -12,14 +12,14 @@ final class AftermathNotificationManager { static let shared = AftermathNotificationManager() private init() {} - static let categoryID = "AFTERMATH_RATING" - static let actionID = "RATE_NOW" - static let visitIDKey = "visitID" + static let categoryID = "AFTERMATH_RATING" + static let actionID = "RATE_NOW" + static let momentIDKey = "momentID" // V5 – primärer Key static let personNameKey = "personName" // MARK: - Setup - /// Registriert die Notification-Kategorie mit "Jetzt bewerten"-Action. + /// Registriert die Notification-Kategorie mit „Jetzt bewerten"-Action. /// Muss beim App-Start einmalig aufgerufen werden. func registerCategory() { let rateNow = UNNotificationAction( @@ -38,12 +38,12 @@ final class AftermathNotificationManager { // MARK: - Schedule - /// Plant eine Nachwirkungs-Erinnerung für den angegebenen Besuch. + /// Plant eine Nachwirkungs-Erinnerung für den angegebenen Treffen-Moment. /// - Parameters: - /// - visitID: UUID des Visit-Objekts + /// - momentID: UUID des Moment-Objekts /// - personName: Name der Person (für Notification-Text) /// - delay: Verzögerung in Sekunden (Standard: 36 Stunden) - func scheduleAftermath(visitID: UUID, personName: String, delay: TimeInterval = 36 * 3600) { + func scheduleAftermath(momentID: UUID, personName: String, delay: TimeInterval = 36 * 3600) { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in guard granted else { logger.warning("Notification-Berechtigung abgelehnt – keine Nachwirkungs-Erinnerung.") @@ -52,11 +52,11 @@ final class AftermathNotificationManager { if let error { logger.error("Berechtigung-Fehler: \(error.localizedDescription)") } - self.createNotification(visitID: visitID, personName: personName, delay: delay) + self.createNotification(momentID: momentID, personName: personName, delay: delay) } } - private func createNotification(visitID: UUID, personName: String, delay: TimeInterval) { + private func createNotification(momentID: UUID, personName: String, delay: TimeInterval) { let content = UNMutableNotificationContent() content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName) // Persönlichkeitsgerechter Body-Text (softer für hohen Neurotizismus) @@ -70,13 +70,13 @@ final class AftermathNotificationManager { content.sound = .default content.categoryIdentifier = Self.categoryID content.userInfo = [ - Self.visitIDKey: visitID.uuidString, + Self.momentIDKey: momentID.uuidString, Self.personNameKey: personName ] let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false) let request = UNNotificationRequest( - identifier: notificationID(for: visitID), + identifier: notificationID(for: momentID), content: content, trigger: trigger ) @@ -85,7 +85,7 @@ final class AftermathNotificationManager { if let error { logger.error("Notification konnte nicht geplant werden: \(error.localizedDescription)") } else { - logger.info("Nachwirkungs-Erinnerung geplant für Visit \(visitID.uuidString) in \(Int(delay / 3600))h.") + logger.info("Nachwirkungs-Erinnerung geplant für Moment \(momentID.uuidString) in \(Int(delay / 3600))h.") } } } @@ -93,16 +93,16 @@ final class AftermathNotificationManager { // MARK: - Cancel /// Entfernt eine geplante Nachwirkungs-Erinnerung. - func cancelAftermath(visitID: UUID) { + func cancelAftermath(momentID: UUID) { UNUserNotificationCenter.current().removePendingNotificationRequests( - withIdentifiers: [notificationID(for: visitID)] + withIdentifiers: [notificationID(for: momentID)] ) - logger.info("Nachwirkungs-Erinnerung abgebrochen für Visit \(visitID.uuidString).") + logger.info("Nachwirkungs-Erinnerung abgebrochen für Moment \(momentID.uuidString).") } // MARK: - Helpers - private func notificationID(for visitID: UUID) -> String { - "aftermath_\(visitID.uuidString)" + private func notificationID(for momentID: UUID) -> String { + "aftermath_\(momentID.uuidString)" } } diff --git a/nahbar/AftermathRatingFlowView.swift b/nahbar/AftermathRatingFlowView.swift index 4e32dfd..bca52ca 100644 --- a/nahbar/AftermathRatingFlowView.swift +++ b/nahbar/AftermathRatingFlowView.swift @@ -2,22 +2,22 @@ import SwiftUI import SwiftData // MARK: - AftermathRatingFlowView -// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (3 Fragen). -// Wird aus einer Push-Notification heraus oder aus VisitHistorySection geöffnet. +// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen). +// Wird aus einer Push-Notification heraus oder aus der Momente-Liste geöffnet. struct AftermathRatingFlowView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss - let visit: Visit + let moment: Moment - private let questions = RatingQuestion.aftermath // 3 Fragen + private let questions = RatingQuestion.aftermath // 4 Fragen @State private var currentIndex: Int = 0 @State private var values: [Int?] @State private var showSummary: Bool = false - init(visit: Visit) { - self.visit = visit + init(moment: Moment) { + self.moment = moment _values = State(initialValue: Array(repeating: nil, count: RatingQuestion.aftermath.count)) } @@ -25,7 +25,7 @@ struct AftermathRatingFlowView: View { NavigationStack { Group { if showSummary { - VisitSummaryView(visit: visit, onDismiss: { dismiss() }) + MeetingSummaryView(moment: moment, onDismiss: { dismiss() }) } else { questionStep } @@ -83,27 +83,27 @@ struct AftermathRatingFlowView: View { questionIndex: i, value: values[i], isAftermath: true, - visit: visit + moment: moment ) modelContext.insert(rating) } - visit.status = .completed - visit.aftermathCompletedAt = Date() + moment.meetingStatus = .completed + moment.aftermathCompletedAt = Date() // Evtl. geplante Notification abbrechen (falls Nutzer selbst geöffnet hat) - AftermathNotificationManager.shared.cancelAftermath(visitID: visit.id) + AftermathNotificationManager.shared.cancelAftermath(momentID: moment.id) do { try modelContext.save() AppEventLog.shared.record( - "Nachwirkung abgeschlossen für Visit \(visit.id.uuidString)", - level: .success, category: "Visit" + "Nachwirkung abgeschlossen für Moment \(moment.id.uuidString)", + level: .success, category: "Meeting" ) } catch { AppEventLog.shared.record( "Fehler beim Speichern der Nachwirkung: \(error.localizedDescription)", - level: .error, category: "Visit" + level: .error, category: "Meeting" ) } diff --git a/nahbar/VisitEditFlowView.swift b/nahbar/VisitEditFlowView.swift index 52cda8e..421c349 100644 --- a/nahbar/VisitEditFlowView.swift +++ b/nahbar/VisitEditFlowView.swift @@ -1,27 +1,27 @@ import SwiftUI import SwiftData -// MARK: - VisitEditFlowView +// MARK: - MeetingEditFlowView // Erlaubt das nachträgliche Bearbeiten einer bereits abgegebenen Sofort-Bewertung. // Lädt die gespeicherten Rating-Werte vor und überschreibt sie beim Speichern. -struct VisitEditFlowView: View { +struct MeetingEditFlowView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss - let visit: Visit + let moment: Moment private let questions = RatingQuestion.immediate @State private var currentIndex: Int = 0 @State private var values: [Int?] - @State private var note: String @State private var showNoteStep: Bool = false + @State private var note: String - init(visit: Visit) { - self.visit = visit - // Vorbefüllen mit gespeicherten Werten + init(moment: Moment) { + self.moment = moment + // Vorbefüllen mit gespeicherten Sofort-Rating-Werten var prefilled: [Int?] = Array(repeating: nil, count: RatingQuestion.immediate.count) - if let ratings = visit.ratings { + if let ratings = moment.ratings { for r in ratings where !r.isAftermath { if r.questionIndex < prefilled.count { prefilled[r.questionIndex] = r.value @@ -29,7 +29,7 @@ struct VisitEditFlowView: View { } } _values = State(initialValue: prefilled) - _note = State(initialValue: visit.note ?? "") + _note = State(initialValue: moment.text) } var body: some View { @@ -77,7 +77,7 @@ struct VisitEditFlowView: View { private var noteStep: some View { VStack(alignment: .leading, spacing: 20) { - Text("Möchtest du die Notiz anpassen?") + Text("Notiz anpassen") .font(.title3.weight(.semibold)) .padding(.horizontal, 24) .padding(.top, 24) @@ -124,25 +124,29 @@ struct VisitEditFlowView: View { private func saveEdits() { // Bestehende Sofort-Ratings in-place aktualisieren - if let ratings = visit.ratings { + if let ratings = moment.ratings { for rating in ratings where !rating.isAftermath { if rating.questionIndex < values.count { rating.value = values[rating.questionIndex] } } } - visit.note = note.isEmpty ? nil : note.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + moment.text = trimmed + } + moment.updatedAt = Date() do { try modelContext.save() AppEventLog.shared.record( - "Besuchsbewertung bearbeitet: Visit \(visit.id.uuidString)", - level: .info, category: "Visit" + "Treffen-Bewertung bearbeitet: Moment \(moment.id.uuidString)", + level: .info, category: "Meeting" ) } catch { AppEventLog.shared.record( "Fehler beim Aktualisieren der Bewertung: \(error.localizedDescription)", - level: .error, category: "Visit" + level: .error, category: "Meeting" ) } dismiss() diff --git a/nahbar/VisitHistorySection.swift b/nahbar/VisitHistorySection.swift deleted file mode 100644 index f56081c..0000000 --- a/nahbar/VisitHistorySection.swift +++ /dev/null @@ -1,171 +0,0 @@ -import SwiftUI - -// MARK: - VisitHistorySection -// Wiederverwendbare Section für PersonDetailView. -// Zeigt die letzten Besuche einer Person mit Score-Badge und Status. - -struct VisitHistorySection: View { - let person: Person - @Binding var showingVisitRating: Bool - @Binding var showingAftermathRating: Bool - @Binding var selectedVisitForAftermath: Visit? - @Binding var selectedVisitForEdit: Visit? - @Binding var selectedVisitForSummary: Visit? - - private var recentVisits: [Visit] { - person.sortedVisits.prefix(5).map { $0 } - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header - HStack { - SectionHeader(title: "Treffen", icon: "star.fill") - Spacer() - Button { - showingVisitRating = true - } label: { - Image(systemName: "plus") - .font(.body.bold()) - .foregroundStyle(Color.accentColor) - } - } - - if recentVisits.isEmpty { - // Empty State - HStack(spacing: 12) { - Image(systemName: "star") - .font(.title3) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text("Noch keine Treffen bewertet") - .font(.subheadline) - .foregroundStyle(.secondary) - Text("Tippe auf + um loszulegen") - .font(.caption) - .foregroundStyle(.tertiary) - } - Spacer() - } - .padding(14) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) - } else { - VStack(spacing: 0) { - ForEach(recentVisits.indices, id: \.self) { i in - let v = recentVisits[i] - VisitRowView( - visit: v, - onTap: { selectedVisitForSummary = v }, - onAftermathTap: { - selectedVisitForAftermath = v - showingAftermathRating = true - }, - onEditTap: { - selectedVisitForEdit = v - } - ) - if i < recentVisits.count - 1 { - Divider().padding(.leading, 16) - } - } - } - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) - } - } - } -} - -// MARK: - VisitRowView - -private struct VisitRowView: View { - let visit: Visit - let onTap: () -> Void - let onAftermathTap: () -> Void - let onEditTap: () -> Void - - var body: some View { - HStack(spacing: 12) { - // Tappbarer Hauptbereich (Score + Datum/Status) - Button(action: onTap) { - HStack(spacing: 12) { - // Score-Kreis - ZStack { - Circle() - .fill(scoreColor.opacity(0.15)) - .frame(width: 40, height: 40) - if let avg = visit.immediateAverage { - Text(String(format: "%.1f", avg)) - .font(.caption.bold()) - .foregroundStyle(scoreColor) - } else { - Image(systemName: "minus") - .font(.caption.bold()) - .foregroundStyle(.secondary) - } - } - - VStack(alignment: .leading, spacing: 2) { - Text(visit.visitDate.formatted(date: .abbreviated, time: .omitted)) - .font(.subheadline.weight(.medium)) - statusLabel - } - } - } - .buttonStyle(.plain) - - Spacer() - - // Nachwirkungs-Badge falls ausstehend - if visit.status == .awaitingAftermath { - Button(action: onAftermathTap) { - Text("Nachwirkung") - .font(.caption.bold()) - .foregroundStyle(.orange) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(.orange.opacity(0.12), in: Capsule()) - } - .buttonStyle(.plain) - } - - // Bearbeiten-Button - Button(action: onEditTap) { - Image(systemName: "pencil") - .font(.caption.bold()) - .foregroundStyle(.secondary) - .padding(8) - .background(Color(.tertiarySystemBackground), in: Circle()) - } - .buttonStyle(.plain) - } - .padding(14) - } - - private var statusLabel: some View { - Group { - switch visit.status { - case .immediateCompleted: - Text("Bewertet") - .font(.caption) - .foregroundStyle(.secondary) - case .awaitingAftermath: - Text("Nachwirkung ausstehend") - .font(.caption) - .foregroundStyle(.orange) - case .completed: - Text("Abgeschlossen") - .font(.caption) - .foregroundStyle(.green) - } - } - } - - private var scoreColor: Color { - guard let avg = visit.immediateAverage else { return .gray } - switch avg { - case ..<(-0.5): return .red - case (-0.5)..<(0.5): return Color(.systemGray3) - default: return .green - } - } -} diff --git a/nahbar/VisitRatingFlowView.swift b/nahbar/VisitRatingFlowView.swift index b6a21aa..3142446 100644 --- a/nahbar/VisitRatingFlowView.swift +++ b/nahbar/VisitRatingFlowView.swift @@ -1,32 +1,29 @@ import SwiftUI import SwiftData -// MARK: - VisitRatingFlowView -// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung (9 Fragen). -// Erstellt beim Abschluss ein Visit-Objekt mit allen Ratings und plant -// die Nachwirkungs-Notification. +// MARK: - MeetingRatingFlowView +// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung eines Treffen-Moments. +// Erwartet einen bereits gespeicherten Moment vom Typ .meeting und ergänzt ihn +// um Ratings sowie den Nachwirkungs-Status. -struct VisitRatingFlowView: View { +struct MeetingRatingFlowView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss - let person: Person + let moment: Moment // Nachwirkungs-Verzögerung (aus App-Einstellungen übergeben) var aftermathDelay: TimeInterval = 36 * 3600 // MARK: State - private let questions = RatingQuestion.immediate // 9 Fragen + private let questions = RatingQuestion.immediate // 5 Fragen @State private var currentIndex: Int = 0 - @State private var values: [Int?] // [nil] × 9 - @State private var note: String = "" - @State private var showNoteStep: Bool = false + @State private var values: [Int?] // [nil] × 5 @State private var showSummary: Bool = false - @State private var createdVisit: Visit? = nil - init(person: Person, aftermathDelay: TimeInterval = 36 * 3600) { - self.person = person + init(moment: Moment, aftermathDelay: TimeInterval = 36 * 3600) { + self.moment = moment self.aftermathDelay = aftermathDelay _values = State(initialValue: Array(repeating: nil, count: RatingQuestion.immediate.count)) } @@ -34,15 +31,13 @@ struct VisitRatingFlowView: View { var body: some View { NavigationStack { Group { - if showSummary, let visit = createdVisit { - VisitSummaryView(visit: visit, onDismiss: { dismiss() }) - } else if showNoteStep { - noteStep + if showSummary { + MeetingSummaryView(moment: moment, onDismiss: { dismiss() }) } else { questionStep } } - .navigationTitle(showNoteStep ? "Notiz" : "Besuch bewerten") + .navigationTitle("Treffen bewerten") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -50,7 +45,7 @@ struct VisitRatingFlowView: View { } if !showSummary { ToolbarItem(placement: .confirmationAction) { - Button(nextButtonLabel) { + Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") { advance() } } @@ -78,95 +73,53 @@ struct VisitRatingFlowView: View { .clipped() } - // MARK: - Notiz-Screen - - private var noteStep: some View { - VStack(alignment: .leading, spacing: 20) { - Text("Möchtest du noch etwas festhalten?") - .font(.title3.weight(.semibold)) - .padding(.horizontal, 24) - .padding(.top, 24) - - ZStack(alignment: .topLeading) { - RoundedRectangle(cornerRadius: 12) - .fill(Color(.secondarySystemBackground)) - if note.isEmpty { - Text("Optional – z. B. was besonders war…") - .foregroundStyle(.tertiary) - .padding(16) - } - TextEditor(text: $note) - .padding(12) - .scrollContentBackground(.hidden) - } - .frame(minHeight: 120) - .padding(.horizontal, 24) - - Spacer() - } - } - // MARK: - Navigation - private var nextButtonLabel: LocalizedStringKey { - if showNoteStep { return "Fertig" } - if currentIndex == questions.count - 1 { return "Weiter" } - return "Weiter" - } - private func advance() { - if showNoteStep { - saveVisit() - return - } if currentIndex < questions.count - 1 { withAnimation { currentIndex += 1 } } else { - withAnimation { showNoteStep = true } + saveRatings() } } // MARK: - Speichern - private func saveVisit() { - let visit = Visit(visitDate: Date(), person: person) - visit.note = note.isEmpty ? nil : note.trimmingCharacters(in: .whitespacesAndNewlines) - visit.status = .awaitingAftermath - modelContext.insert(visit) - + private func saveRatings() { for (i, q) in questions.enumerated() { let rating = Rating( category: q.category, questionIndex: i, value: values[i], isAftermath: false, - visit: visit + moment: moment ) modelContext.insert(rating) } + moment.meetingStatus = .awaitingAftermath + do { try modelContext.save() AppEventLog.shared.record( - "Besuch bewertet: \(person.firstName) (\(questions.count) Fragen)", - level: .info, category: "Visit" + "Treffen bewertet: \(moment.person?.firstName ?? "?") (\(questions.count) Fragen)", + level: .info, category: "Meeting" ) } catch { AppEventLog.shared.record( - "Fehler beim Speichern des Besuchs: \(error.localizedDescription)", - level: .error, category: "Visit" + "Fehler beim Speichern der Treffen-Bewertung: \(error.localizedDescription)", + level: .error, category: "Meeting" ) } // Nachwirkungs-Notification planen AftermathNotificationManager.shared.scheduleAftermath( - visitID: visit.id, - personName: person.firstName, + momentID: moment.id, + personName: moment.person?.firstName ?? "", delay: aftermathDelay ) - visit.aftermathNotificationScheduled = true + moment.aftermathNotificationScheduled = true - createdVisit = visit withAnimation { showSummary = true } } } diff --git a/nahbar/VisitSummaryView.swift b/nahbar/VisitSummaryView.swift index 55a436f..0cf2799 100644 --- a/nahbar/VisitSummaryView.swift +++ b/nahbar/VisitSummaryView.swift @@ -1,11 +1,11 @@ import SwiftUI -// MARK: - VisitSummaryView -// Zeigt die Zusammenfassung eines Besuchs nach Abschluss des Rating-Flows. +// MARK: - MeetingSummaryView +// Zeigt die Zusammenfassung eines bewerteten Treffen-Moments. // Wird sowohl nach der Sofort-Bewertung als auch nach der Nachwirkung gezeigt. -struct VisitSummaryView: View { - let visit: Visit +struct MeetingSummaryView: View { + let moment: Moment let onDismiss: () -> Void private var immediateCategories: [RatingCategory] { @@ -17,14 +17,14 @@ struct VisitSummaryView: View { VStack(spacing: 28) { // Header VStack(spacing: 8) { - Image(systemName: visit.status == .completed ? "checkmark.circle.fill" : "clock.fill") + Image(systemName: moment.isMeetingComplete ? "checkmark.circle.fill" : "clock.fill") .font(.system(size: 48)) - .foregroundStyle(visit.status == .completed ? .green : .orange) + .foregroundStyle(moment.isMeetingComplete ? .green : .orange) - Text(visit.status == .completed ? "Alles festgehalten" : "Gut gemacht!") + Text(moment.isMeetingComplete ? "Alles festgehalten" : "Gut gemacht!") .font(.title2.bold()) - Text(visit.status == .awaitingAftermath + Text(moment.meetingStatus == .awaitingAftermath ? "Wir erinnern dich an die Nachwirkung." : "Bewertung abgeschlossen.") .font(.subheadline) @@ -34,22 +34,24 @@ struct VisitSummaryView: View { .padding(.top, 32) // Sofort-Werte - if let immediateAvg = visit.immediateAverage { - summaryCard(title: "Sofort-Eindruck", average: immediateAvg, categories: immediateCategories, isAftermath: false) + if let immediateAvg = moment.immediateAverage { + summaryCard(title: "Sofort-Eindruck", average: immediateAvg, + categories: immediateCategories, isAftermath: false) } // Nachwirkungs-Wert (falls vorhanden) - if let aftermathAvg = visit.aftermathAverage { - summaryCard(title: "Nachwirkung", average: aftermathAvg, categories: [.nachwirkung], isAftermath: true) + if let aftermathAvg = moment.aftermathAverage { + summaryCard(title: "Nachwirkung", average: aftermathAvg, + categories: [.nachwirkung], isAftermath: true) } - // Notiz - if let note = visit.note, !note.isEmpty { + // Momenttext / Notiz + if !moment.text.isEmpty { VStack(alignment: .leading, spacing: 8) { Label("Notiz", systemImage: "note.text") .font(.subheadline.bold()) .foregroundStyle(.secondary) - Text(note) + Text(moment.text) .font(.body) } .frame(maxWidth: .infinity, alignment: .leading) @@ -87,7 +89,7 @@ struct VisitSummaryView: View { } ForEach(categories, id: \.self) { category in - if let avg = visit.averageForCategory(category, aftermath: isAftermath) { + if let avg = moment.averageForCategory(category, aftermath: isAftermath) { categoryRow(category: category, average: avg) } } diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift index 0037f24..41e0d7c 100644 --- a/nahbar/nahbar/AddMomentView.swift +++ b/nahbar/nahbar/AddMomentView.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData import EventKit +import UserNotifications struct AddMomentView: View { @Environment(\.nahbarTheme) var theme @@ -9,27 +10,48 @@ struct AddMomentView: View { let person: Person + /// Wird nach dem Speichern eines Treffen-Moments aufgerufen — + /// die aufrufende Ansicht kann dann den Rating-Flow öffnen. + var onMeetingCreated: ((Moment) -> Void)? = nil + @State private var text = "" @State private var selectedType: MomentType = .conversation @FocusState private var isFocused: Bool - // Calendar + // Treffen: Kalender-Integration @State private var addToCalendar = false @State private var eventDate: Date = { let cal = Calendar.current let hour = cal.component(.hour, from: Date()) return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date() }() - @State private var eventDuration: Double = 3600 // seconds; -1 = all-day + @State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag + + // Vorhaben: Erinnerung + @State private var addReminder = false + @State private var reminderDate: Date = { + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() + return Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow + }() private var isValid: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty } - private var showsCalendarSection: Bool { selectedType == .meeting || selectedType == .intention } + private var showsCalendarSection: Bool { selectedType == .meeting } + private var showsReminderSection: Bool { selectedType == .intention } + + private var placeholder: String { + switch selectedType { + case .meeting: return "Wo habt ihr euch getroffen?\nWas habt ihr unternommen?" + case .intention: return "Was möchtest du mit dieser Person machen?" + case .thought: return "Welcher Gedanke kam dir in den Sinn?" + case .conversation: return "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?" + } + } var body: some View { NavigationStack { VStack(alignment: .leading, spacing: 20) { - // Person context chip + // Person-Kontext-Chip HStack(spacing: 10) { PersonAvatar(person: person, size: 36) Text(person.name) @@ -43,7 +65,7 @@ struct AddMomentView: View { .padding(.horizontal, 20) .padding(.top, 8) - // Type selector + // Typ-Selektor ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(MomentType.allCases, id: \.self) { type in @@ -67,10 +89,10 @@ struct AddMomentView: View { .padding(.horizontal, 20) } - // Text input + // Texteingabe ZStack(alignment: .topLeading) { if text.isEmpty { - Text("Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?") + Text(placeholder) .font(.system(size: 16)) .foregroundStyle(theme.contentTertiary) .padding(.horizontal, 16) @@ -91,16 +113,24 @@ struct AddMomentView: View { .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .padding(.horizontal, 20) - // Calendar section — shown for Treffen and Vorhaben + // Treffen: Kalendertermin if showsCalendarSection { calendarSection .transition(.opacity.combined(with: .move(edge: .top))) } + // Vorhaben: Erinnerung + if showsReminderSection { + reminderSection + .transition(.opacity.combined(with: .move(edge: .top))) + } + Spacer() } .animation(.easeInOut(duration: 0.2), value: showsCalendarSection) + .animation(.easeInOut(duration: 0.2), value: showsReminderSection) .animation(.easeInOut(duration: 0.2), value: addToCalendar) + .animation(.easeInOut(duration: 0.2), value: addReminder) .background(theme.backgroundPrimary.ignoresSafeArea()) .navigationTitle("Moment festhalten") .navigationBarTitleDisplayMode(.inline) @@ -121,7 +151,7 @@ struct AddMomentView: View { .onAppear { isFocused = true } } - // MARK: - Calendar Section + // MARK: - Kalender-Sektion (Treffen) private var calendarSection: some View { VStack(spacing: 0) { @@ -151,7 +181,7 @@ struct AddMomentView: View { .font(.system(size: 15)) .foregroundStyle(theme.contentPrimary) .tint(theme.accent) - .environment(\.locale, Locale(identifier: "de_DE")) + .environment(\.locale, Locale.current) .padding(.horizontal, 16) .padding(.vertical, 10) @@ -180,7 +210,47 @@ struct AddMomentView: View { .padding(.horizontal, 20) } - // MARK: - Save + // MARK: - Erinnerungs-Sektion (Vorhaben) + + private var reminderSection: some View { + VStack(spacing: 0) { + HStack { + Image(systemName: "bell") + .font(.system(size: 14)) + .foregroundStyle(addReminder ? theme.accent : theme.contentTertiary) + Text("Erinnerung setzen") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Toggle("", isOn: $addReminder) + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + if addReminder { + RowDivider() + + DatePicker( + "Wann?", + selection: $reminderDate, + in: Date()..., + 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) + } + + // MARK: - Speichern private func save() { let trimmed = text.trimmingCharacters(in: .whitespaces) @@ -189,6 +259,42 @@ struct AddMomentView: View { let moment = Moment(text: trimmed, type: selectedType, person: person) modelContext.insert(moment) person.moments?.append(moment) + person.touch() + + // Vorhaben: Erinnerung speichern + Notification planen + if selectedType == .intention && addReminder { + moment.reminderDate = reminderDate + scheduleIntentionReminder(for: moment) + } + + do { + try modelContext.save() + } catch { + AppEventLog.shared.record( + "Fehler beim Speichern des Moments: \(error.localizedDescription)", + level: .error, category: "Moment" + ) + } + + // Treffen: Callback für Rating-Flow + evtl. Kalendertermin + if selectedType == .meeting { + if addToCalendar { + 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) { + // Callback nach Dismiss + } + } + dismiss() + onMeetingCreated?(moment) + return + } guard addToCalendar else { dismiss() @@ -204,16 +310,40 @@ struct AddMomentView: View { modelContext.insert(calEntry) person.logEntries?.append(calEntry) - // Kein async/await — Callback-API vermeidet "unsafeForcedSync" - createCalendarEvent(notes: trimmed) + createCalendarEvent(notes: trimmed) {} + } + + // MARK: - Vorhaben-Erinnerung + + private func scheduleIntentionReminder(for moment: Moment) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + guard granted else { return } + + let content = UNMutableNotificationContent() + content.title = person.firstName + content.body = moment.text + content.sound = .default + + let components = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute], from: reminderDate + ) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let request = UNNotificationRequest( + identifier: "intention-\(moment.id)", + content: content, + trigger: trigger + ) + center.add(request) + } } // MARK: - EventKit (callback-basiert, kein Swift Concurrency) - private func createCalendarEvent(notes: String) { + private func createCalendarEvent(notes: String, completion: @escaping () -> Void) { let store = EKEventStore() - let completion: (Bool, Error?) -> Void = { [store] granted, _ in + let handler: (Bool, Error?) -> Void = { [store] granted, _ in guard granted, let calendar = store.defaultCalendarForNewEvents else { DispatchQueue.main.async { self.dismiss() } return @@ -239,9 +369,9 @@ struct AddMomentView: View { } if #available(iOS 17.0, *) { - store.requestWriteOnlyAccessToEvents(completion: completion) + store.requestWriteOnlyAccessToEvents(completion: handler) } else { - store.requestAccess(to: .event, completion: completion) + store.requestAccess(to: .event, completion: handler) } } } diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index e88166d..1429d47 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -11,18 +11,12 @@ struct PersonDetailView: View { @State private var showingAddMoment = false @State private var showingEditPerson = false - @State private var showingVisitRating = false - @State private var showingAftermathRating = false - @State private var selectedVisitForAftermath: Visit? = nil - @State private var selectedVisitForEdit: Visit? = nil - @State private var selectedVisitForSummary: Visit? = nil - @State private var nextStepText = "" - @State private var isEditingNextStep = false - @State private var showingReminderSheet = false - @State private var reminderDate: Date = { - let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() - return Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow - }() + + // Meeting-Rating-Flow (V5) + @State private var momentForRating: Moment? = nil + @State private var momentForAftermath: Moment? = nil + @State private var momentForEdit: Moment? = nil + @State private var momentForSummary: Moment? = nil @StateObject private var personalityStore = PersonalityStore.shared @@ -30,8 +24,6 @@ struct PersonDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 28) { personHeader - nextStepSection - visitsSection momentsSection if hasInfoContent { infoSection } } @@ -50,39 +42,40 @@ struct PersonDetailView: View { } } .sheet(isPresented: $showingAddMoment) { - AddMomentView(person: person) + AddMomentView(person: person) { meetingMoment in + // Nach Sheet-Dismiss kurz warten, dann Rating-Flow öffnen + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + momentForRating = meetingMoment + } + } } .sheet(isPresented: $showingEditPerson) { AddPersonView(existingPerson: person) } - .sheet(isPresented: $showingReminderSheet) { - NextStepReminderSheet(person: person, reminderDate: $reminderDate) + .sheet(item: $momentForRating) { moment in + MeetingRatingFlowView( + moment: moment, + aftermathDelay: AftermathDelayOption.loadFromDefaults().seconds + ) } - .sheet(isPresented: $showingVisitRating) { - VisitRatingFlowView(person: person, - aftermathDelay: AftermathDelayOption.loadFromDefaults().seconds) + .sheet(item: $momentForAftermath) { moment in + AftermathRatingFlowView(moment: moment) } - .sheet(item: $selectedVisitForAftermath) { visit in - AftermathRatingFlowView(visit: visit) + .sheet(item: $momentForEdit) { moment in + MeetingEditFlowView(moment: moment) } - .sheet(item: $selectedVisitForEdit) { visit in - VisitEditFlowView(visit: visit) - } - .sheet(item: $selectedVisitForSummary) { visit in + .sheet(item: $momentForSummary) { moment in NavigationStack { - VisitSummaryView(visit: visit, onDismiss: { selectedVisitForSummary = nil }) - .navigationTitle("Besuch") + MeetingSummaryView(moment: moment, onDismiss: { momentForSummary = nil }) + .navigationTitle("Treffen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Schließen") { selectedVisitForSummary = nil } + Button("Schließen") { momentForSummary = nil } } } } } - .onAppear { - nextStepText = person.nextStep ?? "" - } // Schützt vor Crash wenn der ModelContext durch Migration oder // CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden. .onReceive( @@ -118,218 +111,7 @@ struct PersonDetailView: View { } } - // MARK: - Next Step - - private var nextStepSection: some View { - VStack(alignment: .leading, spacing: 10) { - SectionHeader(title: "Nächster Schritt", icon: "arrow.right.circle") - - if isEditingNextStep { - nextStepEditor - } else if let step = person.nextStep, !person.nextStepCompleted { - nextStepDisplay(step: step) - } else { - VStack(alignment: .leading, spacing: 8) { - addNextStepButton - if let profile = personalityStore.profile, profile.isComplete { - nextStepSuggestionsView(profile: profile) - } - } - } - } - } - - private var addNextStepButton: some View { - Button { - nextStepText = "" - person.nextStep = nil - person.nextStepCompleted = false - isEditingNextStep = true - } label: { - HStack(spacing: 8) { - Image(systemName: "plus") - .font(.system(size: 13)) - Text("Schritt definieren") - .font(.system(size: 15)) - } - .foregroundStyle(theme.contentTertiary) - .padding(.horizontal, 14) - .padding(.vertical, 11) - .frame(maxWidth: .infinity, alignment: .leading) - .background(theme.surfaceCard) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) - .overlay( - RoundedRectangle(cornerRadius: theme.radiusCard) - .stroke(theme.borderSubtle, lineWidth: 1) - ) - } - } - - private var nextStepEditor: some View { - HStack(alignment: .top, spacing: 10) { - TextField("Was als Nächstes?", text: $nextStepText, axis: .vertical) - .font(.system(size: 15, design: theme.displayDesign)) - .foregroundStyle(theme.contentPrimary) - .tint(theme.accent) - .lineLimit(1...4) - - Button { - let trimmed = nextStepText.trimmingCharacters(in: .whitespaces) - if !trimmed.isEmpty { - person.nextStep = trimmed - person.nextStepCompleted = false - cancelReminder(for: person) - person.nextStepReminderDate = nil - isEditingNextStep = false - // Erinnerungsdatum auf morgen 9 Uhr zurücksetzen - let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() - reminderDate = Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow - showingReminderSheet = true - } else { - isEditingNextStep = false - } - } label: { - Text("Ok") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(theme.accent) - } - .padding(.top, 2) - } - .padding(14) - .background(theme.surfaceCard) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) - .overlay( - RoundedRectangle(cornerRadius: theme.radiusCard) - .stroke(theme.accent.opacity(0.25), lineWidth: 1) - ) - } - - private func nextStepDisplay(step: String) -> some View { - HStack(alignment: .top, spacing: 12) { - Button { - withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { - if let step = person.nextStep { - let entry = LogEntry(type: .nextStep, title: step, person: person) - modelContext.insert(entry) - person.logEntries?.append(entry) - } - person.nextStepCompleted = true - cancelReminder(for: person) - person.nextStepReminderDate = nil - } - } label: { - Image(systemName: "circle") - .font(.system(size: 22)) - .foregroundStyle(theme.contentTertiary) - } - - VStack(alignment: .leading, spacing: 4) { - Text(step) - .font(.system(size: 15, design: theme.displayDesign)) - .foregroundStyle(theme.contentPrimary) - .fixedSize(horizontal: false, vertical: true) - - if let reminder = person.nextStepReminderDate { - Label(reminder.formatted(.dateTime.day().month().hour().minute().locale(Locale(identifier: "de_DE"))), systemImage: "bell") - .font(.system(size: 12)) - .foregroundStyle(theme.accent.opacity(0.8)) - } - } - - Spacer() - - Button { - nextStepText = step - isEditingNextStep = true - } label: { - Image(systemName: "pencil") - .font(.system(size: 13)) - .foregroundStyle(theme.contentTertiary) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(theme.surfaceCard) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) - } - - private func cancelReminder(for person: Person) { - UNUserNotificationCenter.current() - .removePendingNotificationRequests(withIdentifiers: ["nextstep-\(person.id)"]) - } - - /// Persönlichkeitsbasierter Aktivitätshinweis – ein einziger kombinierter Vorschlag. - /// Zwei passende Aktivitäten werden zu einem lesbaren String verbunden. - private func nextStepSuggestionsView(profile: PersonalityProfile) -> some View { - let preferred = PersonalityEngine.preferredActivityStyle(for: profile) - let highlightNew = PersonalityEngine.highlightNovelty(for: profile) - - // (text, style, isNovel) – kein Icon mehr nötig, da einzelne Zeile - 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) } - - // Top-2 zu einem Satz kombinieren: "Kaffee trinken oder spazieren gehen" - let top = sorted.prefix(2).map { $0.0 } - let hint = top.joined(separator: " oder ") - let topActivity = sorted.first?.0 ?? "" - - return Button { - nextStepText = topActivity - isEditingNextStep = 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(.horizontal, 14) - .padding(.vertical, 7) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private func deleteMoment(_ moment: Moment) { - modelContext.delete(moment) - person.touch() - } - - private func toggleImportant(_ moment: Moment) { - moment.isImportant.toggle() - moment.updatedAt = Date() - } - - // MARK: - Visits - - private var visitsSection: some View { - VisitHistorySection( - person: person, - showingVisitRating: $showingVisitRating, - showingAftermathRating: $showingAftermathRating, - selectedVisitForAftermath: $selectedVisitForAftermath, - selectedVisitForEdit: $selectedVisitForEdit, - selectedVisitForSummary: $selectedVisitForSummary - ) - } - - // MARK: - Moments + // MARK: - Momente private var momentsSection: some View { VStack(alignment: .leading, spacing: 10) { @@ -362,6 +144,12 @@ struct PersonDetailView: View { } } + // Persönlichkeitsbasierte Vorhaben-Vorschläge (ersetzt nextStepSection) + if person.openIntentions.isEmpty, + let profile = personalityStore.profile, profile.isComplete { + intentionSuggestionButton(profile: profile) + } + if person.sortedMoments.isEmpty { Text("Noch nichts festgehalten. Dein nächstes Gespräch kann hier beginnen.") .font(.system(size: 14)) @@ -374,7 +162,12 @@ struct PersonDetailView: View { moment: moment, isLast: index == person.sortedMoments.count - 1, onDelete: { deleteMoment(moment) }, - onToggleImportant: { toggleImportant(moment) } + onToggleImportant: { toggleImportant(moment) }, + onRateMeeting: { momentForRating = moment }, + onAftermathMeeting: { momentForAftermath = moment }, + onViewSummary: { momentForSummary = moment }, + onEditMeeting: { momentForEdit = moment }, + onToggleIntention: { toggleIntention(moment) } ) } } @@ -384,6 +177,53 @@ struct PersonDetailView: View { } } + // MARK: - Vorhaben-Vorschlag (ersetzt nextStepSection/nextStepSuggestionsView) + + private func intentionSuggestionButton(profile: PersonalityProfile) -> some View { + let preferred = PersonalityEngine.preferredActivityStyle(for: profile) + let highlightNew = PersonalityEngine.highlightNovelty(for: profile) + + 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) + } + .padding(.horizontal, 14) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + // MARK: - Info private var hasInfoContent: Bool { @@ -421,107 +261,47 @@ struct PersonDetailView: View { .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) } } -} -// MARK: - Reminder Sheet + // MARK: - Aktionen -struct NextStepReminderSheet: View { - @Environment(\.nahbarTheme) var theme - @Environment(\.dismiss) var dismiss - @Bindable var person: Person - @Binding var reminderDate: Date - - var body: some View { - VStack(spacing: 0) { - // Handle - RoundedRectangle(cornerRadius: 2) - .fill(Color.secondary.opacity(0.3)) - .frame(width: 36, height: 4) - .padding(.top, 12) - - VStack(spacing: 24) { - // Header - VStack(spacing: 6) { - Image(systemName: "bell") - .font(.system(size: 26, weight: .light)) - .foregroundStyle(theme.accent) - - Text("Erinnerung setzen?") - .font(.system(size: 20, weight: .light, design: theme.displayDesign)) - .foregroundStyle(theme.contentPrimary) - - if let step = person.nextStep { - Text(step) - .font(.system(size: 14)) - .foregroundStyle(theme.contentSecondary) - .multilineTextAlignment(.center) - .lineLimit(2) - } - } - - // Date picker - DatePicker("", selection: $reminderDate, in: Date()..., displayedComponents: [.date, .hourAndMinute]) - .datePickerStyle(.compact) - .labelsHidden() - .tint(theme.accent) - .environment(\.locale, Locale(identifier: "de_DE")) - - // Buttons - VStack(spacing: 10) { - Button { - scheduleReminder() - dismiss() - } label: { - Text("Erinnern") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(theme.accent) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusTag)) - } - - Button { - dismiss() - } label: { - Text("Überspringen") - .font(.system(size: 15)) - .foregroundStyle(theme.contentSecondary) - } - } - } - .padding(.horizontal, 24) - .padding(.top, 20) - .padding(.bottom, 32) - } - .background(theme.backgroundPrimary) - .presentationDetents([.height(380)]) - .presentationDragIndicator(.hidden) + private func deleteMoment(_ moment: Moment) { + modelContext.delete(moment) + person.touch() } - private func scheduleReminder() { - let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - guard granted else { return } + private func toggleImportant(_ moment: Moment) { + moment.isImportant.toggle() + moment.updatedAt = Date() + } - let content = UNMutableNotificationContent() - content.title = person.firstName - content.body = person.nextStep ?? "" - content.sound = .default - - let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: reminderDate) - let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) - let request = UNNotificationRequest( - identifier: "nextstep-\(person.id)", - content: content, - trigger: trigger - ) - center.add(request) - - DispatchQueue.main.async { - person.nextStepReminderDate = reminderDate + private func toggleIntention(_ moment: Moment) { + guard moment.isIntention else { return } + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + if !moment.isCompleted { + // Vorhaben abhaken: LogEntry erstellen + moment.isCompleted = true + moment.updatedAt = Date() + let entry = LogEntry(type: .nextStep, title: moment.text, person: person) + modelContext.insert(entry) + person.logEntries?.append(entry) + // Erinnerung abbrechen falls vorhanden + if moment.reminderDate != nil { + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: ["intention-\(moment.id)"]) + } + } else { + moment.isCompleted = false + moment.updatedAt = Date() } } + do { + try modelContext.save() + } catch { + AppEventLog.shared.record( + "Fehler beim Abhaken des Vorhabens: \(error.localizedDescription)", + level: .error, category: "Intention" + ) + } } } @@ -536,6 +316,11 @@ private struct DeletableMomentRow: View { let isLast: Bool let onDelete: () -> Void let onToggleImportant: () -> Void + let onRateMeeting: () -> Void + let onAftermathMeeting: () -> Void + let onViewSummary: () -> Void + let onEditMeeting: () -> Void + let onToggleIntention: () -> Void @State private var offset: CGFloat = 0 private let actionWidth: CGFloat = 76 @@ -585,7 +370,14 @@ private struct DeletableMomentRow: View { // Zeilen-Inhalt schiebt sich über die Buttons VStack(spacing: 0) { - MomentRowView(moment: moment) + MomentRowView( + moment: moment, + onRateMeeting: onRateMeeting, + onAftermathMeeting: onAftermathMeeting, + onViewSummary: onViewSummary, + onEditMeeting: onEditMeeting, + onToggleIntention: onToggleIntention + ) if !isLast { RowDivider() } } .background(theme.surfaceCard) @@ -635,52 +427,139 @@ struct MomentRowView: View { @Environment(\.nahbarTheme) var theme let moment: Moment - var body: some View { - HStack(alignment: .top, spacing: 12) { - // Type icon with optional source badge overlay - ZStack(alignment: .bottomTrailing) { - Image(systemName: moment.type.icon) - .font(.system(size: 13, weight: .light)) - .foregroundStyle(theme.contentTertiary) - .frame(width: 18) - .padding(.top, 2) + // Callbacks für typ-spezifische Aktionen (nur in PersonDetailView relevant) + var onRateMeeting: (() -> Void)? = nil + var onAftermathMeeting: (() -> Void)? = nil + var onViewSummary: (() -> Void)? = nil + var onEditMeeting: (() -> Void)? = nil + var onToggleIntention: (() -> Void)? = nil - if let source = moment.source { - Image(systemName: source.icon) - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(.white) - .padding(2) - .background(sourceColor(source)) - .clipShape(Circle()) - .offset(x: 5, y: 4) + var body: some View { + switch moment.type { + case .meeting: + meetingRow + case .intention: + intentionRow + default: + standardRow + } + } + + // MARK: Standard-Zeile (Gespräch, Gedanke) + + private var standardRow: some View { + HStack(alignment: .top, spacing: 12) { + typeIcon + momentContent + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + // MARK: Treffen-Zeile + + private var meetingRow: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + typeIcon + momentContent + Spacer() + // Score-Badge oder Bearbeiten-Button + if let avg = moment.immediateAverage { + Button { onViewSummary?() } label: { + scoreBadge(avg) + } + .buttonStyle(.plain) } } - .frame(width: 18) - .padding(.top, 2) + + // Aktions-Zeile + HStack(spacing: 8) { + Spacer().frame(width: 30) // Einrückung für Typ-Icon + + if moment.meetingStatus == nil || (moment.ratings ?? []).isEmpty { + // Noch nicht bewertet + Button { onRateMeeting?() } label: { + Label("Bewerten", systemImage: "star") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(theme.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(theme.accent.opacity(0.1)) + .clipShape(Capsule()) + } + } else if moment.meetingStatus == .awaitingAftermath { + // Nachwirkung ausstehend + Button { onAftermathMeeting?() } label: { + Label("Nachwirkung", systemImage: "moon.stars") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.purple) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.purple.opacity(0.1)) + .clipShape(Capsule()) + } + // Bewertung bearbeiten + Button { onEditMeeting?() } label: { + Image(systemName: "pencil") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } + } else if moment.isMeetingComplete { + // Abgeschlossen – nur bearbeiten + Button { onEditMeeting?() } label: { + Label("Bearbeiten", systemImage: "pencil") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(theme.contentTertiary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(theme.contentTertiary.opacity(0.08)) + .clipShape(Capsule()) + } + } + + Spacer() + } + .padding(.bottom, 4) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + } + + // MARK: Vorhaben-Zeile + + private var intentionRow: some View { + HStack(alignment: .top, spacing: 12) { + // Checkbox + Button { onToggleIntention?() } label: { + Image(systemName: moment.isCompleted ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20)) + .foregroundStyle(moment.isCompleted ? Color.green : theme.contentTertiary) + .padding(.top, 1) + } + .buttonStyle(.plain) VStack(alignment: .leading, spacing: 4) { Text(moment.text) .font(.system(size: 15, design: theme.displayDesign)) - .foregroundStyle(theme.contentPrimary) + .foregroundStyle(moment.isCompleted ? theme.contentTertiary : theme.contentPrimary) + .strikethrough(moment.isCompleted, color: theme.contentTertiary) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 6) { - if moment.isImportant { - Image(systemName: "star.fill") - .font(.system(size: 10)) - .foregroundStyle(.orange) - } - Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) + if let reminder = moment.reminderDate, !moment.isCompleted { + Label( + reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale.current)), + systemImage: "bell" + ) .font(.system(size: 12)) - .foregroundStyle(theme.contentTertiary) - - if let source = moment.source { - Text("·") + .foregroundStyle(theme.accent.opacity(0.8)) + } else { + Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) - Text(source.rawValue) - .font(.system(size: 12)) - .foregroundStyle(sourceColor(source).opacity(0.8)) } } } @@ -689,6 +568,80 @@ struct MomentRowView: View { } .padding(.horizontal, 16) .padding(.vertical, 12) + .opacity(moment.isCompleted ? 0.55 : 1.0) + } + + // MARK: - Gemeinsame Elemente + + private var typeIcon: some View { + ZStack(alignment: .bottomTrailing) { + Image(systemName: moment.type.icon) + .font(.system(size: 13, weight: .light)) + .foregroundStyle(theme.contentTertiary) + .frame(width: 18) + .padding(.top, 2) + + if let source = moment.source { + Image(systemName: source.icon) + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.white) + .padding(2) + .background(sourceColor(source)) + .clipShape(Circle()) + .offset(x: 5, y: 4) + } + } + .frame(width: 18) + .padding(.top, 2) + } + + private var momentContent: some View { + VStack(alignment: .leading, spacing: 4) { + Text(moment.text) + .font(.system(size: 15, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 6) { + if moment.isImportant { + Image(systemName: "star.fill") + .font(.system(size: 10)) + .foregroundStyle(.orange) + } + Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + + if let source = moment.source { + Text("·") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + Text(source.rawValue) + .font(.system(size: 12)) + .foregroundStyle(sourceColor(source).opacity(0.8)) + } + } + } + } + + private func scoreBadge(_ value: Double) -> some View { + ZStack { + Circle() + .fill(scoreColor(value).opacity(0.15)) + .frame(width: 34, height: 34) + Text(String(format: "%.1f", value)) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(scoreColor(value)) + } + } + + private func scoreColor(_ value: Double) -> Color { + switch value { + case ..<(-0.5): return .red + case (-0.5)..<(0.5): return Color(.systemGray3) + case (0.5)...: return .green + default: return .gray + } } private func sourceColor(_ source: MomentSource) -> Color {