ace6801d01
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>
439 lines
16 KiB
Swift
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()
|
|
}
|
|
}
|