Umbau aud "Momante"
This commit is contained in:
@@ -19,7 +19,6 @@
|
||||
26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */; };
|
||||
26B2CAE92F93C0490039BA3B /* AftermathRatingFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */; };
|
||||
26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */; };
|
||||
26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */; };
|
||||
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */; };
|
||||
26B2CAF72F93ED690039BA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */; };
|
||||
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */; };
|
||||
@@ -112,7 +111,6 @@
|
||||
26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitRatingFlowView.swift; sourceTree = "<group>"; };
|
||||
26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AftermathRatingFlowView.swift; sourceTree = "<group>"; };
|
||||
26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitSummaryView.swift; sourceTree = "<group>"; };
|
||||
26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitHistorySection.swift; sourceTree = "<group>"; };
|
||||
26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitEditFlowView.swift; sourceTree = "<group>"; };
|
||||
26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyBadgeView.swift; sourceTree = "<group>"; };
|
||||
@@ -225,7 +223,6 @@
|
||||
26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */,
|
||||
26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */,
|
||||
26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */,
|
||||
26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */,
|
||||
26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -440,7 +437,6 @@
|
||||
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */,
|
||||
26EF66322F9112E700824F91 /* Models.swift in Sources */,
|
||||
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */,
|
||||
26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */,
|
||||
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
|
||||
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
|
||||
26F8B0CB2F94E4E1004905B9 /* PersonalityEngine.swift in Sources */,
|
||||
|
||||
BIN
Binary file not shown.
@@ -278,7 +278,10 @@ struct AddMomentView: View {
|
||||
|
||||
// Treffen: Callback für Rating-Flow + evtl. Kalendertermin
|
||||
if selectedType == .meeting {
|
||||
// createdAt = Zeitpunkt des Treffens; wenn ein Zukunftstermin gewählt,
|
||||
// bleibt der Moment zunächst unbewertet (kein sofortiger Rating-Flow)
|
||||
if addToCalendar {
|
||||
moment.createdAt = eventDate
|
||||
let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute())
|
||||
let calEntry = LogEntry(
|
||||
type: .calendarEvent,
|
||||
|
||||
@@ -6,15 +6,12 @@ struct CallSuggestionView: View {
|
||||
@Bindable var person: Person
|
||||
let onConfirm: () -> Void
|
||||
|
||||
/// Zeigt PersonalityBadge wenn profil vorhanden und Person schon länger nicht besucht.
|
||||
/// Zeigt PersonalityBadge wenn Profil vorhanden und letztes Treffen schon länger zurückliegt.
|
||||
private var showRecommendedBadge: Bool {
|
||||
guard let profile = PersonalityStore.shared.profile,
|
||||
profile.level(for: .agreeableness) == .high else { return false }
|
||||
let lastVisit = person.visits?
|
||||
.compactMap { $0.visitDate }
|
||||
.max()
|
||||
guard let lastVisit else { return true }
|
||||
let days = Calendar.current.dateComponents([.day], from: lastVisit, to: Date()).day ?? 0
|
||||
guard let lastMeeting = person.lastMeetingDate else { return true }
|
||||
let days = Calendar.current.dateComponents([.day], from: lastMeeting, to: Date()).day ?? 0
|
||||
return days > 14
|
||||
}
|
||||
|
||||
@@ -63,12 +60,12 @@ struct CallSuggestionView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
}
|
||||
|
||||
// Offener nächster Schritt
|
||||
if let step = person.nextStep, !person.nextStepCompleted {
|
||||
// Offenes Vorhaben (ersetzt nextStep)
|
||||
if let intention = person.openIntentions.first {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
.font(.system(size: 13))
|
||||
Text(step)
|
||||
Text(intention.text)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundStyle(theme.accent.opacity(0.85))
|
||||
|
||||
@@ -9,6 +9,8 @@ struct ContentView: View {
|
||||
@AppStorage("callWindowOnboardingDone") private var onboardingDone = false
|
||||
@AppStorage("callSuggestionDate") private var suggestionDateStr = ""
|
||||
@AppStorage("photoRepairPassDone") private var photoRepairPassDone = false
|
||||
@AppStorage("visitMigrationPassDone") private var visitMigrationPassDone = false
|
||||
@AppStorage("nextStepMigrationPassDone") private var nextStepMigrationPassDone = false
|
||||
|
||||
@EnvironmentObject private var callWindowManager: CallWindowManager
|
||||
@EnvironmentObject private var appLockManager: AppLockManager
|
||||
@@ -85,6 +87,8 @@ struct ContentView: View {
|
||||
syncPeopleCache()
|
||||
importPendingMoments()
|
||||
runPhotoRepairPass()
|
||||
runVisitMigrationPass()
|
||||
runNextStepMigrationPass()
|
||||
if !nahbarOnboardingDone {
|
||||
showingNahbarOnboarding = true
|
||||
} else if !onboardingDone {
|
||||
@@ -210,6 +214,114 @@ struct ContentView: View {
|
||||
AppEventLog.shared.record("Foto-Migration: \(personsNeedingRepair.count) Person(en) migriert", level: .success, category: "Migration")
|
||||
}
|
||||
|
||||
// MARK: - Visit Migration Pass (V4 → V5 Datenmigration)
|
||||
|
||||
/// Überführt Visit-Objekte in Meeting-Momente.
|
||||
/// Ratings und HealthSnapshot werden auf den neuen Moment umgehängt, BEVOR das Visit
|
||||
/// gelöscht wird – sonst würde die Cascade-Delete-Rule die Ratings mitlöschen.
|
||||
/// Läuft einmalig nach der Schema-V5-Migration. Danach: visitMigrationPassDone = true.
|
||||
private func runVisitMigrationPass() {
|
||||
guard !visitMigrationPassDone else { return }
|
||||
|
||||
let descriptor = FetchDescriptor<Visit>()
|
||||
guard let visits = try? modelContext.fetch(descriptor) else {
|
||||
visitMigrationPassDone = true
|
||||
logger.info("Visit Migration Pass: Fetch fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
|
||||
guard !visits.isEmpty else {
|
||||
visitMigrationPassDone = true
|
||||
logger.info("Visit Migration Pass: nichts zu migrieren")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Visit Migration Pass: \(visits.count) Visit(s) werden migriert")
|
||||
for visit in visits {
|
||||
let moment = Moment(
|
||||
text: visit.note ?? "",
|
||||
type: .meeting,
|
||||
source: nil,
|
||||
person: visit.person
|
||||
)
|
||||
moment.createdAt = visit.visitDate
|
||||
moment.updatedAt = visit.visitDate
|
||||
moment.statusRaw = visit.statusRaw
|
||||
moment.aftermathNotificationScheduled = visit.aftermathNotificationScheduled
|
||||
moment.aftermathCompletedAt = visit.aftermathCompletedAt
|
||||
modelContext.insert(moment)
|
||||
visit.person?.moments?.append(moment)
|
||||
|
||||
// Ratings umhängen BEVOR Visit gelöscht wird (Cascade-Delete-Schutz)
|
||||
for rating in (visit.ratings ?? []) {
|
||||
rating.visit = nil
|
||||
rating.moment = moment
|
||||
}
|
||||
|
||||
// HealthSnapshot umhängen
|
||||
if let snapshot = visit.healthSnapshot {
|
||||
snapshot.visit = nil
|
||||
snapshot.moment = moment
|
||||
}
|
||||
|
||||
modelContext.delete(visit)
|
||||
}
|
||||
|
||||
save()
|
||||
visitMigrationPassDone = true
|
||||
logger.info("Visit Migration Pass abgeschlossen")
|
||||
AppEventLog.shared.record(
|
||||
"Treffen-Migration: \(visits.count) Visit(s) zu Momenten konvertiert",
|
||||
level: .success,
|
||||
category: "Migration"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Next Step Migration Pass (V4 → V5 Datenmigration)
|
||||
|
||||
/// Überführt Person.nextStep-Felder in Vorhaben-Momente.
|
||||
/// Läuft einmalig nach der Schema-V5-Migration. Danach: nextStepMigrationPassDone = true.
|
||||
private func runNextStepMigrationPass() {
|
||||
guard !nextStepMigrationPassDone else { return }
|
||||
|
||||
let personsWithNextStep = persons.filter {
|
||||
let step = $0.nextStep
|
||||
return step != nil && !(step!.isEmpty)
|
||||
}
|
||||
|
||||
guard !personsWithNextStep.isEmpty else {
|
||||
nextStepMigrationPassDone = true
|
||||
logger.info("Next Step Migration Pass: nichts zu migrieren")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Next Step Migration Pass: \(personsWithNextStep.count) Person(en) werden migriert")
|
||||
for person in personsWithNextStep {
|
||||
guard let text = person.nextStep, !text.isEmpty else { continue }
|
||||
|
||||
let moment = Moment(text: text, type: .intention, source: nil, person: person)
|
||||
moment.reminderDate = person.nextStepReminderDate
|
||||
moment.isCompleted = person.nextStepCompleted
|
||||
modelContext.insert(moment)
|
||||
person.moments?.append(moment)
|
||||
|
||||
// Legacy-Felder leeren
|
||||
person.nextStep = nil
|
||||
person.nextStepCompleted = false
|
||||
person.nextStepReminderDate = nil
|
||||
person.touch()
|
||||
}
|
||||
|
||||
save()
|
||||
nextStepMigrationPassDone = true
|
||||
logger.info("Next Step Migration Pass abgeschlossen")
|
||||
AppEventLog.shared.record(
|
||||
"Vorhaben-Migration: \(personsWithNextStep.count) Nächste-Schritte zu Momenten konvertiert",
|
||||
level: .success,
|
||||
category: "Migration"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func save() {
|
||||
|
||||
@@ -286,6 +286,7 @@
|
||||
},
|
||||
"Abgeschlossen" : {
|
||||
"comment" : "VisitHistorySection – visit status label",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -539,6 +540,7 @@
|
||||
},
|
||||
"Anstehende Termine" : {
|
||||
"comment" : "TodayView – section title for upcoming reminders",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -548,6 +550,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Anstehende Unternehmungen" : {
|
||||
"comment" : "TodayView – section title for intention moments with reminder dates",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Upcoming Activities"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"App wirklich zurücksetzen?" : {
|
||||
|
||||
},
|
||||
@@ -664,6 +677,7 @@
|
||||
},
|
||||
"Besuch" : {
|
||||
"comment" : "PersonDetailView – button label to rate a new visit",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -675,6 +689,7 @@
|
||||
},
|
||||
"Besuch bewerten" : {
|
||||
"comment" : "VisitRatingFlowView – navigation title",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -696,8 +711,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Bewerten" : {
|
||||
"comment" : "PersonDetailView – button label to open rating flow for a meeting moment",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Bewertet" : {
|
||||
"comment" : "VisitHistorySection – visit status label for immediateCompleted",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -1587,11 +1614,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Erfasse Treffen als Moment und bewerte sie mit einem kurzen Fragebogen." : {
|
||||
"comment" : "OnboardingContainerView – feature tour card description for Treffen",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Log meetings as a moment and rate them with a short questionnaire."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ergebnis bestätigen und fortfahren" : {
|
||||
|
||||
},
|
||||
"Erinnern" : {
|
||||
"comment" : "PersonDetailView – set reminder confirmation button",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -1601,8 +1640,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Erinnerung setzen" : {
|
||||
"comment" : "AddMomentView – toggle label to enable reminder for intention moments",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Set reminder"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Erinnerung setzen?" : {
|
||||
"comment" : "PersonDetailView – reminder prompt title",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -1966,6 +2017,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Geplante Treffen" : {
|
||||
"comment" : "TodayView – section title for planned future meeting moments",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Planned Meetings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Gesprächszeit" : {
|
||||
"comment" : "SettingsView section header / CallWindowSetupView nav title",
|
||||
"localizations" : {
|
||||
@@ -2053,9 +2115,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Halte fest, wen du getroffen hast – und wann." : {
|
||||
|
||||
},
|
||||
"Hat sich deine Sicht auf die Person verändert?" : {
|
||||
"comment" : "RatingQuestion – aftermath question text",
|
||||
@@ -2486,8 +2545,20 @@
|
||||
"Kontakte überspringen?" : {
|
||||
|
||||
},
|
||||
"Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." : {
|
||||
"Kontakte und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." : {
|
||||
"comment" : "OnboardingPrivacyView – local storage privacy row text",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Contacts and moments stay local on your device – no cloud synchronisation."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." : {
|
||||
"comment" : "OnboardingPrivacyView – local storage privacy row text (stale: replaced by V5 string)",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2680,6 +2751,7 @@
|
||||
},
|
||||
"Möchtest du die Notiz anpassen?" : {
|
||||
"comment" : "VisitEditFlowView – note step title",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2703,6 +2775,7 @@
|
||||
},
|
||||
"Möchtest du noch etwas festhalten?" : {
|
||||
"comment" : "VisitRatingFlowView – note step title",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2906,6 +2979,7 @@
|
||||
},
|
||||
"Nächster Schritt" : {
|
||||
"comment" : "PersonDetailView – next step section header",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2940,6 +3014,7 @@
|
||||
},
|
||||
"Nachwirkung ausstehend" : {
|
||||
"comment" : "VisitHistorySection – visit status label for awaitingAftermath",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -3178,6 +3253,7 @@
|
||||
},
|
||||
"Noch keine Treffen bewertet" : {
|
||||
"comment" : "VisitHistorySection – empty state title",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -3208,6 +3284,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Notiz anpassen" : {
|
||||
|
||||
},
|
||||
"Notizen" : {
|
||||
"comment" : "AddPersonView / PersonDetailView – notes field label (plural)",
|
||||
@@ -3257,6 +3336,7 @@
|
||||
},
|
||||
"Offene Schritte" : {
|
||||
"comment" : "TodayView – section title for open next steps",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -3266,8 +3346,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Offene Unternehmungen" : {
|
||||
"comment" : "TodayView – section title for open intention moments without reminder",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Open Activities"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ok" : {
|
||||
"comment" : "PersonDetailView – next step confirmation button",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -3388,8 +3480,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Plane gemeinsame Aktivitäten und bleib mit wichtigen Menschen in Kontakt." : {
|
||||
|
||||
"Plane Unternehmungen mit Erinnerung – nahbar erinnert dich zur richtigen Zeit." : {
|
||||
"comment" : "OnboardingContainerView – feature tour card description for Unternehmung (intention type)",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Plan activities with a reminder – nahbar notifies you at the right time."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Plane Vorhaben mit Erinnerung – nahbar erinnert dich zur richtigen Zeit." : {
|
||||
"comment" : "OnboardingContainerView – stale, replaced by Momente wording",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Plan intentions with a reminder – nahbar notifies you at the right time."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PRO" : {
|
||||
"comment" : "Badge label for Pro tier",
|
||||
@@ -3539,6 +3651,7 @@
|
||||
},
|
||||
"Schritt definieren" : {
|
||||
"comment" : "PersonDetailView – define next step button",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -3803,6 +3916,7 @@
|
||||
},
|
||||
"Tippe auf + um loszulegen" : {
|
||||
"comment" : "VisitHistorySection – empty state subtitle",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -3844,6 +3958,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Treffen bewerten" : {
|
||||
|
||||
},
|
||||
"Treffen mit %@" : {
|
||||
"comment" : "AddMomentView – calendar event / LogEntry title with person name",
|
||||
@@ -3982,6 +4099,17 @@
|
||||
},
|
||||
"Uns fehlt noch was – wir würden gerne mehr von dir erfahren." : {
|
||||
|
||||
},
|
||||
"Unternehmung" : {
|
||||
"comment" : "MomentType.intention displayName – shown in type picker and feature tour",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Activity"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unwohl" : {
|
||||
"comment" : "RatingQuestion – negative pole for comfort during meeting",
|
||||
@@ -4130,7 +4258,8 @@
|
||||
}
|
||||
},
|
||||
"Vorhaben" : {
|
||||
"comment" : "MomentType.intention raw value",
|
||||
"comment" : "MomentType.intention raw value (stale: displayName now returns 'Momente')",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -4232,6 +4361,7 @@
|
||||
},
|
||||
"Was als Nächstes?" : {
|
||||
"comment" : "PersonDetailView – next step input placeholder",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -4243,6 +4373,7 @@
|
||||
},
|
||||
"Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?" : {
|
||||
"comment" : "AddMomentView – text editor placeholder",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
|
||||
@@ -165,17 +165,34 @@ struct LogbuchView: View {
|
||||
|
||||
private func logbuchRow(item: LogbuchItem) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
// Typ-Icon (bei Vorhaben: Checkbox-Status)
|
||||
Group {
|
||||
if case .moment(let m) = item, m.isIntention {
|
||||
Image(systemName: m.isCompleted ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(m.isCompleted ? Color.green : theme.contentTertiary)
|
||||
} else {
|
||||
Image(systemName: item.icon)
|
||||
.font(.system(size: 14, weight: .light))
|
||||
.foregroundStyle(item.isLogEntry ? theme.accent : theme.contentTertiary)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 14, weight: .light))
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
// Titel (Vorhaben: Durchgestrichen wenn erledigt)
|
||||
if case .moment(let m) = item, m.isIntention, m.isCompleted {
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
.strikethrough(true, color: theme.contentTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
Text(item.title)
|
||||
.font(.system(size: 15, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
if case .moment(let m) = item, m.isImportant {
|
||||
@@ -196,11 +213,33 @@ struct LogbuchView: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Score-Badge für bewertete Treffen
|
||||
if case .moment(let m) = item, m.isMeeting, let avg = m.immediateAverage {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(scoreColor(avg).opacity(0.15))
|
||||
.frame(width: 30, height: 30)
|
||||
Text(String(format: "%.1f", avg))
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(scoreColor(avg))
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private func scoreColor(_ value: Double) -> Color {
|
||||
switch value {
|
||||
case ..<(-0.5): return .red
|
||||
case (-0.5)..<(0.5): return Color(.systemGray3)
|
||||
case (0.5)...: return .green
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private var emptyState: some View {
|
||||
|
||||
+108
-5
@@ -46,7 +46,12 @@ enum MomentType: String, CaseIterable, Codable {
|
||||
case intention = "Vorhaben"
|
||||
|
||||
/// Anzeigename im UI — entkoppelt Persistenzschlüssel von der Darstellung.
|
||||
var displayName: String { rawValue }
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .intention: return "Unternehmung"
|
||||
default: return rawValue
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
@@ -233,6 +238,36 @@ class Person {
|
||||
(visits ?? []).count
|
||||
}
|
||||
|
||||
// MARK: Meeting-Momente (V5)
|
||||
|
||||
var meetingMoments: [Moment] {
|
||||
(moments ?? []).filter { $0.type == .meeting }
|
||||
}
|
||||
|
||||
var sortedMeetings: [Moment] {
|
||||
meetingMoments.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var lastMeeting: Moment? {
|
||||
sortedMeetings.first
|
||||
}
|
||||
|
||||
var lastMeetingDate: Date? {
|
||||
lastMeeting?.createdAt
|
||||
}
|
||||
|
||||
var meetingCount: Int {
|
||||
meetingMoments.count
|
||||
}
|
||||
|
||||
// MARK: Offene Vorhaben (V5)
|
||||
|
||||
/// Alle Vorhaben-Momente, die noch nicht abgehakt wurden.
|
||||
var openIntentions: [Moment] {
|
||||
(moments ?? []).filter { $0.isOpen }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
|
||||
/// Muss nach jeder inhaltlichen Änderung aufgerufen werden.
|
||||
func touch() {
|
||||
updatedAt = Date()
|
||||
@@ -302,6 +337,17 @@ class Moment {
|
||||
var isImportant: Bool = false // Vom Nutzer als wichtig markiert
|
||||
var person: Person?
|
||||
|
||||
// V5: Meeting-Bewertungsfelder (ehemals auf Visit)
|
||||
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
|
||||
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
|
||||
var statusRaw: String? = nil // nil für Nicht-Treffen; VisitStatus-rawValue für Treffen
|
||||
var aftermathNotificationScheduled: Bool = false
|
||||
var aftermathCompletedAt: Date? = nil
|
||||
|
||||
// V5: Vorhaben-Felder (ehemals Person.nextStep / nextStepReminderDate)
|
||||
var reminderDate: Date? = nil // Erinnerungstermin für Vorhaben
|
||||
var isCompleted: Bool = false // Ob das Vorhaben abgehakt wurde
|
||||
|
||||
init(text: String, type: MomentType = .conversation, source: MomentSource? = nil, person: Person? = nil) {
|
||||
self.id = UUID()
|
||||
self.text = text
|
||||
@@ -311,6 +357,13 @@ class Moment {
|
||||
self.updatedAt = Date()
|
||||
self.isImportant = false
|
||||
self.person = person
|
||||
self.ratings = []
|
||||
self.healthSnapshot = nil
|
||||
self.statusRaw = nil
|
||||
self.aftermathNotificationScheduled = false
|
||||
self.aftermathCompletedAt = nil
|
||||
self.reminderDate = nil
|
||||
self.isCompleted = false
|
||||
}
|
||||
|
||||
var type: MomentType {
|
||||
@@ -322,6 +375,51 @@ class Moment {
|
||||
get { sourceRaw.flatMap { MomentSource(rawValue: $0) } }
|
||||
set { sourceRaw = newValue?.rawValue }
|
||||
}
|
||||
|
||||
// MARK: Meeting-Hilfseigenschaften (V5)
|
||||
|
||||
var isMeeting: Bool { type == .meeting }
|
||||
|
||||
var meetingStatus: VisitStatus? {
|
||||
get { statusRaw.flatMap { VisitStatus(rawValue: $0) } }
|
||||
set { statusRaw = newValue?.rawValue }
|
||||
}
|
||||
|
||||
var isMeetingComplete: Bool {
|
||||
meetingStatus == .completed
|
||||
}
|
||||
|
||||
var sortedRatings: [Rating] {
|
||||
(ratings ?? []).sorted { $0.questionIndex < $1.questionIndex }
|
||||
}
|
||||
|
||||
func averageForCategory(_ category: RatingCategory, aftermath: Bool) -> Double? {
|
||||
let filtered = (ratings ?? []).filter {
|
||||
$0.category == category && $0.isAftermath == aftermath
|
||||
}
|
||||
let valued = filtered.compactMap { $0.value }
|
||||
guard !valued.isEmpty else { return nil }
|
||||
return Double(valued.reduce(0, +)) / Double(valued.count)
|
||||
}
|
||||
|
||||
var immediateAverage: Double? {
|
||||
let values = (ratings ?? []).filter { !$0.isAftermath }.compactMap { $0.value }
|
||||
guard !values.isEmpty else { return nil }
|
||||
return Double(values.reduce(0, +)) / Double(values.count)
|
||||
}
|
||||
|
||||
var aftermathAverage: Double? {
|
||||
let values = (ratings ?? []).filter { $0.isAftermath }.compactMap { $0.value }
|
||||
guard !values.isEmpty else { return nil }
|
||||
return Double(values.reduce(0, +)) / Double(values.count)
|
||||
}
|
||||
|
||||
// MARK: Vorhaben-Hilfseigenschaften (V5)
|
||||
|
||||
var isIntention: Bool { type == .intention }
|
||||
|
||||
/// Ein Vorhaben das noch nicht abgehakt wurde.
|
||||
var isOpen: Bool { isIntention && !isCompleted }
|
||||
}
|
||||
|
||||
// MARK: - Visit Rating Enums
|
||||
@@ -497,15 +595,18 @@ class Rating {
|
||||
var questionIndex: Int = 0
|
||||
var value: Int? = nil // nil = übersprungen; -2...+2 sonst
|
||||
var isAftermath: Bool = false
|
||||
var visit: Visit? = nil
|
||||
var visit: Visit? = nil // V4 legacy – nach Migration immer nil
|
||||
var moment: Moment? = nil // V5 – primäre Referenz
|
||||
|
||||
init(category: RatingCategory, questionIndex: Int, value: Int?, isAftermath: Bool, visit: Visit? = nil) {
|
||||
init(category: RatingCategory, questionIndex: Int, value: Int?, isAftermath: Bool,
|
||||
visit: Visit? = nil, moment: Moment? = nil) {
|
||||
self.id = UUID()
|
||||
self.categoryRaw = category.rawValue
|
||||
self.questionIndex = questionIndex
|
||||
self.value = value
|
||||
self.isAftermath = isAftermath
|
||||
self.visit = visit
|
||||
self.moment = moment
|
||||
}
|
||||
|
||||
var category: RatingCategory {
|
||||
@@ -523,11 +624,13 @@ class HealthSnapshot {
|
||||
var hrvMs: Double? = nil
|
||||
var restingHR: Int? = nil
|
||||
var steps: Int? = nil
|
||||
var visit: Visit? = nil
|
||||
var visit: Visit? = nil // V4 legacy – nach Migration immer nil
|
||||
var moment: Moment? = nil // V5 – primäre Referenz
|
||||
|
||||
init(visit: Visit? = nil) {
|
||||
init(visit: Visit? = nil, moment: Moment? = nil) {
|
||||
self.id = UUID()
|
||||
self.visit = visit
|
||||
self.moment = moment
|
||||
}
|
||||
|
||||
var hasData: Bool {
|
||||
|
||||
@@ -186,18 +186,123 @@ enum NahbarSchemaV3: VersionedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema V4 (aktuelles Schema)
|
||||
// Referenziert die Live-Typen aus Models.swift.
|
||||
// Beim Hinzufügen von V5 muss V4 als eingefrorener Snapshot gesichert werden.
|
||||
// MARK: - Schema V4 (eingefrorener Snapshot)
|
||||
// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten
|
||||
// Schema-Hash von V4-Datenbanken auf Nutzer-Geräten entsprechen.
|
||||
//
|
||||
// V4 fügt hinzu:
|
||||
// V4 fügte hinzu:
|
||||
// • Visit, Rating, HealthSnapshot: neue Modelle für Besuchs-Bewertungen
|
||||
// • Person: visits-Relationship
|
||||
|
||||
enum NahbarSchemaV4: VersionedSchema {
|
||||
static var versionIdentifier = Schema.Version(4, 0, 0)
|
||||
static var models: [any PersistentModel.Type] {
|
||||
[nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self, nahbar.PersonPhoto.self,
|
||||
[PersonPhoto.self, Person.self, Moment.self, LogEntry.self,
|
||||
Visit.self, Rating.self, HealthSnapshot.self]
|
||||
}
|
||||
|
||||
@Model final class PersonPhoto {
|
||||
var id: UUID = UUID()
|
||||
@Attribute(.externalStorage) var imageData: Data = Data()
|
||||
var createdAt: Date = Date()
|
||||
init() {}
|
||||
}
|
||||
|
||||
@Model final class Person {
|
||||
var id: UUID = UUID()
|
||||
var name: String = ""
|
||||
var tagRaw: String = "Andere"
|
||||
var birthday: Date? = nil
|
||||
var occupation: String? = nil
|
||||
var location: String? = nil
|
||||
var interests: String? = nil
|
||||
var generalNotes: String? = nil
|
||||
var nudgeFrequencyRaw: String = "Monatlich"
|
||||
var nextStep: String? = nil
|
||||
var nextStepCompleted: Bool = false
|
||||
var nextStepReminderDate: Date? = nil
|
||||
var lastSuggestedForCall: Date? = nil
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var isArchived: Bool = false
|
||||
@Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil
|
||||
var photoData: Data? = nil
|
||||
@Relationship(deleteRule: .cascade) var moments: [Moment]? = []
|
||||
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = []
|
||||
@Relationship(deleteRule: .cascade) var visits: [Visit]? = []
|
||||
init() {}
|
||||
}
|
||||
|
||||
@Model final class Moment {
|
||||
var id: UUID = UUID()
|
||||
var text: String = ""
|
||||
var typeRaw: String = "Gespräch"
|
||||
var sourceRaw: String? = nil
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var isImportant: Bool = false
|
||||
var person: Person? = nil
|
||||
init() {}
|
||||
}
|
||||
|
||||
@Model final class LogEntry {
|
||||
var id: UUID = UUID()
|
||||
var typeRaw: String = "Schritt abgeschlossen"
|
||||
var title: String = ""
|
||||
var loggedAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var person: Person? = nil
|
||||
init() {}
|
||||
}
|
||||
|
||||
@Model final class Visit {
|
||||
var id: UUID = UUID()
|
||||
var visitDate: Date = Date()
|
||||
var statusRaw: String = "sofort_abgeschlossen"
|
||||
var note: String? = nil
|
||||
var aftermathNotificationScheduled: Bool = false
|
||||
var aftermathCompletedAt: Date? = nil
|
||||
var person: Person? = nil
|
||||
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
|
||||
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
|
||||
init() {}
|
||||
}
|
||||
|
||||
@Model final class Rating {
|
||||
var id: UUID = UUID()
|
||||
var categoryRaw: String = "Selbst"
|
||||
var questionIndex: Int = 0
|
||||
var value: Int? = nil
|
||||
var isAftermath: Bool = false
|
||||
var visit: Visit? = nil
|
||||
init() {}
|
||||
}
|
||||
|
||||
@Model final class HealthSnapshot {
|
||||
var id: UUID = UUID()
|
||||
var sleepHours: Double? = nil
|
||||
var hrvMs: Double? = nil
|
||||
var restingHR: Int? = nil
|
||||
var steps: Int? = nil
|
||||
var visit: Visit? = nil
|
||||
init() {}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema V5 (aktuelles Schema)
|
||||
// Referenziert die Live-Typen aus Models.swift.
|
||||
// Beim Hinzufügen von V6 muss V5 als eingefrorener Snapshot gesichert werden.
|
||||
//
|
||||
// V5 fügt hinzu:
|
||||
// • Moment: ratings, healthSnapshot, statusRaw, aftermathNotificationScheduled,
|
||||
// aftermathCompletedAt, reminderDate, isCompleted
|
||||
// • Rating: moment-Relationship (neben legacy visit)
|
||||
// • HealthSnapshot: moment-Relationship (neben legacy visit)
|
||||
|
||||
enum NahbarSchemaV5: VersionedSchema {
|
||||
static var versionIdentifier = Schema.Version(5, 0, 0)
|
||||
static var models: [any PersistentModel.Type] {
|
||||
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
|
||||
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self]
|
||||
}
|
||||
}
|
||||
@@ -206,7 +311,8 @@ enum NahbarSchemaV4: VersionedSchema {
|
||||
|
||||
enum NahbarMigrationPlan: SchemaMigrationPlan {
|
||||
static var schemas: [any VersionedSchema.Type] {
|
||||
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self, NahbarSchemaV4.self]
|
||||
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
||||
NahbarSchemaV4.self, NahbarSchemaV5.self]
|
||||
}
|
||||
|
||||
static var stages: [MigrationStage] {
|
||||
@@ -221,7 +327,12 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
|
||||
|
||||
// V3 → V4: Visit/Rating/HealthSnapshot neu, Person bekommt visits-Relationship.
|
||||
// Alle neuen Felder haben Default-Werte → lightweight-Migration reicht aus.
|
||||
.lightweight(fromVersion: NahbarSchemaV3.self, toVersion: NahbarSchemaV4.self)
|
||||
.lightweight(fromVersion: NahbarSchemaV3.self, toVersion: NahbarSchemaV4.self),
|
||||
|
||||
// V4 → V5: Moment bekommt rating/intention-Felder, Rating/HealthSnapshot
|
||||
// bekommen moment-Relationship. Visit/HealthSnapshot bleiben im Schema
|
||||
// (CloudKit-Sicherheit). Alle neuen Felder haben Default-Werte → lightweight.
|
||||
.lightweight(fromVersion: NahbarSchemaV4.self, toVersion: NahbarSchemaV5.self)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,7 +605,7 @@ private struct OnboardingPrivacyView: View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
privacyRow(
|
||||
icon: "iphone",
|
||||
text: "Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation."
|
||||
text: "Kontakte und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation."
|
||||
)
|
||||
privacyRow(
|
||||
icon: "person.slash",
|
||||
@@ -664,14 +664,14 @@ struct FeatureTourStep {
|
||||
static let all: [FeatureTourStep] = [
|
||||
FeatureTourStep(
|
||||
icon: "checklist",
|
||||
title: "Vorhaben",
|
||||
description: "Plane gemeinsame Aktivitäten und bleib mit wichtigen Menschen in Kontakt.",
|
||||
title: "Unternehmung",
|
||||
description: "Plane Unternehmungen mit Erinnerung – nahbar erinnert dich zur richtigen Zeit.",
|
||||
showPrivacySummary: false
|
||||
),
|
||||
FeatureTourStep(
|
||||
icon: "figure.walk.arrival",
|
||||
title: "Treffen",
|
||||
description: "Halte fest, wen du getroffen hast – und wann.",
|
||||
description: "Erfasse Treffen als Moment und bewerte sie mit einem kurzen Fragebogen.",
|
||||
showPrivacySummary: false
|
||||
),
|
||||
FeatureTourStep(
|
||||
|
||||
@@ -43,7 +43,8 @@ struct PersonDetailView: View {
|
||||
}
|
||||
.sheet(isPresented: $showingAddMoment) {
|
||||
AddMomentView(person: person) { meetingMoment in
|
||||
// Nach Sheet-Dismiss kurz warten, dann Rating-Flow öffnen
|
||||
// Rating-Flow nur für vergangene Treffen – zukünftige Termine überspringen
|
||||
guard meetingMoment.createdAt <= Date() else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
||||
momentForRating = meetingMoment
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ enum PersonalityEngine {
|
||||
static func sortedSuggestions(
|
||||
contacts: [NahbarContact],
|
||||
profile: PersonalityProfile?,
|
||||
lastVisitDates: [UUID: Date]
|
||||
lastMeetingDates: [UUID: Date]
|
||||
) -> [ContactSuggestion] {
|
||||
guard let profile else {
|
||||
return contacts.map { ContactSuggestion(contact: $0, isRecommended: false, reason: nil) }
|
||||
@@ -127,16 +127,16 @@ enum PersonalityEngine {
|
||||
var sorted = contacts
|
||||
if a == .high {
|
||||
sorted = contacts.sorted { lhs, rhs in
|
||||
let lDate = lastVisitDates[lhs.id] ?? .distantPast
|
||||
let rDate = lastVisitDates[rhs.id] ?? .distantPast
|
||||
return lDate < rDate // ältester Besuch zuerst
|
||||
let lDate = lastMeetingDates[lhs.id] ?? .distantPast
|
||||
let rDate = lastMeetingDates[rhs.id] ?? .distantPast
|
||||
return lDate < rDate // ältestes Treffen zuerst
|
||||
}
|
||||
}
|
||||
|
||||
return sorted.prefix(maxCount).map { contact in
|
||||
let longAgo: Bool
|
||||
if let lastVisit = lastVisitDates[contact.id] {
|
||||
let daysSince = Calendar.current.dateComponents([.day], from: lastVisit, to: Date()).day ?? 0
|
||||
if let lastMeeting = lastMeetingDates[contact.id] {
|
||||
let daysSince = Calendar.current.dateComponents([.day], from: lastMeeting, to: Date()).day ?? 0
|
||||
longAgo = daysSince > 14
|
||||
} else {
|
||||
longAgo = true
|
||||
|
||||
@@ -605,11 +605,13 @@ struct SettingsView: View {
|
||||
UserProfileStore.shared.reset()
|
||||
ContactStore.shared.reset()
|
||||
|
||||
// 3. Onboarding-Flags zurücksetzen
|
||||
// 3. Onboarding- und Migrations-Flags zurücksetzen
|
||||
nahbarOnboardingDone = false
|
||||
callWindowOnboardingDone = false
|
||||
photoRepairPassDone = false
|
||||
callSuggestionDate = ""
|
||||
UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone")
|
||||
UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone")
|
||||
|
||||
// 4. App neu starten damit alle States frisch initialisiert werden
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) }
|
||||
|
||||
@@ -4,11 +4,12 @@ import SwiftData
|
||||
struct TodayView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@Query private var people: [Person]
|
||||
@Query(filter: #Predicate<Visit> { $0.statusRaw == "warte_nachwirkung" },
|
||||
sort: \Visit.visitDate, order: .reverse)
|
||||
private var pendingAftermaths: [Visit]
|
||||
@State private var showingAftermathRating = false
|
||||
@State private var selectedVisitForAftermath: Visit? = nil
|
||||
// V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung"
|
||||
@Query(filter: #Predicate<Moment> {
|
||||
$0.statusRaw == "warte_nachwirkung" && $0.typeRaw == "Treffen"
|
||||
}, sort: \Moment.createdAt, order: .reverse)
|
||||
private var pendingAftermaths: [Moment]
|
||||
@State private var selectedMomentForAftermath: Moment? = nil
|
||||
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
||||
|
||||
private var needsAttention: [Person] {
|
||||
@@ -21,28 +22,42 @@ struct TodayView: View {
|
||||
people.filter { $0.hasBirthdayWithin(days: daysAhead) }
|
||||
}
|
||||
|
||||
// People with a scheduled reminder within the look-ahead window
|
||||
private var upcomingReminders: [Person] {
|
||||
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: Date()) ?? Date()
|
||||
return people
|
||||
.filter { p in
|
||||
guard let reminder = p.nextStepReminderDate, !p.nextStepCompleted else { return false }
|
||||
return reminder >= Date() && reminder <= horizon
|
||||
}
|
||||
.sorted { ($0.nextStepReminderDate ?? Date()) < ($1.nextStepReminderDate ?? Date()) }
|
||||
// V5: Geplante Treffen (Datum in der Zukunft, noch nicht bewertet)
|
||||
@Query(filter: #Predicate<Moment> {
|
||||
$0.typeRaw == "Treffen" && $0.statusRaw == nil
|
||||
}, sort: \Moment.createdAt)
|
||||
private var allUnratedMeetings: [Moment]
|
||||
|
||||
private var plannedMeetings: [Moment] {
|
||||
let now = Date()
|
||||
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now
|
||||
return allUnratedMeetings.filter { $0.createdAt > now && $0.createdAt <= horizon }
|
||||
}
|
||||
|
||||
// Open next steps NOT already shown under upcoming reminders
|
||||
private var openNextSteps: [Person] {
|
||||
let scheduledIDs = Set(upcomingReminders.map { $0.id })
|
||||
return people.filter { p in
|
||||
p.nextStep != nil && !p.nextStepCompleted && !scheduledIDs.contains(p.id)
|
||||
// V5: Vorhaben-Momente mit Erinnerung innerhalb des Zeitfensters
|
||||
@Query(filter: #Predicate<Moment> {
|
||||
$0.typeRaw == "Vorhaben" && !$0.isCompleted && $0.reminderDate != nil
|
||||
}, sort: \Moment.reminderDate)
|
||||
private var allIntentionsWithReminder: [Moment]
|
||||
|
||||
private var upcomingReminders: [Moment] {
|
||||
let now = Date()
|
||||
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now
|
||||
return allIntentionsWithReminder.filter { m in
|
||||
guard let r = m.reminderDate else { return false }
|
||||
return r >= now && r <= horizon
|
||||
}
|
||||
}
|
||||
|
||||
// V5: Vorhaben ohne Erinnerung (offene Schritte)
|
||||
@Query(filter: #Predicate<Moment> {
|
||||
$0.typeRaw == "Vorhaben" && !$0.isCompleted && $0.reminderDate == nil
|
||||
}, sort: \Moment.createdAt)
|
||||
private var openIntentions: [Moment]
|
||||
|
||||
private var isEmpty: Bool {
|
||||
needsAttention.isEmpty && birthdayPeople.isEmpty && openNextSteps.isEmpty
|
||||
&& upcomingReminders.isEmpty && pendingAftermaths.isEmpty
|
||||
needsAttention.isEmpty && birthdayPeople.isEmpty && openIntentions.isEmpty
|
||||
&& upcomingReminders.isEmpty && pendingAftermaths.isEmpty && plannedMeetings.isEmpty
|
||||
}
|
||||
|
||||
private var birthdaySectionTitle: LocalizedStringKey {
|
||||
@@ -103,28 +118,48 @@ struct TodayView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !upcomingReminders.isEmpty {
|
||||
TodaySection(title: "Anstehende Termine", icon: "calendar") {
|
||||
ForEach(upcomingReminders) { person in
|
||||
if !plannedMeetings.isEmpty {
|
||||
TodaySection(title: "Geplante Treffen", icon: "calendar.badge.clock") {
|
||||
ForEach(plannedMeetings) { moment in
|
||||
if let person = moment.person {
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: reminderHint(for: person))
|
||||
TodayRow(person: person, hint: plannedMeetingHint(for: moment))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if person.id != upcomingReminders.last?.id {
|
||||
}
|
||||
if moment.id != plannedMeetings.last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !openNextSteps.isEmpty {
|
||||
TodaySection(title: "Offene Schritte", icon: "arrow.right.circle") {
|
||||
ForEach(openNextSteps) { person in
|
||||
if !upcomingReminders.isEmpty {
|
||||
TodaySection(title: "Anstehende Unternehmungen", icon: "calendar") {
|
||||
ForEach(upcomingReminders) { moment in
|
||||
if let person = moment.person {
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: person.nextStep ?? "")
|
||||
TodayRow(person: person, hint: intentionReminderHint(for: moment))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if person.id != openNextSteps.last?.id {
|
||||
}
|
||||
if moment.id != upcomingReminders.last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !openIntentions.isEmpty {
|
||||
TodaySection(title: "Offene Unternehmungen", icon: "arrow.right.circle") {
|
||||
ForEach(openIntentions) { moment in
|
||||
if let person = moment.person {
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: moment.text)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if moment.id != openIntentions.last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
@@ -133,20 +168,19 @@ struct TodayView: View {
|
||||
|
||||
if !pendingAftermaths.isEmpty {
|
||||
TodaySection(title: "Nachwirkung fällig", icon: "moon.stars.fill") {
|
||||
ForEach(pendingAftermaths) { visit in
|
||||
ForEach(pendingAftermaths) { moment in
|
||||
Button {
|
||||
selectedVisitForAftermath = visit
|
||||
showingAftermathRating = true
|
||||
selectedMomentForAftermath = moment
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
if let p = visit.person {
|
||||
if let p = moment.person {
|
||||
PersonAvatar(person: p, size: 36)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(visit.person?.name ?? "Unbekannt")
|
||||
Text(moment.person?.name ?? "Unbekannt")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text("Treffen \(visit.visitDate.formatted(date: .abbreviated, time: .omitted))")
|
||||
Text("Treffen \(moment.createdAt.formatted(date: .abbreviated, time: .omitted))")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
@@ -159,7 +193,7 @@ struct TodayView: View {
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if visit.id != pendingAftermaths.last?.id {
|
||||
if moment.id != pendingAftermaths.last?.id {
|
||||
RowDivider()
|
||||
}
|
||||
}
|
||||
@@ -186,8 +220,8 @@ struct TodayView: View {
|
||||
}
|
||||
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||
.navigationBarHidden(true)
|
||||
.sheet(item: $selectedVisitForAftermath) { visit in
|
||||
AftermathRatingFlowView(visit: visit)
|
||||
.sheet(item: $selectedMomentForAftermath) { moment in
|
||||
AftermathRatingFlowView(moment: moment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,10 +260,15 @@ struct TodayView: View {
|
||||
return ""
|
||||
}
|
||||
|
||||
private func reminderHint(for person: Person) -> String {
|
||||
guard let reminder = person.nextStepReminderDate else { return person.nextStep ?? "" }
|
||||
private func plannedMeetingHint(for moment: Moment) -> String {
|
||||
let dateStr = moment.createdAt.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
|
||||
return moment.text.isEmpty ? dateStr : "\(moment.text) · \(dateStr)"
|
||||
}
|
||||
|
||||
private func intentionReminderHint(for moment: Moment) -> String {
|
||||
guard let reminder = moment.reminderDate else { return moment.text }
|
||||
let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute())
|
||||
return "\(person.nextStep ?? "") · \(dateStr)"
|
||||
return "\(moment.text) · \(dateStr)"
|
||||
}
|
||||
|
||||
private func lastSeenHint(for person: Person) -> String {
|
||||
|
||||
@@ -155,14 +155,14 @@ struct SchemaRegressionTests {
|
||||
#expect(NahbarSchemaV3.versionIdentifier.patch == 0)
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 4 Schemas")
|
||||
func migrationPlanHasFourSchemas() {
|
||||
#expect(NahbarMigrationPlan.schemas.count == 4)
|
||||
@Test("Migrationsplan enthält genau 5 Schemas")
|
||||
func migrationPlanHasFiveSchemas() {
|
||||
#expect(NahbarMigrationPlan.schemas.count == 5)
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 3 Stages")
|
||||
func migrationPlanHasThreeStages() {
|
||||
#expect(NahbarMigrationPlan.stages.count == 3)
|
||||
@Test("Migrationsplan enthält genau 4 Stages")
|
||||
func migrationPlanHasFourStages() {
|
||||
#expect(NahbarMigrationPlan.stages.count == 4)
|
||||
}
|
||||
|
||||
@Test("ContainerFallback-Gleichheit funktioniert korrekt")
|
||||
|
||||
@@ -91,14 +91,20 @@ struct MomentTypeTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("displayName ist für alle Types gleich rawValue")
|
||||
func displayNameEqualsRawValueForAllTypes() {
|
||||
for type_ in MomentType.allCases {
|
||||
@Test("displayName entspricht rawValue außer für .intention")
|
||||
func displayNameEqualsRawValueExceptIntention() {
|
||||
for type_ in MomentType.allCases where type_ != .intention {
|
||||
#expect(type_.displayName == type_.rawValue,
|
||||
"displayName sollte rawValue sein für \(type_)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test(".intention hat displayName 'Unternehmung' (entkoppelt von rawValue 'Vorhaben')")
|
||||
func intentionDisplayNameIsUnternehmung() {
|
||||
#expect(MomentType.intention.displayName == "Unternehmung")
|
||||
#expect(MomentType.intention.rawValue == "Vorhaben") // Persistenz-Key bleibt
|
||||
}
|
||||
|
||||
@Test("alle Types haben nicht-leeres displayName")
|
||||
func allTypesHaveNonEmptyDisplayName() {
|
||||
for type_ in MomentType.allCases {
|
||||
@@ -287,6 +293,67 @@ struct MomentComputedPropertyTests {
|
||||
let m = Moment(text: "Test")
|
||||
#expect(!m.isImportant)
|
||||
}
|
||||
|
||||
// V5 – Meeting-Felder
|
||||
@Test("V5: statusRaw startet als nil")
|
||||
func statusRawDefaultsNil() {
|
||||
let m = Moment(text: "Test", type: .meeting)
|
||||
#expect(m.statusRaw == nil)
|
||||
}
|
||||
|
||||
@Test("V5: aftermathNotificationScheduled startet als false")
|
||||
func aftermathNotificationScheduledDefaultsFalse() {
|
||||
let m = Moment(text: "Test")
|
||||
#expect(!m.aftermathNotificationScheduled)
|
||||
}
|
||||
|
||||
@Test("V5: isCompleted startet als false")
|
||||
func isCompletedDefaultsFalse() {
|
||||
let m = Moment(text: "Test")
|
||||
#expect(!m.isCompleted)
|
||||
}
|
||||
|
||||
@Test("V5: reminderDate startet als nil")
|
||||
func reminderDateDefaultsNil() {
|
||||
let m = Moment(text: "Test", type: .intention)
|
||||
#expect(m.reminderDate == nil)
|
||||
}
|
||||
|
||||
@Test("V5: isMeeting true nur für .meeting")
|
||||
func isMeetingOnlyForMeetingType() {
|
||||
let meeting = Moment(text: "Test", type: .meeting)
|
||||
let other = Moment(text: "Test", type: .conversation)
|
||||
#expect(meeting.isMeeting)
|
||||
#expect(!other.isMeeting)
|
||||
}
|
||||
|
||||
@Test("V5: isIntention true nur für .intention")
|
||||
func isIntentionOnlyForIntentionType() {
|
||||
let intention = Moment(text: "Test", type: .intention)
|
||||
let other = Moment(text: "Test", type: .thought)
|
||||
#expect(intention.isIntention)
|
||||
#expect(!other.isIntention)
|
||||
}
|
||||
|
||||
@Test("V5: isOpen true wenn intention && nicht erledigt")
|
||||
func isOpenWhenNotCompleted() {
|
||||
let m = Moment(text: "Test", type: .intention)
|
||||
m.isCompleted = false
|
||||
#expect(m.isOpen)
|
||||
m.isCompleted = true
|
||||
#expect(!m.isOpen)
|
||||
}
|
||||
|
||||
@Test("V5: meetingStatus round-trip via statusRaw")
|
||||
func meetingStatusRoundTrip() {
|
||||
let m = Moment(text: "Test", type: .meeting)
|
||||
m.meetingStatus = .awaitingAftermath
|
||||
#expect(m.meetingStatus == .awaitingAftermath)
|
||||
#expect(m.statusRaw == VisitStatus.awaitingAftermath.rawValue)
|
||||
m.meetingStatus = .completed
|
||||
#expect(m.meetingStatus == .completed)
|
||||
#expect(m.isMeetingComplete)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LogEntry Tests
|
||||
|
||||
@@ -409,7 +409,7 @@ struct PersonalityEngineBehaviorTests {
|
||||
let suggestions = PersonalityEngine.sortedSuggestions(
|
||||
contacts: [],
|
||||
profile: nil,
|
||||
lastVisitDates: [:]
|
||||
lastMeetingDates: [:]
|
||||
)
|
||||
for s in suggestions {
|
||||
#expect(!s.isRecommended)
|
||||
|
||||
@@ -323,9 +323,9 @@ struct AftermathNotificationManagerTests {
|
||||
#expect(AftermathNotificationManager.actionID == "RATE_NOW")
|
||||
}
|
||||
|
||||
@Test("visitIDKey ist unveränderlich")
|
||||
func visitIDKey() {
|
||||
#expect(AftermathNotificationManager.visitIDKey == "visitID")
|
||||
@Test("momentIDKey ist unveränderlich")
|
||||
func momentIDKey() {
|
||||
#expect(AftermathNotificationManager.momentIDKey == "momentID")
|
||||
}
|
||||
|
||||
@Test("personNameKey ist unveränderlich")
|
||||
@@ -345,14 +345,27 @@ struct SchemaV4RegressionTests {
|
||||
#expect(NahbarSchemaV4.versionIdentifier.minor == 0)
|
||||
#expect(NahbarSchemaV4.versionIdentifier.patch == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 4 Schemas")
|
||||
func migrationPlanHasFourSchemas() {
|
||||
#expect(NahbarMigrationPlan.schemas.count == 4)
|
||||
// MARK: - Schema-Regressionswächter (V5)
|
||||
|
||||
@Suite("Schema – Regressionswächter V5")
|
||||
struct SchemaV5RegressionTests {
|
||||
|
||||
@Test("NahbarSchemaV5 hat Version 5.0.0")
|
||||
func schemaV5HasCorrectVersion() {
|
||||
#expect(NahbarSchemaV5.versionIdentifier.major == 5)
|
||||
#expect(NahbarSchemaV5.versionIdentifier.minor == 0)
|
||||
#expect(NahbarSchemaV5.versionIdentifier.patch == 0)
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 3 Stages")
|
||||
func migrationPlanHasThreeStages() {
|
||||
#expect(NahbarMigrationPlan.stages.count == 3)
|
||||
@Test("Migrationsplan enthält genau 5 Schemas")
|
||||
func migrationPlanHasFiveSchemas() {
|
||||
#expect(NahbarMigrationPlan.schemas.count == 5)
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 4 Stages")
|
||||
func migrationPlanHasFourStages() {
|
||||
#expect(NahbarMigrationPlan.stages.count == 4)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user