Files
nahbar/nahbar/nahbar/LogbuchView.swift
T
sven ace6801d01 Refactor: KI-Auswertung aus Logbuch entfernt
Die KI-Karte (aiAnalysisCard) wurde vollständig aus LogbuchView ausgebaut.
KI Insights sind weiterhin über den Sparkles-Button im Kontakt-Header
zugänglich. Sektionsüberschrift in PersonDetailView von
"Verlauf & KI Insights zu [Name]" auf "Verlauf" vereinfacht.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:38:31 +02:00

439 lines
16 KiB
Swift

import SwiftUI
import SwiftData
import CoreData
// MARK: - Timeline Item
private enum LogbuchItem: Identifiable {
case moment(Moment)
case logEntry(LogEntry)
var id: String {
switch self {
case .moment(let m): return "m-\(m.id)"
case .logEntry(let e): return "e-\(e.id)"
}
}
var date: Date {
switch self {
case .moment(let m): return m.createdAt
case .logEntry(let e): return e.loggedAt
}
}
var icon: String {
switch self {
case .moment(let m): return m.type.icon
case .logEntry(let e): return e.type.icon
}
}
var label: String {
switch self {
case .moment(let m): return m.type.displayName
case .logEntry(let e): return e.type.rawValue
}
}
var title: String {
switch self {
case .moment(let m): return m.text
case .logEntry(let e): return e.title
}
}
var isLogEntry: Bool {
if case .logEntry = self { return true }
return false
}
}
// MARK: - Logbuch View
struct LogbuchView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss
let person: Person
// Kalender-Lösch-Bestätigung
@State private var momentPendingDelete: Moment? = nil
@State private var showCalendarDeleteDialog = false
// Moment-Bearbeitung
@State private var momentForTextEdit: Moment? = nil
/// Inkrementiert bei jeder CalendarEventStore-Änderung triggert Re-Render der Rows.
@State private var calendarEventsVersion = 0
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
// Timeline
if mergedItems.isEmpty {
emptyState
} else {
ForEach(groupedItems, id: \.0) { month, items in
monthSection(month: month, items: items)
}
}
}
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 48)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Logbuch")
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.sheet(item: $momentForTextEdit) { moment in
EditMomentView(moment: moment)
}
.onReceive(NotificationCenter.default.publisher(for: CalendarEventStore.didChangeNotification)) { _ in
calendarEventsVersion += 1
}
.confirmationDialog(
"Moment löschen",
isPresented: $showCalendarDeleteDialog,
presenting: momentPendingDelete
) { moment in
Button("Moment + Kalendereintrag löschen", role: .destructive) {
performDelete(moment, deleteCalendarEvent: true)
momentPendingDelete = nil
}
Button("Nur Moment löschen", role: .destructive) {
performDelete(moment, deleteCalendarEvent: false)
momentPendingDelete = nil
}
Button("Abbrechen", role: .cancel) {
momentPendingDelete = nil
}
} message: { _ in
Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?")
}
.onReceive(
NotificationCenter.default.publisher(
for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification")
)
) { notification in
guard notification.userInfo?[NSInvalidatedAllObjectsKey] != nil else { return }
dismiss()
}
}
// MARK: - Month Section
private func monthSection(month: String, items: [LogbuchItem]) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(month.uppercased())
.font(.system(size: 11, weight: .semibold))
.tracking(0.8)
.foregroundStyle(theme.contentTertiary)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
if case .moment(let moment) = item {
DeletableLogbuchRow(
isImportant: moment.isImportant,
isLast: index == items.count - 1,
onEdit: { momentForTextEdit = moment },
onDelete: { deleteMoment(moment) },
onToggleImportant: { toggleImportant(moment) }
) {
logbuchRow(item: item)
}
} else {
logbuchRow(item: item)
if index < items.count - 1 { RowDivider() }
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
private func deleteMoment(_ moment: Moment) {
if CalendarEventStore.identifier(for: moment.id) != nil {
momentPendingDelete = moment
showCalendarDeleteDialog = true
} else {
performDelete(moment, deleteCalendarEvent: false)
}
}
private func performDelete(_ moment: Moment, deleteCalendarEvent: Bool) {
let momentID = moment.id
if deleteCalendarEvent, let eventID = CalendarEventStore.identifier(for: momentID) {
Task {
_ = await CalendarManager.shared.deleteEvent(identifier: eventID)
CalendarEventStore.remove(momentID: momentID)
}
} else {
CalendarEventStore.remove(momentID: momentID)
}
modelContext.delete(moment)
person.touch()
}
private func toggleImportant(_ moment: Moment) {
moment.isImportant.toggle()
moment.updatedAt = Date()
}
// MARK: - Row
private func logbuchRow(item: LogbuchItem) -> some View {
HStack(alignment: .top, spacing: 12) {
// Typ-Icon (bei Vorhaben: Checkbox-Status)
Group {
if case .moment(let m) = item, m.isIntention {
Image(systemName: m.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(m.isCompleted ? Color.green : theme.contentTertiary)
} else {
Image(systemName: item.icon)
.foregroundStyle(item.isLogEntry ? theme.accent : theme.contentTertiary)
}
}
.font(.system(size: 14, weight: .light))
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 3) {
// Titel (Vorhaben: Durchgestrichen wenn erledigt)
if case .moment(let m) = item, m.isIntention, m.isCompleted {
Text(item.title)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentTertiary)
.strikethrough(true, color: theme.contentTertiary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text(item.title)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 6) {
if case .moment(let m) = item, m.isImportant {
Image(systemName: "star.fill")
.font(.system(size: 10))
.foregroundStyle(.orange)
}
if case .moment(let m) = item,
calendarEventsVersion >= 0, // Dependency auf calendarEventsVersion
CalendarEventStore.identifier(for: m.id) != nil {
Image(systemName: "calendar")
.font(.system(size: 10))
.foregroundStyle(theme.contentTertiary)
}
Text(LocalizedStringKey(item.label))
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Text("·")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Text(item.date.formatted(.dateTime.day().month(.abbreviated).year()))
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
Spacer()
// Score-Badge für bewertete Treffen
if case .moment(let m) = item, m.isMeeting, let avg = m.immediateAverage {
ZStack {
Circle()
.fill(scoreColor(avg).opacity(0.15))
.frame(width: 30, height: 30)
Text(String(format: "%.1f", avg))
.font(.system(size: 9, weight: .bold))
.foregroundStyle(scoreColor(avg))
}
.padding(.top, 2)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
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
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "book.closed")
.font(.system(size: 32, weight: .light))
.foregroundStyle(theme.contentTertiary)
Text("Noch keine Einträge")
.font(.system(size: 16, weight: .light))
.foregroundStyle(theme.contentSecondary)
Text("Momente und abgeschlossene Schritte erscheinen hier.")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
}
// MARK: - Data
private var mergedItems: [LogbuchItem] {
let moments = person.sortedMoments.map { LogbuchItem.moment($0) }
let entries = person.sortedLogEntries.map { LogbuchItem.logEntry($0) }
return (moments + entries).sorted { $0.date > $1.date }
}
private var groupedItems: [(String, [LogbuchItem])] {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
formatter.locale = Locale.current
var result: [(String, [LogbuchItem])] = []
var currentKey = ""
var currentGroup: [LogbuchItem] = []
for item in mergedItems {
let key = formatter.string(from: item.date)
if key != currentKey {
if !currentGroup.isEmpty { result.append((currentKey, currentGroup)) }
currentKey = key
currentGroup = [item]
} else {
currentGroup.append(item)
}
}
if !currentGroup.isEmpty { result.append((currentKey, currentGroup)) }
return result
}
}
// MARK: - Deletable Logbuch Row
// Rechts wischen Bearbeiten (accent) + Wichtig (orange), Links wischen Löschen (rot)
private struct DeletableLogbuchRow<Content: View>: View {
@Environment(\.nahbarTheme) var theme
let isImportant: Bool
let isLast: Bool
let onEdit: () -> Void
let onDelete: () -> Void
let onToggleImportant: () -> Void
@ViewBuilder let content: Content
@State private var offset: CGFloat = 0
private let actionWidth: CGFloat = 76
var body: some View {
ZStack {
HStack(spacing: 0) {
// Links: Bearbeiten-Button
Button {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
onEdit()
} label: {
VStack(spacing: 4) {
Image(systemName: "pencil")
.font(.system(size: 15, weight: .medium))
Text("Bearbeiten")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.white)
.frame(width: actionWidth)
.frame(maxHeight: .infinity)
}
.background(theme.accent)
// Links: Wichtig-Button
Button {
onToggleImportant()
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
} label: {
VStack(spacing: 4) {
Image(systemName: isImportant ? "star.slash.fill" : "star.fill")
.font(.system(size: 15, weight: .medium))
Text(isImportant ? "Entfernen" : "Wichtig")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.white)
.frame(width: actionWidth)
.frame(maxHeight: .infinity)
}
.background(Color.orange)
Spacer()
// Rechts: Löschen-Button
Button {
withAnimation(.spring(response: 0.28, dampingFraction: 0.75)) {
offset = -UIScreen.main.bounds.width
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { onDelete() }
} label: {
VStack(spacing: 4) {
Image(systemName: "trash")
.font(.system(size: 15, weight: .medium))
Text("Löschen")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.white)
.frame(width: actionWidth)
.frame(maxHeight: .infinity)
}
.background(Color.red)
}
VStack(spacing: 0) {
content
if !isLast { RowDivider() }
}
.background(theme.surfaceCard)
.offset(x: offset)
.gesture(
DragGesture(minimumDistance: 50, coordinateSpace: .local)
.onChanged { value in
let x = value.translation.width
let y = value.translation.height
guard abs(x) > abs(y) * 2.5 else { return }
if x > 0 {
offset = min(x, actionWidth * 2 + 16)
} else {
offset = max(x, -(actionWidth + 16))
}
}
.onEnded { value in
let x = value.translation.width
let y = value.translation.height
guard abs(x) > abs(y) * 2.5 else {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
return
}
if x > actionWidth * 2 + 20 {
onToggleImportant()
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
} else if x > actionWidth * 1.5 {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
} else if x < -(actionWidth * 1.5) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
} else {
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
}
}
)
}
.clipped()
}
}