Komplettumbau auf "Momente"

This commit is contained in:
2026-04-19 19:54:20 +02:00
parent 67cfc95265
commit bbf347b508
8 changed files with 567 additions and 696 deletions
+15 -15
View File
@@ -14,12 +14,12 @@ final class AftermathNotificationManager {
static let categoryID = "AFTERMATH_RATING"
static let actionID = "RATE_NOW"
static let visitIDKey = "visitID"
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)"
}
}
+14 -14
View File
@@ -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"
)
}
+19 -15
View File
@@ -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()
-171
View File
@@ -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
}
}
}
+26 -73
View File
@@ -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 }
}
}
+18 -16
View File
@@ -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)
}
}
+147 -17
View File
@@ -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)
}
}
}
+296 -343
View File
@@ -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,106 +261,46 @@ struct PersonDetailView: View {
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
// MARK: - Aktionen
private func deleteMoment(_ moment: Moment) {
modelContext.delete(moment)
person.touch()
}
// MARK: - Reminder Sheet
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)
}
private func toggleImportant(_ moment: Moment) {
moment.isImportant.toggle()
moment.updatedAt = Date()
}
// 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))
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)"])
}
Button {
dismiss()
} label: {
Text("Überspringen")
.font(.system(size: 15))
.foregroundStyle(theme.contentSecondary)
} else {
moment.isCompleted = false
moment.updatedAt = Date()
}
}
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 32)
}
.background(theme.backgroundPrimary)
.presentationDetents([.height(380)])
.presentationDragIndicator(.hidden)
}
private func scheduleReminder() {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
guard granted else { return }
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
do {
try modelContext.save()
} catch {
AppEventLog.shared.record(
"Fehler beim Abhaken des Vorhabens: \(error.localizedDescription)",
level: .error, category: "Intention"
)
center.add(request)
DispatchQueue.main.async {
person.nextStepReminderDate = reminderDate
}
}
}
}
@@ -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,9 +427,153 @@ struct MomentRowView: View {
@Environment(\.nahbarTheme) var theme
let moment: Moment
// 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
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) {
// Type icon with optional source badge overlay
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)
}
}
// 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(moment.isCompleted ? theme.contentTertiary : theme.contentPrimary)
.strikethrough(moment.isCompleted, color: theme.contentTertiary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) {
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.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)
}
}
}
Spacer()
}
.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))
@@ -657,7 +593,9 @@ struct MomentRowView: View {
}
.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))
@@ -684,11 +622,26 @@ struct MomentRowView: View {
}
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
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 {