- #22: Dedizierte Kontakt-Sektion mit Telefon (Action Sheet) und E-Mail (mailto + Fallback-Alert mit Kopieren) - #28: Nudge-Intervall-Chip im Header mit Farb-Dot, relativem Zeitstempel und direktem Menu zur Anpassung; NudgeStatus-Enum + Tests - #31: KI-Analyse-Button im Kontakt-Header (oben rechts) mit MaxBadge; AIAnalysisSheet mit Auto-Start, Consent-Flow und allen Zuständen (idle/loading/result/error) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,12 +19,15 @@ struct AddPersonView: View {
|
|||||||
@State private var interests = ""
|
@State private var interests = ""
|
||||||
@State private var generalNotes = ""
|
@State private var generalNotes = ""
|
||||||
@State private var culturalBackground = ""
|
@State private var culturalBackground = ""
|
||||||
|
@State private var phoneNumber = ""
|
||||||
|
@State private var emailAddress = ""
|
||||||
@State private var hasBirthday = false
|
@State private var hasBirthday = false
|
||||||
@State private var birthday = Date()
|
@State private var birthday = Date()
|
||||||
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
||||||
|
|
||||||
@State private var showingContactPicker = false
|
@State private var showingContactPicker = false
|
||||||
@State private var importedName: String? = nil // tracks whether fields were pre-filled
|
@State private var importedName: String? = nil // tracks whether fields were pre-filled
|
||||||
|
@State private var pendingCnIdentifier: String? = nil
|
||||||
@State private var showingDeleteConfirmation = false
|
@State private var showingDeleteConfirmation = false
|
||||||
|
|
||||||
@State private var selectedPhoto: UIImage? = nil
|
@State private var selectedPhoto: UIImage? = nil
|
||||||
@@ -101,6 +104,25 @@ struct AddPersonView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kontaktdaten (Telefon + E-Mail)
|
||||||
|
formSection("Kontakt") {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
inlineField("Telefon", text: $phoneNumber)
|
||||||
|
.keyboardType(.phonePad)
|
||||||
|
RowDivider()
|
||||||
|
inlineField("E-Mail", text: $emailAddress)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontakt aktualisieren (nur im Bearbeiten-Modus)
|
||||||
|
if isEditing {
|
||||||
|
refreshContactButton
|
||||||
|
}
|
||||||
|
|
||||||
// Birthday
|
// Birthday
|
||||||
formSection("Geburtstag") {
|
formSection("Geburtstag") {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -333,6 +355,39 @@ struct AddPersonView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Vom Kontakt aktualisieren (Bearbeiten-Modus)
|
||||||
|
|
||||||
|
private var refreshContactButton: some View {
|
||||||
|
Button {
|
||||||
|
showingContactPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "arrow.clockwise.circle")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("Vom Kontakt aktualisieren")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text("Leere Felder werden aus dem Adressbuch ergänzt")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 13)
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Contact Mapping
|
// MARK: - Contact Mapping
|
||||||
|
|
||||||
private func applyContact(_ contact: CNContact) {
|
private func applyContact(_ contact: CNContact) {
|
||||||
@@ -355,6 +410,15 @@ struct AddPersonView: View {
|
|||||||
if let data = imported.photoData {
|
if let data = imported.photoData {
|
||||||
selectedPhoto = UIImage(data: data)
|
selectedPhoto = UIImage(data: data)
|
||||||
}
|
}
|
||||||
|
// Telefon und E-Mail: im Hinzufügen-Modus immer übernehmen,
|
||||||
|
// im Bearbeiten-Modus nur wenn noch leer (nicht-destruktiv)
|
||||||
|
if let phone = imported.phoneNumber, !phone.isEmpty {
|
||||||
|
if phoneNumber.isEmpty { phoneNumber = phone }
|
||||||
|
}
|
||||||
|
if let email = imported.emailAddress, !email.isEmpty {
|
||||||
|
if emailAddress.isEmpty { emailAddress = email }
|
||||||
|
}
|
||||||
|
pendingCnIdentifier = imported.cnIdentifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
@@ -403,6 +467,8 @@ struct AddPersonView: View {
|
|||||||
interests = p.interests ?? ""
|
interests = p.interests ?? ""
|
||||||
culturalBackground = p.culturalBackground ?? ""
|
culturalBackground = p.culturalBackground ?? ""
|
||||||
generalNotes = p.generalNotes ?? ""
|
generalNotes = p.generalNotes ?? ""
|
||||||
|
phoneNumber = p.phoneNumber ?? ""
|
||||||
|
emailAddress = p.emailAddress ?? ""
|
||||||
hasBirthday = p.birthday != nil
|
hasBirthday = p.birthday != nil
|
||||||
birthday = p.birthday ?? Date()
|
birthday = p.birthday ?? Date()
|
||||||
nudgeFrequency = p.nudgeFrequency
|
nudgeFrequency = p.nudgeFrequency
|
||||||
@@ -428,6 +494,9 @@ struct AddPersonView: View {
|
|||||||
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
||||||
p.birthday = hasBirthday ? birthday : nil
|
p.birthday = hasBirthday ? birthday : nil
|
||||||
p.nudgeFrequency = nudgeFrequency
|
p.nudgeFrequency = nudgeFrequency
|
||||||
|
p.phoneNumber = phoneNumber.isEmpty ? nil : phoneNumber
|
||||||
|
p.emailAddress = emailAddress.isEmpty ? nil : emailAddress
|
||||||
|
if let cn = pendingCnIdentifier { p.cnIdentifier = cn }
|
||||||
p.touch()
|
p.touch()
|
||||||
applyPhoto(newPhotoData, to: p)
|
applyPhoto(newPhotoData, to: p)
|
||||||
} else {
|
} else {
|
||||||
@@ -442,6 +511,9 @@ struct AddPersonView: View {
|
|||||||
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
|
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
|
||||||
nudgeFrequency: nudgeFrequency
|
nudgeFrequency: nudgeFrequency
|
||||||
)
|
)
|
||||||
|
person.phoneNumber = phoneNumber.isEmpty ? nil : phoneNumber
|
||||||
|
person.emailAddress = emailAddress.isEmpty ? nil : emailAddress
|
||||||
|
person.cnIdentifier = pendingCnIdentifier
|
||||||
modelContext.insert(person)
|
modelContext.insert(person)
|
||||||
applyPhoto(newPhotoData, to: person)
|
applyPhoto(newPhotoData, to: person)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ struct ContactImport {
|
|||||||
let location: String
|
let location: String
|
||||||
let birthday: Date?
|
let birthday: Date?
|
||||||
let photoData: Data?
|
let photoData: Data?
|
||||||
|
let phoneNumber: String? // primäre Telefonnummer (bevorzugt Mobil/iPhone)
|
||||||
|
let emailAddress: String? // erste verfügbare E-Mail-Adresse
|
||||||
|
let cnIdentifier: String? // stabile Apple Contacts-ID für spätere Aktualisierung
|
||||||
|
|
||||||
static func from(_ contact: CNContact) -> ContactImport {
|
static func from(_ contact: CNContact) -> ContactImport {
|
||||||
// Mittelname einbeziehen, falls vorhanden
|
// Mittelname einbeziehen, falls vorhanden
|
||||||
@@ -271,8 +274,22 @@ struct ContactImport {
|
|||||||
photoData = nil
|
photoData = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telefon: Mobil/iPhone bevorzugen, dann erste verfügbare Nummer
|
||||||
|
let mobileLabels = ["iPhone", "_$!<Mobile>!$_", "_$!<Main>!$_"]
|
||||||
|
let phoneNumber: String?
|
||||||
|
if let labeled = contact.phoneNumbers.first(where: { mobileLabels.contains($0.label ?? "") }) {
|
||||||
|
phoneNumber = labeled.value.stringValue
|
||||||
|
} else {
|
||||||
|
phoneNumber = contact.phoneNumbers.first?.value.stringValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-Mail: erste verfügbare Adresse
|
||||||
|
let emailAddress = contact.emailAddresses.first.map { $0.value as String }
|
||||||
|
|
||||||
return ContactImport(name: name, occupation: occupation, location: location,
|
return ContactImport(name: name, occupation: occupation, location: location,
|
||||||
birthday: birthdayDate, photoData: photoData)
|
birthday: birthdayDate, photoData: photoData,
|
||||||
|
phoneNumber: phoneNumber, emailAddress: emailAddress,
|
||||||
|
cnIdentifier: contact.identifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -627,6 +627,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Alle 2 Wochen" : {
|
||||||
|
"comment" : "NudgeFrequency displayLabel – biweekly chip in PersonDetailView header",
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Every 2 weeks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Alle Features freigeschaltet" : {
|
"Alle Features freigeschaltet" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -818,7 +830,6 @@
|
|||||||
},
|
},
|
||||||
"Anrufen" : {
|
"Anrufen" : {
|
||||||
"comment" : "PersonDetailView – activity suggestion: call the person",
|
"comment" : "PersonDetailView – activity suggestion: call the person",
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1510,17 +1521,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Geplanter Moment" : {
|
|
||||||
"comment" : "Notification subtitle for moment reminders",
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Planned moment"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Deine Daten gehören dir" : {
|
"Deine Daten gehören dir" : {
|
||||||
"comment" : "OnboardingPrivacyView – headline",
|
"comment" : "OnboardingPrivacyView – headline",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1923,6 +1923,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"E-Mail" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Edel & tiefgründig" : {
|
||||||
|
"comment" : "Theme tagline for Onyx",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Refined & profound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Editorial & präzise" : {
|
"Editorial & präzise" : {
|
||||||
"comment" : "Theme tagline for Ink",
|
"comment" : "Theme tagline for Ink",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2297,6 +2319,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"FaceTime" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Fällig am" : {
|
"Fällig am" : {
|
||||||
"comment" : "AddTodoView – label for due date picker",
|
"comment" : "AddTodoView – label for due date picker",
|
||||||
@@ -2579,6 +2604,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Geplanter Moment" : {
|
||||||
|
"comment" : "Notification subtitle for moment reminders",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Planned moment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Geschenkidee anzeigen" : {
|
"Geschenkidee anzeigen" : {
|
||||||
"comment" : "TodayView GiftSuggestionRow – collapsed state button",
|
"comment" : "TodayView GiftSuggestionRow – collapsed state button",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2773,6 +2809,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Glühend & intensiv" : {
|
||||||
|
"comment" : "Theme tagline for Ember",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Glowing & intense"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Gut gemacht!" : {
|
"Gut gemacht!" : {
|
||||||
"comment" : "VisitSummaryView – completion title when all done",
|
"comment" : "VisitSummaryView – completion title when all done",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3169,6 +3216,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Keine Mail-App gefunden" : {
|
||||||
|
"comment" : "PersonDetailView – alert title when no mail client is installed",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "No Mail App Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Keine Registrierung, kein Account, kein Tracking." : {
|
"Keine Registrierung, kein Account, kein Tracking." : {
|
||||||
"comment" : "OnboardingPrivacyView – no-account privacy row text",
|
"comment" : "OnboardingPrivacyView – no-account privacy row text",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3270,9 +3328,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Klar & kontrastreich" : {
|
||||||
|
"comment" : "Theme tagline for Chalk",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Clear & high-contrast"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Kontakt" : {
|
"Kontakt" : {
|
||||||
"comment" : "ShareExtensionView – contact selection section header",
|
"comment" : "ShareExtensionView – contact selection section header",
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3378,6 +3446,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Kopieren" : {
|
||||||
|
"comment" : "Generic – copy to clipboard button",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Copy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Kühl & präzise" : {
|
||||||
|
"comment" : "Theme tagline for Vapor",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Cool & precise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Kurze Frage vorab" : {
|
"Kurze Frage vorab" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -3399,6 +3489,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Leere Felder werden aus dem Adressbuch ergänzt" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Empty fields will be filled from your address book"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Leg Vorhaben an und erhalte eine Erinnerung – damit aus 'Wir müssen mal wieder…' ein echtes Treffen wird." : {
|
"Leg Vorhaben an und erhalte eine Erinnerung – damit aus 'Wir müssen mal wieder…' ein echtes Treffen wird." : {
|
||||||
"comment" : "TourCatalog – onboarding step 4 body",
|
"comment" : "TourCatalog – onboarding step 4 body",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -3835,7 +3935,6 @@
|
|||||||
},
|
},
|
||||||
"Nachricht" : {
|
"Nachricht" : {
|
||||||
"comment" : "ShareExtensionView – message text section header",
|
"comment" : "ShareExtensionView – message text section header",
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4104,6 +4203,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Natürlich & klar" : {
|
||||||
|
"comment" : "Theme tagline for Birch",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Natural & clear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Natürlich & verbunden" : {
|
"Natürlich & verbunden" : {
|
||||||
"comment" : "Theme tagline for Grove",
|
"comment" : "Theme tagline for Grove",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4420,6 +4530,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"OK" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Onboarding abschließen und App starten" : {
|
"Onboarding abschließen und App starten" : {
|
||||||
"comment" : "OnboardingPrivacyView – CTA button accessibility label",
|
"comment" : "OnboardingPrivacyView – CTA button accessibility label",
|
||||||
@@ -4756,6 +4869,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Scharf & dunkel" : {
|
||||||
|
"comment" : "Theme tagline for Flint",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Sharp & dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Schließen" : {
|
"Schließen" : {
|
||||||
"comment" : "PersonDetailView / ShareExtensionView – close button",
|
"comment" : "PersonDetailView / ShareExtensionView – close button",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5046,6 +5170,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Telefon" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Phone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Telegram" : {
|
"Telegram" : {
|
||||||
"comment" : "MomentSource.telegram raw value",
|
"comment" : "MomentSource.telegram raw value",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -5636,6 +5770,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Vom Kontakt aktualisieren" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Update from Contact"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Vom Kontakt übernehmen" : {
|
"Vom Kontakt übernehmen" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -5981,6 +6125,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"WhatsApp" : {
|
||||||
|
"comment" : "PersonDetailView – phone action sheet option (brand name, keep as-is)",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "WhatsApp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Wichtig" : {
|
"Wichtig" : {
|
||||||
"comment" : "LogbuchView swipe action – mark moment as important",
|
"comment" : "LogbuchView swipe action – mark moment as important",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ enum NudgeFrequency: String, CaseIterable, Codable {
|
|||||||
case .quarterly: return 90
|
case .quarterly: return 90
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lesbares Label für den Nudge-Chip im Header
|
||||||
|
var displayLabel: String {
|
||||||
|
switch self {
|
||||||
|
case .never: return "Nie"
|
||||||
|
case .weekly: return "Wöchentlich"
|
||||||
|
case .biweekly: return "Alle 2 Wochen"
|
||||||
|
case .monthly: return "Monatlich"
|
||||||
|
case .quarterly: return "Quartalsweise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ampelstatus des Nudge-Intervalls einer Person
|
||||||
|
enum NudgeStatus: Equatable {
|
||||||
|
case never // kein Intervall gesetzt
|
||||||
|
case ok // < 75 % des Intervalls verstrichen
|
||||||
|
case soon // 75–100 % verstrichen
|
||||||
|
case overdue // > 100 % verstrichen
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MomentType: String, Codable {
|
enum MomentType: String, Codable {
|
||||||
@@ -117,6 +136,9 @@ class Person {
|
|||||||
var interests: String?
|
var interests: String?
|
||||||
var generalNotes: String?
|
var generalNotes: String?
|
||||||
var culturalBackground: String? = nil // V6: kultureller Hintergrund
|
var culturalBackground: String? = nil // V6: kultureller Hintergrund
|
||||||
|
var phoneNumber: String? = nil // V9: primäre Telefonnummer
|
||||||
|
var emailAddress: String? = nil // V9: primäre E-Mail-Adresse
|
||||||
|
var cnIdentifier: String? = nil // V9: Apple Contacts-ID für "Vom Kontakt aktualisieren"
|
||||||
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
|
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
|
||||||
var nextStep: String?
|
var nextStep: String?
|
||||||
var nextStepCompleted: Bool = false
|
var nextStepCompleted: Bool = false
|
||||||
@@ -158,6 +180,9 @@ class Person {
|
|||||||
self.interests = interests
|
self.interests = interests
|
||||||
self.generalNotes = generalNotes
|
self.generalNotes = generalNotes
|
||||||
self.culturalBackground = culturalBackground
|
self.culturalBackground = culturalBackground
|
||||||
|
self.phoneNumber = nil
|
||||||
|
self.emailAddress = nil
|
||||||
|
self.cnIdentifier = nil
|
||||||
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
||||||
self.photoData = nil
|
self.photoData = nil
|
||||||
self.photo = nil
|
self.photo = nil
|
||||||
@@ -196,6 +221,17 @@ class Person {
|
|||||||
return Date().timeIntervalSince(createdAt) > Double(days * 86400)
|
return Date().timeIntervalSince(createdAt) > Double(days * 86400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dreistufiger Ampelstatus basierend auf verstrichener Zeit vs. Nudge-Intervall
|
||||||
|
var nudgeStatus: NudgeStatus {
|
||||||
|
guard nudgeFrequency != .never, let days = nudgeFrequency.days else { return .never }
|
||||||
|
let interval = Double(days * 86400)
|
||||||
|
let reference = lastMomentDate ?? createdAt
|
||||||
|
let elapsed = Date().timeIntervalSince(reference)
|
||||||
|
if elapsed >= interval { return .overdue }
|
||||||
|
if elapsed >= interval * 0.75 { return .soon }
|
||||||
|
return .ok
|
||||||
|
}
|
||||||
|
|
||||||
func hasBirthdayWithin(days: Int) -> Bool {
|
func hasBirthdayWithin(days: Int) -> Bool {
|
||||||
guard let birthday else { return false }
|
guard let birthday else { return false }
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
|
|||||||
@@ -640,15 +640,141 @@ enum NahbarSchemaV7: VersionedSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Schema V8 (aktuelles Schema)
|
// MARK: - Schema V8 (eingefrorener Snapshot)
|
||||||
// Referenziert die Live-Typen aus Models.swift.
|
// Exakter Zustand aller Modelle zum Zeitpunkt des V8-Deployments.
|
||||||
// Beim Hinzufügen von V9 muss V8 als eingefrorener Snapshot gesichert werden.
|
// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten
|
||||||
|
// Schema-Hash von V8-Datenbanken auf Nutzer-Geräten entsprechen.
|
||||||
//
|
//
|
||||||
// V8 fügt hinzu:
|
// V8 fügte hinzu:
|
||||||
// • Todo: reminderDate (optionale Push-Benachrichtigung)
|
// • Todo: reminderDate (optionale Push-Benachrichtigung)
|
||||||
|
|
||||||
enum NahbarSchemaV8: VersionedSchema {
|
enum NahbarSchemaV8: VersionedSchema {
|
||||||
static var versionIdentifier = Schema.Version(8, 0, 0)
|
static var versionIdentifier = Schema.Version(8, 0, 0)
|
||||||
|
static var models: [any PersistentModel.Type] {
|
||||||
|
[PersonPhoto.self, Person.self, Moment.self, LogEntry.self,
|
||||||
|
Visit.self, Rating.self, HealthSnapshot.self, Todo.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 culturalBackground: 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]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var todos: [Todo]? = []
|
||||||
|
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
|
||||||
|
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
|
||||||
|
var statusRaw: String? = nil
|
||||||
|
var aftermathNotificationScheduled: Bool = false
|
||||||
|
var aftermathCompletedAt: Date? = nil
|
||||||
|
var reminderDate: Date? = nil
|
||||||
|
var isCompleted: Bool = false
|
||||||
|
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
|
||||||
|
var moment: Moment? = 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
|
||||||
|
var moment: Moment? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Todo {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var title: String = ""
|
||||||
|
var dueDate: Date = Date()
|
||||||
|
var isCompleted: Bool = false
|
||||||
|
var completedAt: Date? = nil
|
||||||
|
var reminderDate: Date? = nil // V8-Feld
|
||||||
|
var person: Person? = nil
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schema V9 (aktuelles Schema)
|
||||||
|
// Referenziert die Live-Typen aus Models.swift.
|
||||||
|
// Beim Hinzufügen von V10 muss V9 als eingefrorener Snapshot gesichert werden.
|
||||||
|
//
|
||||||
|
// V9 fügt hinzu:
|
||||||
|
// • Person: phoneNumber, emailAddress, cnIdentifier (Kontaktdaten für direkte Aktionen)
|
||||||
|
|
||||||
|
enum NahbarSchemaV9: VersionedSchema {
|
||||||
|
static var versionIdentifier = Schema.Version(9, 0, 0)
|
||||||
static var models: [any PersistentModel.Type] {
|
static var models: [any PersistentModel.Type] {
|
||||||
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
|
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
|
||||||
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self, nahbar.Todo.self]
|
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self, nahbar.Todo.self]
|
||||||
@@ -661,7 +787,7 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
|
|||||||
static var schemas: [any VersionedSchema.Type] {
|
static var schemas: [any VersionedSchema.Type] {
|
||||||
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
||||||
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self,
|
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self,
|
||||||
NahbarSchemaV7.self, NahbarSchemaV8.self]
|
NahbarSchemaV7.self, NahbarSchemaV8.self, NahbarSchemaV9.self]
|
||||||
}
|
}
|
||||||
|
|
||||||
static var stages: [MigrationStage] {
|
static var stages: [MigrationStage] {
|
||||||
@@ -693,7 +819,11 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
|
|||||||
|
|
||||||
// V7 → V8: Todo bekommt reminderDate = nil.
|
// V7 → V8: Todo bekommt reminderDate = nil.
|
||||||
// Optionales Feld mit nil-Default → lightweight-Migration reicht aus.
|
// Optionales Feld mit nil-Default → lightweight-Migration reicht aus.
|
||||||
.lightweight(fromVersion: NahbarSchemaV7.self, toVersion: NahbarSchemaV8.self)
|
.lightweight(fromVersion: NahbarSchemaV7.self, toVersion: NahbarSchemaV8.self),
|
||||||
|
|
||||||
|
// V8 → V9: Person bekommt phoneNumber, emailAddress, cnIdentifier = nil.
|
||||||
|
// Alle drei Felder sind optional mit nil-Default → lightweight-Migration reicht aus.
|
||||||
|
.lightweight(fromVersion: NahbarSchemaV8.self, toVersion: NahbarSchemaV9.self)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,23 @@ import SwiftData
|
|||||||
import CoreData
|
import CoreData
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import UIKit
|
||||||
|
|
||||||
private let todoNotificationLogger = Logger(subsystem: "nahbar", category: "TodoNotification")
|
private let todoNotificationLogger = Logger(subsystem: "nahbar", category: "TodoNotification")
|
||||||
|
|
||||||
|
// Wiederverwendet in AIAnalysisSheet (scoped auf diese Datei)
|
||||||
|
private enum AnalysisState {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case result(AIAnalysisResult, Date)
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
struct PersonDetailView: View {
|
struct PersonDetailView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.modelContext) var modelContext
|
@Environment(\.modelContext) var modelContext
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@Environment(\.openURL) var openURL
|
||||||
@Bindable var person: Person
|
@Bindable var person: Person
|
||||||
|
|
||||||
@State private var showingAddMoment = false
|
@State private var showingAddMoment = false
|
||||||
@@ -33,13 +43,31 @@ struct PersonDetailView: View {
|
|||||||
@State private var momentPendingDelete: Moment? = nil
|
@State private var momentPendingDelete: Moment? = nil
|
||||||
@State private var showCalendarDeleteDialog = false
|
@State private var showCalendarDeleteDialog = false
|
||||||
|
|
||||||
|
// Kontakt-Aktionsblatt (Telefon)
|
||||||
|
@State private var showingPhoneActionSheet = false
|
||||||
|
|
||||||
|
// Fallback wenn keine Mail-App installiert
|
||||||
|
@State private var showingEmailFallback = false
|
||||||
|
|
||||||
@StateObject private var personalityStore = PersonalityStore.shared
|
@StateObject private var personalityStore = PersonalityStore.shared
|
||||||
|
@StateObject private var storeManager = StoreManager.shared
|
||||||
@State private var activityHint: String = ""
|
@State private var activityHint: String = ""
|
||||||
|
|
||||||
|
// KI-Analyse
|
||||||
|
@State private var showingAIAnalysis = false
|
||||||
|
@State private var showingAIPaywall = false
|
||||||
|
|
||||||
|
private var canUseAI: Bool {
|
||||||
|
storeManager.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 28) {
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
personHeader
|
personHeader
|
||||||
|
if person.phoneNumber != nil || person.emailAddress != nil {
|
||||||
|
kontaktSection
|
||||||
|
}
|
||||||
momentsSection
|
momentsSection
|
||||||
todosSection
|
todosSection
|
||||||
if !person.sortedMoments.isEmpty || !person.sortedLogEntries.isEmpty { logbuchSection }
|
if !person.sortedMoments.isEmpty || !person.sortedLogEntries.isEmpty { logbuchSection }
|
||||||
@@ -58,6 +86,7 @@ struct PersonDetailView: View {
|
|||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(theme.accent)
|
.foregroundStyle(theme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddTodo) {
|
.sheet(isPresented: $showingAddTodo) {
|
||||||
AddTodoView(person: person)
|
AddTodoView(person: person)
|
||||||
@@ -104,6 +133,12 @@ struct PersonDetailView: View {
|
|||||||
.sheet(item: $todoForEdit) { todo in
|
.sheet(item: $todoForEdit) { todo in
|
||||||
EditTodoView(todo: todo)
|
EditTodoView(todo: todo)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAIAnalysis) {
|
||||||
|
AIAnalysisSheet(person: person)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAIPaywall) {
|
||||||
|
PaywallView(targeting: .max)
|
||||||
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Moment löschen",
|
"Moment löschen",
|
||||||
isPresented: $showCalendarDeleteDialog,
|
isPresented: $showCalendarDeleteDialog,
|
||||||
@@ -123,6 +158,36 @@ struct PersonDetailView: View {
|
|||||||
} message: { _ in
|
} message: { _ in
|
||||||
Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?")
|
Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?")
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Telefon",
|
||||||
|
isPresented: $showingPhoneActionSheet,
|
||||||
|
titleVisibility: .hidden
|
||||||
|
) {
|
||||||
|
if let phone = person.phoneNumber {
|
||||||
|
let sanitized = phone.components(separatedBy: .init(charactersIn: " -()")).joined()
|
||||||
|
if let url = URL(string: "tel://\(sanitized)") {
|
||||||
|
Button("Anrufen") { openURL(url) }
|
||||||
|
}
|
||||||
|
if let url = URL(string: "sms://\(sanitized)") {
|
||||||
|
Button("Nachricht") { openURL(url) }
|
||||||
|
}
|
||||||
|
if let url = URL(string: "facetime://\(sanitized)") {
|
||||||
|
Button("FaceTime") { openURL(url) }
|
||||||
|
}
|
||||||
|
let waNumber = phone.filter { $0.isNumber }
|
||||||
|
if !waNumber.isEmpty, let url = URL(string: "https://wa.me/\(waNumber)") {
|
||||||
|
Button("WhatsApp") { openURL(url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Keine Mail-App gefunden", isPresented: $showingEmailFallback) {
|
||||||
|
Button("Kopieren") {
|
||||||
|
UIPasteboard.general.string = person.emailAddress
|
||||||
|
}
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(person.emailAddress ?? "")
|
||||||
|
}
|
||||||
// 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(
|
||||||
@@ -138,7 +203,7 @@ struct PersonDetailView: View {
|
|||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var personHeader: some View {
|
private var personHeader: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(alignment: .top, spacing: 16) {
|
||||||
PersonAvatar(person: person, size: 64)
|
PersonAvatar(person: person, size: 64)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
@@ -153,11 +218,130 @@ struct PersonDetailView: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(theme.contentSecondary)
|
.foregroundStyle(theme.contentSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if person.nudgeStatus != .never {
|
||||||
|
nudgeChip
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if canUseAI {
|
||||||
|
showingAIAnalysis = true
|
||||||
|
} else {
|
||||||
|
showingAIPaywall = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
if !storeManager.isMax {
|
||||||
|
MaxBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var nudgeChip: some View {
|
||||||
|
let status = person.nudgeStatus
|
||||||
|
let dotColor: Color = switch status {
|
||||||
|
case .overdue: .red
|
||||||
|
case .soon: .orange
|
||||||
|
default: .green
|
||||||
|
}
|
||||||
|
let reference = person.lastMomentDate ?? person.createdAt
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .full
|
||||||
|
let relativeTime = formatter.localizedString(for: reference, relativeTo: Date())
|
||||||
|
|
||||||
|
return Menu {
|
||||||
|
ForEach(NudgeFrequency.allCases, id: \.self) { freq in
|
||||||
|
Button {
|
||||||
|
person.nudgeFrequency = freq
|
||||||
|
person.touch()
|
||||||
|
try? modelContext.save()
|
||||||
|
} label: {
|
||||||
|
if freq == person.nudgeFrequency {
|
||||||
|
Label(freq.displayLabel, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(freq.displayLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle()
|
||||||
|
.fill(dotColor)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text(person.nudgeFrequency.displayLabel)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
Text(relativeTime)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kontakt
|
||||||
|
|
||||||
|
private var kontaktSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
SectionHeader(title: "Kontakt", icon: "phone")
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if let phone = person.phoneNumber {
|
||||||
|
Button { showingPhoneActionSheet = true } label: {
|
||||||
|
kontaktRow(label: "Telefon", value: phone, icon: "phone.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
if person.emailAddress != nil { RowDivider() }
|
||||||
|
}
|
||||||
|
if let email = person.emailAddress {
|
||||||
|
Button {
|
||||||
|
if let url = URL(string: "mailto:\(email)") {
|
||||||
|
openURL(url) { accepted in
|
||||||
|
if !accepted { showingEmailFallback = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
kontaktRow(label: "E-Mail", value: email, icon: "envelope.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kontaktRow(label: String, value: String, icon: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.frame(width: 88, alignment: .leading)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Momente
|
// MARK: - Momente
|
||||||
|
|
||||||
private var momentsSection: some View {
|
private var momentsSection: some View {
|
||||||
@@ -1286,6 +1470,229 @@ struct EditTodoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Analysis Sheet
|
||||||
|
|
||||||
|
private struct AIAnalysisSheet: View {
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@StateObject private var store = StoreManager.shared
|
||||||
|
let person: Person
|
||||||
|
|
||||||
|
@State private var analysisState: AnalysisState = .idle
|
||||||
|
@State private var showAIConsent = false
|
||||||
|
@State private var showPaywall = false
|
||||||
|
@State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests
|
||||||
|
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
|
||||||
|
|
||||||
|
private var canUseAI: Bool {
|
||||||
|
store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Header mit MAX-Badge
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
SectionHeader(title: "KI-Auswertung", icon: "sparkles")
|
||||||
|
MaxBadge()
|
||||||
|
if !store.isMax && canUseAI {
|
||||||
|
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(theme.backgroundSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inhalt
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch analysisState {
|
||||||
|
case .idle:
|
||||||
|
Button {
|
||||||
|
if aiConsentGiven {
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
} else {
|
||||||
|
showAIConsent = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
Text("\(person.firstName) analysieren")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .loading:
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProgressView().tint(theme.accent)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Analysiere Logbuch…")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text("Das kann bis zu einer Minute dauern.")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
|
||||||
|
case .result(let result, let date):
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
analysisSection(icon: "waveform.path", title: "Muster & Themen", text: result.patterns)
|
||||||
|
RowDivider()
|
||||||
|
analysisSection(icon: "person.2", title: "Beziehungsqualität", text: result.relationship)
|
||||||
|
RowDivider()
|
||||||
|
analysisSection(icon: "arrow.right.circle", title: "Empfehlung", text: result.recommendation)
|
||||||
|
RowDivider()
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("Analysiert")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
Text(date.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale.current)))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text(remainingRequests > 0
|
||||||
|
? "Aktualisieren (\(remainingRequests))"
|
||||||
|
: "Limit erreicht")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
.foregroundStyle(remainingRequests > 0 ? theme.accent : theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.disabled(remainingRequests == 0 || isAnalyzing)
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .error(let msg):
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Analyse fehlgeschlagen", systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text(msg)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
Button {
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
} label: {
|
||||||
|
Text("Erneut versuchen")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
|
.navigationTitle("KI-Analyse")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.themedNavBar()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Schließen") { dismiss() }
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAIConsent) {
|
||||||
|
AIConsentSheet {
|
||||||
|
aiConsentGiven = true
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPaywall) {
|
||||||
|
PaywallView(targeting: .max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Cache laden
|
||||||
|
if let cached = AIAnalysisService.shared.loadCached(for: person) {
|
||||||
|
analysisState = .result(cached.asResult, cached.analyzedAt)
|
||||||
|
}
|
||||||
|
remainingRequests = AIAnalysisService.shared.remainingRequests
|
||||||
|
|
||||||
|
// Auto-start: kein Cache → direkt starten wenn möglich
|
||||||
|
if case .idle = analysisState {
|
||||||
|
if canUseAI && aiConsentGiven {
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
} else if canUseAI && !aiConsentGiven {
|
||||||
|
showAIConsent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func analysisSection(icon: String, title: String, text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
.frame(width: 20)
|
||||||
|
.padding(.top, 2)
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text(LocalizedStringKey(text))
|
||||||
|
.font(.system(size: 14, design: theme.displayDesign))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isAnalyzing: Bool {
|
||||||
|
if case .loading = analysisState { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAnalysis() async {
|
||||||
|
guard !AIAnalysisService.shared.isRateLimited else { return }
|
||||||
|
analysisState = .loading
|
||||||
|
do {
|
||||||
|
let result = try await AIAnalysisService.shared.analyze(person: person)
|
||||||
|
remainingRequests = AIAnalysisService.shared.remainingRequests
|
||||||
|
if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
|
||||||
|
analysisState = .result(result, Date())
|
||||||
|
} catch {
|
||||||
|
if let cached = AIAnalysisService.shared.loadCached(for: person) {
|
||||||
|
analysisState = .result(cached.asResult, cached.analyzedAt)
|
||||||
|
} else {
|
||||||
|
analysisState = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Info Row
|
// MARK: - Info Row
|
||||||
|
|
||||||
struct InfoRowView: View {
|
struct InfoRowView: View {
|
||||||
|
|||||||
@@ -284,13 +284,13 @@ struct SectionHeader: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: theme.sectionHeaderSize, weight: .medium))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.sectionHeaderColor)
|
||||||
Text(title)
|
Text(title)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.system(size: theme.sectionHeaderSize, weight: .semibold))
|
||||||
.tracking(0.8)
|
.tracking(0.8)
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.sectionHeaderColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import SwiftUI
|
|||||||
enum ThemeID: String, CaseIterable, Codable {
|
enum ThemeID: String, CaseIterable, Codable {
|
||||||
case linen, slate, mist, grove, ink, copper
|
case linen, slate, mist, grove, ink, copper
|
||||||
case abyss, dusk, basalt
|
case abyss, dusk, basalt
|
||||||
|
case chalk, flint
|
||||||
|
case onyx, ember, birch, vapor
|
||||||
|
|
||||||
var isPremium: Bool {
|
var isPremium: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .linen, .slate, .mist: return false
|
case .linen, .slate, .mist, .chalk, .flint: return false
|
||||||
default: return true
|
default: return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDark: Bool {
|
var isDark: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .copper, .abyss, .dusk, .basalt: return true
|
case .copper, .abyss, .dusk, .basalt, .flint, .onyx, .ember: return true
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,12 @@ enum ThemeID: String, CaseIterable, Codable {
|
|||||||
case .abyss: return "Abyss"
|
case .abyss: return "Abyss"
|
||||||
case .dusk: return "Dusk"
|
case .dusk: return "Dusk"
|
||||||
case .basalt: return "Basalt"
|
case .basalt: return "Basalt"
|
||||||
|
case .chalk: return "Chalk"
|
||||||
|
case .flint: return "Flint"
|
||||||
|
case .onyx: return "Onyx"
|
||||||
|
case .ember: return "Ember"
|
||||||
|
case .birch: return "Birch"
|
||||||
|
case .vapor: return "Vapor"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +60,12 @@ enum ThemeID: String, CaseIterable, Codable {
|
|||||||
case .abyss: return "Tief & fokussiert · ND"
|
case .abyss: return "Tief & fokussiert · ND"
|
||||||
case .dusk: return "Warm & augenschonend · ND"
|
case .dusk: return "Warm & augenschonend · ND"
|
||||||
case .basalt: return "Neutral & reizarm · ND"
|
case .basalt: return "Neutral & reizarm · ND"
|
||||||
|
case .chalk: return "Klar & kontrastreich"
|
||||||
|
case .flint: return "Scharf & dunkel"
|
||||||
|
case .onyx: return "Edel & tiefgründig"
|
||||||
|
case .ember: return "Glühend & intensiv"
|
||||||
|
case .birch: return "Natürlich & klar"
|
||||||
|
case .vapor: return "Kühl & präzise"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +90,10 @@ struct NahbarTheme {
|
|||||||
// Typography
|
// Typography
|
||||||
let displayDesign: Font.Design
|
let displayDesign: Font.Design
|
||||||
|
|
||||||
|
// Section Headers
|
||||||
|
let sectionHeaderSize: CGFloat
|
||||||
|
let sectionHeaderColor: Color
|
||||||
|
|
||||||
// Shape
|
// Shape
|
||||||
let radiusCard: CGFloat
|
let radiusCard: CGFloat
|
||||||
let radiusTag: CGFloat
|
let radiusTag: CGFloat
|
||||||
@@ -99,6 +117,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.620, green: 0.561, blue: 0.494),
|
contentTertiary: Color(red: 0.620, green: 0.561, blue: 0.494),
|
||||||
accent: Color(red: 0.710, green: 0.443, blue: 0.290),
|
accent: Color(red: 0.710, green: 0.443, blue: 0.290),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.447, green: 0.384, blue: 0.318),
|
||||||
radiusCard: 16,
|
radiusCard: 16,
|
||||||
radiusTag: 8,
|
radiusTag: 8,
|
||||||
reducedMotion: false
|
reducedMotion: false
|
||||||
@@ -115,6 +135,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.557, green: 0.596, blue: 0.643),
|
contentTertiary: Color(red: 0.557, green: 0.596, blue: 0.643),
|
||||||
accent: Color(red: 0.239, green: 0.353, blue: 0.945),
|
accent: Color(red: 0.239, green: 0.353, blue: 0.945),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.353, green: 0.388, blue: 0.431),
|
||||||
radiusCard: 12,
|
radiusCard: 12,
|
||||||
radiusTag: 6,
|
radiusTag: 6,
|
||||||
reducedMotion: false
|
reducedMotion: false
|
||||||
@@ -131,6 +153,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.651, green: 0.651, blue: 0.671),
|
contentTertiary: Color(red: 0.651, green: 0.651, blue: 0.671),
|
||||||
accent: Color(red: 0.569, green: 0.541, blue: 0.745),
|
accent: Color(red: 0.569, green: 0.541, blue: 0.745),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.455, green: 0.455, blue: 0.475),
|
||||||
radiusCard: 20,
|
radiusCard: 20,
|
||||||
radiusTag: 10,
|
radiusTag: 10,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
@@ -147,6 +171,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.467, green: 0.573, blue: 0.455),
|
contentTertiary: Color(red: 0.467, green: 0.573, blue: 0.455),
|
||||||
accent: Color(red: 0.220, green: 0.412, blue: 0.227),
|
accent: Color(red: 0.220, green: 0.412, blue: 0.227),
|
||||||
displayDesign: .serif,
|
displayDesign: .serif,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.298, green: 0.408, blue: 0.298),
|
||||||
radiusCard: 16,
|
radiusCard: 16,
|
||||||
radiusTag: 8,
|
radiusTag: 8,
|
||||||
reducedMotion: false
|
reducedMotion: false
|
||||||
@@ -163,6 +189,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.541, green: 0.541, blue: 0.541),
|
contentTertiary: Color(red: 0.541, green: 0.541, blue: 0.541),
|
||||||
accent: Color(red: 0.749, green: 0.220, blue: 0.165),
|
accent: Color(red: 0.749, green: 0.220, blue: 0.165),
|
||||||
displayDesign: .serif,
|
displayDesign: .serif,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.310, green: 0.310, blue: 0.310),
|
||||||
radiusCard: 8,
|
radiusCard: 8,
|
||||||
radiusTag: 4,
|
radiusTag: 4,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
@@ -179,6 +207,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.502, green: 0.443, blue: 0.373),
|
contentTertiary: Color(red: 0.502, green: 0.443, blue: 0.373),
|
||||||
accent: Color(red: 0.784, green: 0.514, blue: 0.227),
|
accent: Color(red: 0.784, green: 0.514, blue: 0.227),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.714, green: 0.659, blue: 0.588),
|
||||||
radiusCard: 16,
|
radiusCard: 16,
|
||||||
radiusTag: 8,
|
radiusTag: 8,
|
||||||
reducedMotion: false
|
reducedMotion: false
|
||||||
@@ -196,6 +226,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.349, green: 0.408, blue: 0.502),
|
contentTertiary: Color(red: 0.349, green: 0.408, blue: 0.502),
|
||||||
accent: Color(red: 0.357, green: 0.553, blue: 0.937),
|
accent: Color(red: 0.357, green: 0.553, blue: 0.937),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.541, green: 0.612, blue: 0.710),
|
||||||
radiusCard: 14,
|
radiusCard: 14,
|
||||||
radiusTag: 7,
|
radiusTag: 7,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
@@ -213,6 +245,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.431, green: 0.357, blue: 0.271),
|
contentTertiary: Color(red: 0.431, green: 0.357, blue: 0.271),
|
||||||
accent: Color(red: 0.831, green: 0.573, blue: 0.271),
|
accent: Color(red: 0.831, green: 0.573, blue: 0.271),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.651, green: 0.565, blue: 0.451),
|
||||||
radiusCard: 18,
|
radiusCard: 18,
|
||||||
radiusTag: 9,
|
radiusTag: 9,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
@@ -230,11 +264,127 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.365, green: 0.365, blue: 0.365),
|
contentTertiary: Color(red: 0.365, green: 0.365, blue: 0.365),
|
||||||
accent: Color(red: 0.376, green: 0.725, blue: 0.545),
|
accent: Color(red: 0.376, green: 0.725, blue: 0.545),
|
||||||
displayDesign: .monospaced,
|
displayDesign: .monospaced,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.561, green: 0.561, blue: 0.561),
|
||||||
radiusCard: 10,
|
radiusCard: 10,
|
||||||
radiusTag: 5,
|
radiusTag: 5,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MARK: - Chalk (Hochkontrast Hell, kostenlos)
|
||||||
|
static let chalk = NahbarTheme(
|
||||||
|
id: .chalk,
|
||||||
|
backgroundPrimary: Color(red: 0.976, green: 0.976, blue: 0.976),
|
||||||
|
backgroundSecondary: Color(red: 0.945, green: 0.945, blue: 0.945),
|
||||||
|
surfaceCard: Color(red: 1.000, green: 1.000, blue: 1.000),
|
||||||
|
borderSubtle: Color(red: 0.690, green: 0.690, blue: 0.690).opacity(0.50),
|
||||||
|
contentPrimary: Color(red: 0.059, green: 0.059, blue: 0.059),
|
||||||
|
contentSecondary: Color(red: 0.267, green: 0.267, blue: 0.267),
|
||||||
|
contentTertiary: Color(red: 0.482, green: 0.482, blue: 0.482),
|
||||||
|
accent: Color(red: 0.196, green: 0.392, blue: 0.902),
|
||||||
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.267, green: 0.267, blue: 0.267),
|
||||||
|
radiusCard: 12,
|
||||||
|
radiusTag: 6,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Flint (Hochkontrast Dunkel, kostenlos)
|
||||||
|
static let flint = NahbarTheme(
|
||||||
|
id: .flint,
|
||||||
|
backgroundPrimary: Color(red: 0.102, green: 0.102, blue: 0.102),
|
||||||
|
backgroundSecondary: Color(red: 0.137, green: 0.137, blue: 0.137),
|
||||||
|
surfaceCard: Color(red: 0.173, green: 0.173, blue: 0.173),
|
||||||
|
borderSubtle: Color(red: 0.376, green: 0.376, blue: 0.376).opacity(0.50),
|
||||||
|
contentPrimary: Color(red: 0.941, green: 0.941, blue: 0.941),
|
||||||
|
contentSecondary: Color(red: 0.651, green: 0.651, blue: 0.651),
|
||||||
|
contentTertiary: Color(red: 0.416, green: 0.416, blue: 0.416),
|
||||||
|
accent: Color(red: 0.220, green: 0.820, blue: 0.796),
|
||||||
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.651, green: 0.651, blue: 0.651),
|
||||||
|
radiusCard: 12,
|
||||||
|
radiusTag: 6,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Onyx (Tiefes Schwarz, Gold-Serif, bezahlt)
|
||||||
|
static let onyx = NahbarTheme(
|
||||||
|
id: .onyx,
|
||||||
|
backgroundPrimary: Color(red: 0.063, green: 0.063, blue: 0.063),
|
||||||
|
backgroundSecondary: Color(red: 0.094, green: 0.094, blue: 0.094),
|
||||||
|
surfaceCard: Color(red: 0.125, green: 0.125, blue: 0.125),
|
||||||
|
borderSubtle: Color(red: 0.310, green: 0.255, blue: 0.176).opacity(0.50),
|
||||||
|
contentPrimary: Color(red: 0.965, green: 0.949, blue: 0.922),
|
||||||
|
contentSecondary: Color(red: 0.647, green: 0.612, blue: 0.557),
|
||||||
|
contentTertiary: Color(red: 0.412, green: 0.380, blue: 0.333),
|
||||||
|
accent: Color(red: 0.835, green: 0.682, blue: 0.286),
|
||||||
|
displayDesign: .serif,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.647, green: 0.612, blue: 0.557),
|
||||||
|
radiusCard: 14,
|
||||||
|
radiusTag: 7,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Ember (Warmes Dunkel, Orangerot, bezahlt)
|
||||||
|
static let ember = NahbarTheme(
|
||||||
|
id: .ember,
|
||||||
|
backgroundPrimary: Color(red: 0.110, green: 0.086, blue: 0.078),
|
||||||
|
backgroundSecondary: Color(red: 0.145, green: 0.114, blue: 0.102),
|
||||||
|
surfaceCard: Color(red: 0.184, green: 0.149, blue: 0.133),
|
||||||
|
borderSubtle: Color(red: 0.392, green: 0.263, blue: 0.212).opacity(0.50),
|
||||||
|
contentPrimary: Color(red: 0.957, green: 0.918, blue: 0.882),
|
||||||
|
contentSecondary: Color(red: 0.671, green: 0.561, blue: 0.494),
|
||||||
|
contentTertiary: Color(red: 0.435, green: 0.349, blue: 0.298),
|
||||||
|
accent: Color(red: 0.910, green: 0.388, blue: 0.192),
|
||||||
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.671, green: 0.561, blue: 0.494),
|
||||||
|
radiusCard: 16,
|
||||||
|
radiusTag: 8,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Birch (Helles Naturcreme, Waldgrün-Serif, bezahlt)
|
||||||
|
static let birch = NahbarTheme(
|
||||||
|
id: .birch,
|
||||||
|
backgroundPrimary: Color(red: 0.969, green: 0.961, blue: 0.945),
|
||||||
|
backgroundSecondary: Color(red: 0.937, green: 0.925, blue: 0.906),
|
||||||
|
surfaceCard: Color(red: 0.988, green: 0.984, blue: 0.969),
|
||||||
|
borderSubtle: Color(red: 0.682, green: 0.659, blue: 0.612).opacity(0.40),
|
||||||
|
contentPrimary: Color(red: 0.067, green: 0.133, blue: 0.071),
|
||||||
|
contentSecondary: Color(red: 0.227, green: 0.349, blue: 0.224),
|
||||||
|
contentTertiary: Color(red: 0.408, green: 0.502, blue: 0.396),
|
||||||
|
accent: Color(red: 0.118, green: 0.392, blue: 0.153),
|
||||||
|
displayDesign: .serif,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.227, green: 0.349, blue: 0.224),
|
||||||
|
radiusCard: 16,
|
||||||
|
radiusTag: 8,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Vapor (Kühles Weiß, Tintenblau, Violett, bezahlt)
|
||||||
|
static let vapor = NahbarTheme(
|
||||||
|
id: .vapor,
|
||||||
|
backgroundPrimary: Color(red: 0.961, green: 0.965, blue: 0.976),
|
||||||
|
backgroundSecondary: Color(red: 0.925, green: 0.933, blue: 0.953),
|
||||||
|
surfaceCard: Color(red: 0.988, green: 0.988, blue: 1.000),
|
||||||
|
borderSubtle: Color(red: 0.647, green: 0.671, blue: 0.737).opacity(0.45),
|
||||||
|
contentPrimary: Color(red: 0.047, green: 0.063, blue: 0.145),
|
||||||
|
contentSecondary: Color(red: 0.275, green: 0.306, blue: 0.447),
|
||||||
|
contentTertiary: Color(red: 0.478, green: 0.506, blue: 0.616),
|
||||||
|
accent: Color(red: 0.455, green: 0.255, blue: 0.855),
|
||||||
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.275, green: 0.306, blue: 0.447),
|
||||||
|
radiusCard: 14,
|
||||||
|
radiusTag: 7,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
static func theme(for id: ThemeID) -> NahbarTheme {
|
static func theme(for id: ThemeID) -> NahbarTheme {
|
||||||
switch id {
|
switch id {
|
||||||
case .linen: return .linen
|
case .linen: return .linen
|
||||||
@@ -246,6 +396,12 @@ extension NahbarTheme {
|
|||||||
case .abyss: return .abyss
|
case .abyss: return .abyss
|
||||||
case .dusk: return .dusk
|
case .dusk: return .dusk
|
||||||
case .basalt: return .basalt
|
case .basalt: return .basalt
|
||||||
|
case .chalk: return .chalk
|
||||||
|
case .flint: return .flint
|
||||||
|
case .onyx: return .onyx
|
||||||
|
case .ember: return .ember
|
||||||
|
case .birch: return .birch
|
||||||
|
case .vapor: return .vapor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,14 +155,14 @@ struct SchemaRegressionTests {
|
|||||||
#expect(NahbarSchemaV3.versionIdentifier.patch == 0)
|
#expect(NahbarSchemaV3.versionIdentifier.patch == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migrationsplan enthält genau 8 Schemas (V1–V8)")
|
@Test("Migrationsplan enthält genau 9 Schemas (V1–V9)")
|
||||||
func migrationPlanHasEightSchemas() {
|
func migrationPlanHasNineSchemas() {
|
||||||
#expect(NahbarMigrationPlan.schemas.count == 8)
|
#expect(NahbarMigrationPlan.schemas.count == 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)")
|
@Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)")
|
||||||
func migrationPlanHasSevenStages() {
|
func migrationPlanHasEightStages() {
|
||||||
#expect(NahbarMigrationPlan.stages.count == 7)
|
#expect(NahbarMigrationPlan.stages.count == 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ContainerFallback-Gleichheit funktioniert korrekt")
|
@Test("ContainerFallback-Gleichheit funktioniert korrekt")
|
||||||
|
|||||||
@@ -43,6 +43,80 @@ struct NudgeFrequencyTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("displayLabel ist nicht leer für alle Fälle")
|
||||||
|
func displayLabelNotEmpty() {
|
||||||
|
for freq in NudgeFrequency.allCases {
|
||||||
|
#expect(!freq.displayLabel.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("biweekly displayLabel enthält '2 Wochen'")
|
||||||
|
func biweeklyDisplayLabel() {
|
||||||
|
#expect(NudgeFrequency.biweekly.displayLabel.contains("2 Wochen"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NudgeStatus Tests
|
||||||
|
|
||||||
|
@Suite("NudgeStatus")
|
||||||
|
struct NudgeStatusTests {
|
||||||
|
|
||||||
|
private func makePerson(frequency: NudgeFrequency, lastContact: Date?) -> Person {
|
||||||
|
let p = Person(name: "Test", tag: .friends)
|
||||||
|
p.nudgeFrequency = frequency
|
||||||
|
// lastMomentDate ist computed aus moments — wir simulieren via createdAt
|
||||||
|
// Wir setzen createdAt auf den gewünschten Referenzzeitpunkt
|
||||||
|
if let date = lastContact {
|
||||||
|
p.createdAt = date
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("never → .never Status")
|
||||||
|
func neverFrequencyReturnsNever() {
|
||||||
|
let p = makePerson(frequency: .never, lastContact: nil)
|
||||||
|
#expect(p.nudgeStatus == .never)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("kürzlicher Kontakt → .ok")
|
||||||
|
func recentContactReturnsOk() {
|
||||||
|
// Letzte Aktivität: gestern → weit unter 75 % des monatlichen Intervalls
|
||||||
|
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())!
|
||||||
|
let p = makePerson(frequency: .monthly, lastContact: yesterday)
|
||||||
|
#expect(p.nudgeStatus == .ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("75–100 % des Intervalls → .soon")
|
||||||
|
func approachingDeadlineReturnsSoon() {
|
||||||
|
// 25 Tage her bei monatlichem Intervall (30 Tage) = 83 %
|
||||||
|
let almostDue = Calendar.current.date(byAdding: .day, value: -25, to: Date())!
|
||||||
|
let p = makePerson(frequency: .monthly, lastContact: almostDue)
|
||||||
|
#expect(p.nudgeStatus == .soon)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("über 100 % des Intervalls → .overdue")
|
||||||
|
func overdueReturnsOverdue() {
|
||||||
|
// 40 Tage her bei monatlichem Intervall (30 Tage)
|
||||||
|
let tooLong = Calendar.current.date(byAdding: .day, value: -40, to: Date())!
|
||||||
|
let p = makePerson(frequency: .monthly, lastContact: tooLong)
|
||||||
|
#expect(p.nudgeStatus == .overdue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("wöchentlich + 8 Tage her → .overdue")
|
||||||
|
func weeklyOverdue() {
|
||||||
|
let eightDaysAgo = Calendar.current.date(byAdding: .day, value: -8, to: Date())!
|
||||||
|
let p = makePerson(frequency: .weekly, lastContact: eightDaysAgo)
|
||||||
|
#expect(p.nudgeStatus == .overdue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("nudgeStatus stimmt mit needsAttention überein wenn overdue")
|
||||||
|
func nudgeStatusConsistentWithNeedsAttention() {
|
||||||
|
let tooLong = Calendar.current.date(byAdding: .day, value: -40, to: Date())!
|
||||||
|
let p = makePerson(frequency: .monthly, lastContact: tooLong)
|
||||||
|
#expect(p.nudgeStatus == .overdue)
|
||||||
|
#expect(p.needsAttention == true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PersonTag Tests
|
// MARK: - PersonTag Tests
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
// MARK: - ThemeID Enum Tests
|
||||||
|
|
||||||
|
@Suite("ThemeID – Enum")
|
||||||
|
struct ThemeIDTests {
|
||||||
|
|
||||||
|
@Test("Genau 15 Themes vorhanden")
|
||||||
|
func allCasesCount() {
|
||||||
|
#expect(ThemeID.allCases.count == 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rawValues sind einzigartig")
|
||||||
|
func rawValuesAreUnique() {
|
||||||
|
let values = ThemeID.allCases.map { $0.rawValue }
|
||||||
|
#expect(Set(values).count == ThemeID.allCases.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("displayNames sind einzigartig")
|
||||||
|
func displayNamesAreUnique() {
|
||||||
|
let names = ThemeID.allCases.map { $0.displayName }
|
||||||
|
#expect(Set(names).count == ThemeID.allCases.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("displayNames sind nicht leer")
|
||||||
|
func displayNamesNotEmpty() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
#expect(!id.displayName.isEmpty, "displayName für \(id.rawValue) ist leer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Genau 5 kostenlose Themes")
|
||||||
|
func freeThemesCount() {
|
||||||
|
let free = ThemeID.allCases.filter { !$0.isPremium }
|
||||||
|
#expect(free.count == 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Kostenlose Themes: linen, slate, mist, chalk, flint")
|
||||||
|
func freeThemeIdentities() {
|
||||||
|
let free = Set(ThemeID.allCases.filter { !$0.isPremium })
|
||||||
|
#expect(free == [.linen, .slate, .mist, .chalk, .flint])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Genau 10 bezahlte Themes")
|
||||||
|
func premiumThemesCount() {
|
||||||
|
let premium = ThemeID.allCases.filter { $0.isPremium }
|
||||||
|
#expect(premium.count == 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isDark: flint, copper, onyx, ember, abyss, dusk, basalt sind dunkel")
|
||||||
|
func darkThemes() {
|
||||||
|
let dark = Set(ThemeID.allCases.filter { $0.isDark })
|
||||||
|
#expect(dark == [.flint, .copper, .onyx, .ember, .abyss, .dusk, .basalt])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isNeurodiverseFocused: nur abyss, dusk, basalt")
|
||||||
|
func ndThemes() {
|
||||||
|
let nd = Set(ThemeID.allCases.filter { $0.isNeurodiverseFocused })
|
||||||
|
#expect(nd == [.abyss, .dusk, .basalt])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ND-Themes sind alle dunkel")
|
||||||
|
func ndThemesAreDark() {
|
||||||
|
for id in ThemeID.allCases where id.isNeurodiverseFocused {
|
||||||
|
#expect(id.isDark, "\(id.rawValue) ist ND aber nicht dunkel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Neue Hochkontrast-Themes sind nicht ND-fokussiert")
|
||||||
|
func highContrastThemesNotND() {
|
||||||
|
let highContrast: [ThemeID] = [.chalk, .flint, .onyx, .ember, .birch, .vapor]
|
||||||
|
for id in highContrast {
|
||||||
|
#expect(!id.isNeurodiverseFocused, "\(id.rawValue) sollte nicht ND-fokussiert sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NahbarTheme Token Tests
|
||||||
|
|
||||||
|
@Suite("NahbarTheme – Tokens")
|
||||||
|
struct NahbarThemeTokenTests {
|
||||||
|
|
||||||
|
@Test("theme(for:) gibt für jeden ThemeID ein Theme zurück")
|
||||||
|
func themeForAllIDs() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.id == id, "theme(for: \(id.rawValue)).id stimmt nicht überein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("sectionHeaderSize ist positiv für alle Themes")
|
||||||
|
func sectionHeaderSizePositive() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.sectionHeaderSize > 0, "sectionHeaderSize für \(id.rawValue) ist nicht positiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alle Themes haben sectionHeaderSize 13")
|
||||||
|
func allThemesHeaderSize() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.sectionHeaderSize == 13, "\(id.rawValue) sectionHeaderSize sollte 13 sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("radiusCard ist positiv für alle Themes")
|
||||||
|
func radiusCardPositive() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.radiusCard > 0, "radiusCard für \(id.rawValue) ist nicht positiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("radiusTag ist positiv für alle Themes")
|
||||||
|
func radiusTagPositive() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.radiusTag > 0, "radiusTag für \(id.rawValue) ist nicht positiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -359,13 +359,13 @@ struct SchemaV5RegressionTests {
|
|||||||
#expect(NahbarSchemaV5.versionIdentifier.patch == 0)
|
#expect(NahbarSchemaV5.versionIdentifier.patch == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migrationsplan enthält genau 8 Schemas (V1–V8)")
|
@Test("Migrationsplan enthält genau 9 Schemas (V1–V9)")
|
||||||
func migrationPlanHasEightSchemas() {
|
func migrationPlanHasNineSchemas() {
|
||||||
#expect(NahbarMigrationPlan.schemas.count == 8)
|
#expect(NahbarMigrationPlan.schemas.count == 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)")
|
@Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)")
|
||||||
func migrationPlanHasSevenStages() {
|
func migrationPlanHasEightStages() {
|
||||||
#expect(NahbarMigrationPlan.stages.count == 7)
|
#expect(NahbarMigrationPlan.stages.count == 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user