Komplettumbau auf "Momente"
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user