Komplettumbau auf "Momente"
This commit is contained in:
@@ -14,12 +14,12 @@ final class AftermathNotificationManager {
|
|||||||
|
|
||||||
static let categoryID = "AFTERMATH_RATING"
|
static let categoryID = "AFTERMATH_RATING"
|
||||||
static let actionID = "RATE_NOW"
|
static let actionID = "RATE_NOW"
|
||||||
static let visitIDKey = "visitID"
|
static let momentIDKey = "momentID" // V5 – primärer Key
|
||||||
static let personNameKey = "personName"
|
static let personNameKey = "personName"
|
||||||
|
|
||||||
// MARK: - Setup
|
// 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.
|
/// Muss beim App-Start einmalig aufgerufen werden.
|
||||||
func registerCategory() {
|
func registerCategory() {
|
||||||
let rateNow = UNNotificationAction(
|
let rateNow = UNNotificationAction(
|
||||||
@@ -38,12 +38,12 @@ final class AftermathNotificationManager {
|
|||||||
|
|
||||||
// MARK: - Schedule
|
// MARK: - Schedule
|
||||||
|
|
||||||
/// Plant eine Nachwirkungs-Erinnerung für den angegebenen Besuch.
|
/// Plant eine Nachwirkungs-Erinnerung für den angegebenen Treffen-Moment.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - visitID: UUID des Visit-Objekts
|
/// - momentID: UUID des Moment-Objekts
|
||||||
/// - personName: Name der Person (für Notification-Text)
|
/// - personName: Name der Person (für Notification-Text)
|
||||||
/// - delay: Verzögerung in Sekunden (Standard: 36 Stunden)
|
/// - 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
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
guard granted else {
|
guard granted else {
|
||||||
logger.warning("Notification-Berechtigung abgelehnt – keine Nachwirkungs-Erinnerung.")
|
logger.warning("Notification-Berechtigung abgelehnt – keine Nachwirkungs-Erinnerung.")
|
||||||
@@ -52,11 +52,11 @@ final class AftermathNotificationManager {
|
|||||||
if let error {
|
if let error {
|
||||||
logger.error("Berechtigung-Fehler: \(error.localizedDescription)")
|
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()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName)
|
content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName)
|
||||||
// Persönlichkeitsgerechter Body-Text (softer für hohen Neurotizismus)
|
// Persönlichkeitsgerechter Body-Text (softer für hohen Neurotizismus)
|
||||||
@@ -70,13 +70,13 @@ final class AftermathNotificationManager {
|
|||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.categoryIdentifier = Self.categoryID
|
content.categoryIdentifier = Self.categoryID
|
||||||
content.userInfo = [
|
content.userInfo = [
|
||||||
Self.visitIDKey: visitID.uuidString,
|
Self.momentIDKey: momentID.uuidString,
|
||||||
Self.personNameKey: personName
|
Self.personNameKey: personName
|
||||||
]
|
]
|
||||||
|
|
||||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false)
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false)
|
||||||
let request = UNNotificationRequest(
|
let request = UNNotificationRequest(
|
||||||
identifier: notificationID(for: visitID),
|
identifier: notificationID(for: momentID),
|
||||||
content: content,
|
content: content,
|
||||||
trigger: trigger
|
trigger: trigger
|
||||||
)
|
)
|
||||||
@@ -85,7 +85,7 @@ final class AftermathNotificationManager {
|
|||||||
if let error {
|
if let error {
|
||||||
logger.error("Notification konnte nicht geplant werden: \(error.localizedDescription)")
|
logger.error("Notification konnte nicht geplant werden: \(error.localizedDescription)")
|
||||||
} else {
|
} 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
|
// MARK: - Cancel
|
||||||
|
|
||||||
/// Entfernt eine geplante Nachwirkungs-Erinnerung.
|
/// Entfernt eine geplante Nachwirkungs-Erinnerung.
|
||||||
func cancelAftermath(visitID: UUID) {
|
func cancelAftermath(momentID: UUID) {
|
||||||
UNUserNotificationCenter.current().removePendingNotificationRequests(
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func notificationID(for visitID: UUID) -> String {
|
private func notificationID(for momentID: UUID) -> String {
|
||||||
"aftermath_\(visitID.uuidString)"
|
"aftermath_\(momentID.uuidString)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,22 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - AftermathRatingFlowView
|
// MARK: - AftermathRatingFlowView
|
||||||
// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (3 Fragen).
|
// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
|
||||||
// Wird aus einer Push-Notification heraus oder aus VisitHistorySection geöffnet.
|
// Wird aus einer Push-Notification heraus oder aus der Momente-Liste geöffnet.
|
||||||
|
|
||||||
struct AftermathRatingFlowView: View {
|
struct AftermathRatingFlowView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(\.dismiss) private var dismiss
|
@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 currentIndex: Int = 0
|
||||||
@State private var values: [Int?]
|
@State private var values: [Int?]
|
||||||
@State private var showSummary: Bool = false
|
@State private var showSummary: Bool = false
|
||||||
|
|
||||||
init(visit: Visit) {
|
init(moment: Moment) {
|
||||||
self.visit = visit
|
self.moment = moment
|
||||||
_values = State(initialValue: Array(repeating: nil, count: RatingQuestion.aftermath.count))
|
_values = State(initialValue: Array(repeating: nil, count: RatingQuestion.aftermath.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ struct AftermathRatingFlowView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if showSummary {
|
if showSummary {
|
||||||
VisitSummaryView(visit: visit, onDismiss: { dismiss() })
|
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
||||||
} else {
|
} else {
|
||||||
questionStep
|
questionStep
|
||||||
}
|
}
|
||||||
@@ -83,27 +83,27 @@ struct AftermathRatingFlowView: View {
|
|||||||
questionIndex: i,
|
questionIndex: i,
|
||||||
value: values[i],
|
value: values[i],
|
||||||
isAftermath: true,
|
isAftermath: true,
|
||||||
visit: visit
|
moment: moment
|
||||||
)
|
)
|
||||||
modelContext.insert(rating)
|
modelContext.insert(rating)
|
||||||
}
|
}
|
||||||
|
|
||||||
visit.status = .completed
|
moment.meetingStatus = .completed
|
||||||
visit.aftermathCompletedAt = Date()
|
moment.aftermathCompletedAt = Date()
|
||||||
|
|
||||||
// Evtl. geplante Notification abbrechen (falls Nutzer selbst geöffnet hat)
|
// Evtl. geplante Notification abbrechen (falls Nutzer selbst geöffnet hat)
|
||||||
AftermathNotificationManager.shared.cancelAftermath(visitID: visit.id)
|
AftermathNotificationManager.shared.cancelAftermath(momentID: moment.id)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
AppEventLog.shared.record(
|
AppEventLog.shared.record(
|
||||||
"Nachwirkung abgeschlossen für Visit \(visit.id.uuidString)",
|
"Nachwirkung abgeschlossen für Moment \(moment.id.uuidString)",
|
||||||
level: .success, category: "Visit"
|
level: .success, category: "Meeting"
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
AppEventLog.shared.record(
|
AppEventLog.shared.record(
|
||||||
"Fehler beim Speichern der Nachwirkung: \(error.localizedDescription)",
|
"Fehler beim Speichern der Nachwirkung: \(error.localizedDescription)",
|
||||||
level: .error, category: "Visit"
|
level: .error, category: "Meeting"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - VisitEditFlowView
|
// MARK: - MeetingEditFlowView
|
||||||
// Erlaubt das nachträgliche Bearbeiten einer bereits abgegebenen Sofort-Bewertung.
|
// Erlaubt das nachträgliche Bearbeiten einer bereits abgegebenen Sofort-Bewertung.
|
||||||
// Lädt die gespeicherten Rating-Werte vor und überschreibt sie beim Speichern.
|
// Lädt die gespeicherten Rating-Werte vor und überschreibt sie beim Speichern.
|
||||||
|
|
||||||
struct VisitEditFlowView: View {
|
struct MeetingEditFlowView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let visit: Visit
|
let moment: Moment
|
||||||
|
|
||||||
private let questions = RatingQuestion.immediate
|
private let questions = RatingQuestion.immediate
|
||||||
@State private var currentIndex: Int = 0
|
@State private var currentIndex: Int = 0
|
||||||
@State private var values: [Int?]
|
@State private var values: [Int?]
|
||||||
@State private var note: String
|
|
||||||
@State private var showNoteStep: Bool = false
|
@State private var showNoteStep: Bool = false
|
||||||
|
@State private var note: String
|
||||||
|
|
||||||
init(visit: Visit) {
|
init(moment: Moment) {
|
||||||
self.visit = visit
|
self.moment = moment
|
||||||
// Vorbefüllen mit gespeicherten Werten
|
// Vorbefüllen mit gespeicherten Sofort-Rating-Werten
|
||||||
var prefilled: [Int?] = Array(repeating: nil, count: RatingQuestion.immediate.count)
|
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 {
|
for r in ratings where !r.isAftermath {
|
||||||
if r.questionIndex < prefilled.count {
|
if r.questionIndex < prefilled.count {
|
||||||
prefilled[r.questionIndex] = r.value
|
prefilled[r.questionIndex] = r.value
|
||||||
@@ -29,7 +29,7 @@ struct VisitEditFlowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_values = State(initialValue: prefilled)
|
_values = State(initialValue: prefilled)
|
||||||
_note = State(initialValue: visit.note ?? "")
|
_note = State(initialValue: moment.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -77,7 +77,7 @@ struct VisitEditFlowView: View {
|
|||||||
|
|
||||||
private var noteStep: some View {
|
private var noteStep: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
Text("Möchtest du die Notiz anpassen?")
|
Text("Notiz anpassen")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
@@ -124,25 +124,29 @@ struct VisitEditFlowView: View {
|
|||||||
|
|
||||||
private func saveEdits() {
|
private func saveEdits() {
|
||||||
// Bestehende Sofort-Ratings in-place aktualisieren
|
// Bestehende Sofort-Ratings in-place aktualisieren
|
||||||
if let ratings = visit.ratings {
|
if let ratings = moment.ratings {
|
||||||
for rating in ratings where !rating.isAftermath {
|
for rating in ratings where !rating.isAftermath {
|
||||||
if rating.questionIndex < values.count {
|
if rating.questionIndex < values.count {
|
||||||
rating.value = values[rating.questionIndex]
|
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 {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
AppEventLog.shared.record(
|
AppEventLog.shared.record(
|
||||||
"Besuchsbewertung bearbeitet: Visit \(visit.id.uuidString)",
|
"Treffen-Bewertung bearbeitet: Moment \(moment.id.uuidString)",
|
||||||
level: .info, category: "Visit"
|
level: .info, category: "Meeting"
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
AppEventLog.shared.record(
|
AppEventLog.shared.record(
|
||||||
"Fehler beim Aktualisieren der Bewertung: \(error.localizedDescription)",
|
"Fehler beim Aktualisieren der Bewertung: \(error.localizedDescription)",
|
||||||
level: .error, category: "Visit"
|
level: .error, category: "Meeting"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
dismiss()
|
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 SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - VisitRatingFlowView
|
// MARK: - MeetingRatingFlowView
|
||||||
// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung (9 Fragen).
|
// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung eines Treffen-Moments.
|
||||||
// Erstellt beim Abschluss ein Visit-Objekt mit allen Ratings und plant
|
// Erwartet einen bereits gespeicherten Moment vom Typ .meeting und ergänzt ihn
|
||||||
// die Nachwirkungs-Notification.
|
// um Ratings sowie den Nachwirkungs-Status.
|
||||||
|
|
||||||
struct VisitRatingFlowView: View {
|
struct MeetingRatingFlowView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let person: Person
|
let moment: Moment
|
||||||
|
|
||||||
// Nachwirkungs-Verzögerung (aus App-Einstellungen übergeben)
|
// Nachwirkungs-Verzögerung (aus App-Einstellungen übergeben)
|
||||||
var aftermathDelay: TimeInterval = 36 * 3600
|
var aftermathDelay: TimeInterval = 36 * 3600
|
||||||
|
|
||||||
// MARK: State
|
// 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 currentIndex: Int = 0
|
||||||
@State private var values: [Int?] // [nil] × 9
|
@State private var values: [Int?] // [nil] × 5
|
||||||
@State private var note: String = ""
|
|
||||||
@State private var showNoteStep: Bool = false
|
|
||||||
@State private var showSummary: Bool = false
|
@State private var showSummary: Bool = false
|
||||||
@State private var createdVisit: Visit? = nil
|
|
||||||
|
|
||||||
init(person: Person, aftermathDelay: TimeInterval = 36 * 3600) {
|
init(moment: Moment, aftermathDelay: TimeInterval = 36 * 3600) {
|
||||||
self.person = person
|
self.moment = moment
|
||||||
self.aftermathDelay = aftermathDelay
|
self.aftermathDelay = aftermathDelay
|
||||||
_values = State(initialValue: Array(repeating: nil, count: RatingQuestion.immediate.count))
|
_values = State(initialValue: Array(repeating: nil, count: RatingQuestion.immediate.count))
|
||||||
}
|
}
|
||||||
@@ -34,15 +31,13 @@ struct VisitRatingFlowView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if showSummary, let visit = createdVisit {
|
if showSummary {
|
||||||
VisitSummaryView(visit: visit, onDismiss: { dismiss() })
|
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
||||||
} else if showNoteStep {
|
|
||||||
noteStep
|
|
||||||
} else {
|
} else {
|
||||||
questionStep
|
questionStep
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(showNoteStep ? "Notiz" : "Besuch bewerten")
|
.navigationTitle("Treffen bewerten")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
@@ -50,7 +45,7 @@ struct VisitRatingFlowView: View {
|
|||||||
}
|
}
|
||||||
if !showSummary {
|
if !showSummary {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(nextButtonLabel) {
|
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
||||||
advance()
|
advance()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,95 +73,53 @@ struct VisitRatingFlowView: View {
|
|||||||
.clipped()
|
.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
|
// MARK: - Navigation
|
||||||
|
|
||||||
private var nextButtonLabel: LocalizedStringKey {
|
|
||||||
if showNoteStep { return "Fertig" }
|
|
||||||
if currentIndex == questions.count - 1 { return "Weiter" }
|
|
||||||
return "Weiter"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func advance() {
|
private func advance() {
|
||||||
if showNoteStep {
|
|
||||||
saveVisit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if currentIndex < questions.count - 1 {
|
if currentIndex < questions.count - 1 {
|
||||||
withAnimation { currentIndex += 1 }
|
withAnimation { currentIndex += 1 }
|
||||||
} else {
|
} else {
|
||||||
withAnimation { showNoteStep = true }
|
saveRatings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Speichern
|
// MARK: - Speichern
|
||||||
|
|
||||||
private func saveVisit() {
|
private func saveRatings() {
|
||||||
let visit = Visit(visitDate: Date(), person: person)
|
|
||||||
visit.note = note.isEmpty ? nil : note.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
visit.status = .awaitingAftermath
|
|
||||||
modelContext.insert(visit)
|
|
||||||
|
|
||||||
for (i, q) in questions.enumerated() {
|
for (i, q) in questions.enumerated() {
|
||||||
let rating = Rating(
|
let rating = Rating(
|
||||||
category: q.category,
|
category: q.category,
|
||||||
questionIndex: i,
|
questionIndex: i,
|
||||||
value: values[i],
|
value: values[i],
|
||||||
isAftermath: false,
|
isAftermath: false,
|
||||||
visit: visit
|
moment: moment
|
||||||
)
|
)
|
||||||
modelContext.insert(rating)
|
modelContext.insert(rating)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moment.meetingStatus = .awaitingAftermath
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
AppEventLog.shared.record(
|
AppEventLog.shared.record(
|
||||||
"Besuch bewertet: \(person.firstName) (\(questions.count) Fragen)",
|
"Treffen bewertet: \(moment.person?.firstName ?? "?") (\(questions.count) Fragen)",
|
||||||
level: .info, category: "Visit"
|
level: .info, category: "Meeting"
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
AppEventLog.shared.record(
|
AppEventLog.shared.record(
|
||||||
"Fehler beim Speichern des Besuchs: \(error.localizedDescription)",
|
"Fehler beim Speichern der Treffen-Bewertung: \(error.localizedDescription)",
|
||||||
level: .error, category: "Visit"
|
level: .error, category: "Meeting"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nachwirkungs-Notification planen
|
// Nachwirkungs-Notification planen
|
||||||
AftermathNotificationManager.shared.scheduleAftermath(
|
AftermathNotificationManager.shared.scheduleAftermath(
|
||||||
visitID: visit.id,
|
momentID: moment.id,
|
||||||
personName: person.firstName,
|
personName: moment.person?.firstName ?? "",
|
||||||
delay: aftermathDelay
|
delay: aftermathDelay
|
||||||
)
|
)
|
||||||
visit.aftermathNotificationScheduled = true
|
moment.aftermathNotificationScheduled = true
|
||||||
|
|
||||||
createdVisit = visit
|
|
||||||
withAnimation { showSummary = true }
|
withAnimation { showSummary = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - VisitSummaryView
|
// MARK: - MeetingSummaryView
|
||||||
// Zeigt die Zusammenfassung eines Besuchs nach Abschluss des Rating-Flows.
|
// Zeigt die Zusammenfassung eines bewerteten Treffen-Moments.
|
||||||
// Wird sowohl nach der Sofort-Bewertung als auch nach der Nachwirkung gezeigt.
|
// Wird sowohl nach der Sofort-Bewertung als auch nach der Nachwirkung gezeigt.
|
||||||
|
|
||||||
struct VisitSummaryView: View {
|
struct MeetingSummaryView: View {
|
||||||
let visit: Visit
|
let moment: Moment
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
private var immediateCategories: [RatingCategory] {
|
private var immediateCategories: [RatingCategory] {
|
||||||
@@ -17,14 +17,14 @@ struct VisitSummaryView: View {
|
|||||||
VStack(spacing: 28) {
|
VStack(spacing: 28) {
|
||||||
// Header
|
// Header
|
||||||
VStack(spacing: 8) {
|
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))
|
.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())
|
.font(.title2.bold())
|
||||||
|
|
||||||
Text(visit.status == .awaitingAftermath
|
Text(moment.meetingStatus == .awaitingAftermath
|
||||||
? "Wir erinnern dich an die Nachwirkung."
|
? "Wir erinnern dich an die Nachwirkung."
|
||||||
: "Bewertung abgeschlossen.")
|
: "Bewertung abgeschlossen.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -34,22 +34,24 @@ struct VisitSummaryView: View {
|
|||||||
.padding(.top, 32)
|
.padding(.top, 32)
|
||||||
|
|
||||||
// Sofort-Werte
|
// Sofort-Werte
|
||||||
if let immediateAvg = visit.immediateAverage {
|
if let immediateAvg = moment.immediateAverage {
|
||||||
summaryCard(title: "Sofort-Eindruck", average: immediateAvg, categories: immediateCategories, isAftermath: false)
|
summaryCard(title: "Sofort-Eindruck", average: immediateAvg,
|
||||||
|
categories: immediateCategories, isAftermath: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nachwirkungs-Wert (falls vorhanden)
|
// Nachwirkungs-Wert (falls vorhanden)
|
||||||
if let aftermathAvg = visit.aftermathAverage {
|
if let aftermathAvg = moment.aftermathAverage {
|
||||||
summaryCard(title: "Nachwirkung", average: aftermathAvg, categories: [.nachwirkung], isAftermath: true)
|
summaryCard(title: "Nachwirkung", average: aftermathAvg,
|
||||||
|
categories: [.nachwirkung], isAftermath: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notiz
|
// Momenttext / Notiz
|
||||||
if let note = visit.note, !note.isEmpty {
|
if !moment.text.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Notiz", systemImage: "note.text")
|
Label("Notiz", systemImage: "note.text")
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.bold())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text(note)
|
Text(moment.text)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -87,7 +89,7 @@ struct VisitSummaryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ForEach(categories, id: \.self) { category in
|
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)
|
categoryRow(category: category, average: avg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import EventKit
|
import EventKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
struct AddMomentView: View {
|
struct AddMomentView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@@ -9,27 +10,48 @@ struct AddMomentView: View {
|
|||||||
|
|
||||||
let person: Person
|
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 text = ""
|
||||||
@State private var selectedType: MomentType = .conversation
|
@State private var selectedType: MomentType = .conversation
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
// Calendar
|
// Treffen: Kalender-Integration
|
||||||
@State private var addToCalendar = false
|
@State private var addToCalendar = false
|
||||||
@State private var eventDate: Date = {
|
@State private var eventDate: Date = {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let hour = cal.component(.hour, from: Date())
|
let hour = cal.component(.hour, from: Date())
|
||||||
return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? 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 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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
|
||||||
// Person context chip
|
// Person-Kontext-Chip
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
PersonAvatar(person: person, size: 36)
|
PersonAvatar(person: person, size: 36)
|
||||||
Text(person.name)
|
Text(person.name)
|
||||||
@@ -43,7 +65,7 @@ struct AddMomentView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Type selector
|
// Typ-Selektor
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(MomentType.allCases, id: \.self) { type in
|
ForEach(MomentType.allCases, id: \.self) { type in
|
||||||
@@ -67,10 +89,10 @@ struct AddMomentView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text input
|
// Texteingabe
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if text.isEmpty {
|
if text.isEmpty {
|
||||||
Text("Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?")
|
Text(placeholder)
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -91,16 +113,24 @@ struct AddMomentView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
// Calendar section — shown for Treffen and Vorhaben
|
// Treffen: Kalendertermin
|
||||||
if showsCalendarSection {
|
if showsCalendarSection {
|
||||||
calendarSection
|
calendarSection
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vorhaben: Erinnerung
|
||||||
|
if showsReminderSection {
|
||||||
|
reminderSection
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2), value: showsCalendarSection)
|
.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: addToCalendar)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: addReminder)
|
||||||
.background(theme.backgroundPrimary.ignoresSafeArea())
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
.navigationTitle("Moment festhalten")
|
.navigationTitle("Moment festhalten")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -121,7 +151,7 @@ struct AddMomentView: View {
|
|||||||
.onAppear { isFocused = true }
|
.onAppear { isFocused = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Calendar Section
|
// MARK: - Kalender-Sektion (Treffen)
|
||||||
|
|
||||||
private var calendarSection: some View {
|
private var calendarSection: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -151,7 +181,7 @@ struct AddMomentView: View {
|
|||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
.tint(theme.accent)
|
.tint(theme.accent)
|
||||||
.environment(\.locale, Locale(identifier: "de_DE"))
|
.environment(\.locale, Locale.current)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
@@ -180,7 +210,47 @@ struct AddMomentView: View {
|
|||||||
.padding(.horizontal, 20)
|
.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() {
|
private func save() {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespaces)
|
let trimmed = text.trimmingCharacters(in: .whitespaces)
|
||||||
@@ -189,6 +259,42 @@ struct AddMomentView: View {
|
|||||||
let moment = Moment(text: trimmed, type: selectedType, person: person)
|
let moment = Moment(text: trimmed, type: selectedType, person: person)
|
||||||
modelContext.insert(moment)
|
modelContext.insert(moment)
|
||||||
person.moments?.append(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 {
|
guard addToCalendar else {
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -204,16 +310,40 @@ struct AddMomentView: View {
|
|||||||
modelContext.insert(calEntry)
|
modelContext.insert(calEntry)
|
||||||
person.logEntries?.append(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)
|
// MARK: - EventKit (callback-basiert, kein Swift Concurrency)
|
||||||
|
|
||||||
private func createCalendarEvent(notes: String) {
|
private func createCalendarEvent(notes: String, completion: @escaping () -> Void) {
|
||||||
let store = EKEventStore()
|
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 {
|
guard granted, let calendar = store.defaultCalendarForNewEvents else {
|
||||||
DispatchQueue.main.async { self.dismiss() }
|
DispatchQueue.main.async { self.dismiss() }
|
||||||
return
|
return
|
||||||
@@ -239,9 +369,9 @@ struct AddMomentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
store.requestWriteOnlyAccessToEvents(completion: completion)
|
store.requestWriteOnlyAccessToEvents(completion: handler)
|
||||||
} else {
|
} 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 showingAddMoment = false
|
||||||
@State private var showingEditPerson = false
|
@State private var showingEditPerson = false
|
||||||
@State private var showingVisitRating = false
|
|
||||||
@State private var showingAftermathRating = false
|
// Meeting-Rating-Flow (V5)
|
||||||
@State private var selectedVisitForAftermath: Visit? = nil
|
@State private var momentForRating: Moment? = nil
|
||||||
@State private var selectedVisitForEdit: Visit? = nil
|
@State private var momentForAftermath: Moment? = nil
|
||||||
@State private var selectedVisitForSummary: Visit? = nil
|
@State private var momentForEdit: Moment? = nil
|
||||||
@State private var nextStepText = ""
|
@State private var momentForSummary: Moment? = nil
|
||||||
@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
|
|
||||||
}()
|
|
||||||
|
|
||||||
@StateObject private var personalityStore = PersonalityStore.shared
|
@StateObject private var personalityStore = PersonalityStore.shared
|
||||||
|
|
||||||
@@ -30,8 +24,6 @@ struct PersonDetailView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 28) {
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
personHeader
|
personHeader
|
||||||
nextStepSection
|
|
||||||
visitsSection
|
|
||||||
momentsSection
|
momentsSection
|
||||||
if hasInfoContent { infoSection }
|
if hasInfoContent { infoSection }
|
||||||
}
|
}
|
||||||
@@ -50,39 +42,40 @@ struct PersonDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddMoment) {
|
.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) {
|
.sheet(isPresented: $showingEditPerson) {
|
||||||
AddPersonView(existingPerson: person)
|
AddPersonView(existingPerson: person)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingReminderSheet) {
|
.sheet(item: $momentForRating) { moment in
|
||||||
NextStepReminderSheet(person: person, reminderDate: $reminderDate)
|
MeetingRatingFlowView(
|
||||||
|
moment: moment,
|
||||||
|
aftermathDelay: AftermathDelayOption.loadFromDefaults().seconds
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingVisitRating) {
|
.sheet(item: $momentForAftermath) { moment in
|
||||||
VisitRatingFlowView(person: person,
|
AftermathRatingFlowView(moment: moment)
|
||||||
aftermathDelay: AftermathDelayOption.loadFromDefaults().seconds)
|
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedVisitForAftermath) { visit in
|
.sheet(item: $momentForEdit) { moment in
|
||||||
AftermathRatingFlowView(visit: visit)
|
MeetingEditFlowView(moment: moment)
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedVisitForEdit) { visit in
|
.sheet(item: $momentForSummary) { moment in
|
||||||
VisitEditFlowView(visit: visit)
|
|
||||||
}
|
|
||||||
.sheet(item: $selectedVisitForSummary) { visit in
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VisitSummaryView(visit: visit, onDismiss: { selectedVisitForSummary = nil })
|
MeetingSummaryView(moment: moment, onDismiss: { momentForSummary = nil })
|
||||||
.navigationTitle("Besuch")
|
.navigationTitle("Treffen")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
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
|
// Schützt vor Crash wenn der ModelContext durch Migration oder
|
||||||
// CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden.
|
// CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden.
|
||||||
.onReceive(
|
.onReceive(
|
||||||
@@ -118,218 +111,7 @@ struct PersonDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Next Step
|
// MARK: - Momente
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
private var momentsSection: some View {
|
private var momentsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
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 {
|
if person.sortedMoments.isEmpty {
|
||||||
Text("Noch nichts festgehalten. Dein nächstes Gespräch kann hier beginnen.")
|
Text("Noch nichts festgehalten. Dein nächstes Gespräch kann hier beginnen.")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
@@ -374,7 +162,12 @@ struct PersonDetailView: View {
|
|||||||
moment: moment,
|
moment: moment,
|
||||||
isLast: index == person.sortedMoments.count - 1,
|
isLast: index == person.sortedMoments.count - 1,
|
||||||
onDelete: { deleteMoment(moment) },
|
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
|
// MARK: - Info
|
||||||
|
|
||||||
private var hasInfoContent: Bool {
|
private var hasInfoContent: Bool {
|
||||||
@@ -421,106 +261,46 @@ struct PersonDetailView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Aktionen
|
||||||
|
|
||||||
|
private func deleteMoment(_ moment: Moment) {
|
||||||
|
modelContext.delete(moment)
|
||||||
|
person.touch()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reminder Sheet
|
private func toggleImportant(_ moment: Moment) {
|
||||||
|
moment.isImportant.toggle()
|
||||||
struct NextStepReminderSheet: View {
|
moment.updatedAt = Date()
|
||||||
@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
|
private func toggleIntention(_ moment: Moment) {
|
||||||
DatePicker("", selection: $reminderDate, in: Date()..., displayedComponents: [.date, .hourAndMinute])
|
guard moment.isIntention else { return }
|
||||||
.datePickerStyle(.compact)
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
|
||||||
.labelsHidden()
|
if !moment.isCompleted {
|
||||||
.tint(theme.accent)
|
// Vorhaben abhaken: LogEntry erstellen
|
||||||
.environment(\.locale, Locale(identifier: "de_DE"))
|
moment.isCompleted = true
|
||||||
|
moment.updatedAt = Date()
|
||||||
// Buttons
|
let entry = LogEntry(type: .nextStep, title: moment.text, person: person)
|
||||||
VStack(spacing: 10) {
|
modelContext.insert(entry)
|
||||||
Button {
|
person.logEntries?.append(entry)
|
||||||
scheduleReminder()
|
// Erinnerung abbrechen falls vorhanden
|
||||||
dismiss()
|
if moment.reminderDate != nil {
|
||||||
} label: {
|
UNUserNotificationCenter.current()
|
||||||
Text("Erinnern")
|
.removePendingNotificationRequests(withIdentifiers: ["intention-\(moment.id)"])
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.background(theme.accent)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusTag))
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
Button {
|
moment.isCompleted = false
|
||||||
dismiss()
|
moment.updatedAt = Date()
|
||||||
} label: {
|
|
||||||
Text("Überspringen")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentSecondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
do {
|
||||||
.padding(.horizontal, 24)
|
try modelContext.save()
|
||||||
.padding(.top, 20)
|
} catch {
|
||||||
.padding(.bottom, 32)
|
AppEventLog.shared.record(
|
||||||
}
|
"Fehler beim Abhaken des Vorhabens: \(error.localizedDescription)",
|
||||||
.background(theme.backgroundPrimary)
|
level: .error, category: "Intention"
|
||||||
.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
|
|
||||||
)
|
)
|
||||||
center.add(request)
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
person.nextStepReminderDate = reminderDate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,6 +316,11 @@ private struct DeletableMomentRow: View {
|
|||||||
let isLast: Bool
|
let isLast: Bool
|
||||||
let onDelete: () -> Void
|
let onDelete: () -> Void
|
||||||
let onToggleImportant: () -> 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
|
@State private var offset: CGFloat = 0
|
||||||
private let actionWidth: CGFloat = 76
|
private let actionWidth: CGFloat = 76
|
||||||
@@ -585,7 +370,14 @@ private struct DeletableMomentRow: View {
|
|||||||
|
|
||||||
// Zeilen-Inhalt schiebt sich über die Buttons
|
// Zeilen-Inhalt schiebt sich über die Buttons
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
MomentRowView(moment: moment)
|
MomentRowView(
|
||||||
|
moment: moment,
|
||||||
|
onRateMeeting: onRateMeeting,
|
||||||
|
onAftermathMeeting: onAftermathMeeting,
|
||||||
|
onViewSummary: onViewSummary,
|
||||||
|
onEditMeeting: onEditMeeting,
|
||||||
|
onToggleIntention: onToggleIntention
|
||||||
|
)
|
||||||
if !isLast { RowDivider() }
|
if !isLast { RowDivider() }
|
||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
@@ -635,9 +427,153 @@ struct MomentRowView: View {
|
|||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
let moment: Moment
|
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 {
|
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) {
|
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) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Image(systemName: moment.type.icon)
|
Image(systemName: moment.type.icon)
|
||||||
.font(.system(size: 13, weight: .light))
|
.font(.system(size: 13, weight: .light))
|
||||||
@@ -657,7 +593,9 @@ struct MomentRowView: View {
|
|||||||
}
|
}
|
||||||
.frame(width: 18)
|
.frame(width: 18)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var momentContent: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(moment.text)
|
Text(moment.text)
|
||||||
.font(.system(size: 15, design: theme.displayDesign))
|
.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 {
|
private func sourceColor(_ source: MomentSource) -> Color {
|
||||||
|
|||||||
Reference in New Issue
Block a user