- #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 generalNotes = ""
|
||||
@State private var culturalBackground = ""
|
||||
@State private var phoneNumber = ""
|
||||
@State private var emailAddress = ""
|
||||
@State private var hasBirthday = false
|
||||
@State private var birthday = Date()
|
||||
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
||||
|
||||
@State private var showingContactPicker = false
|
||||
@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 selectedPhoto: UIImage? = nil
|
||||
@@ -101,6 +104,25 @@ struct AddPersonView: View {
|
||||
.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
|
||||
formSection("Geburtstag") {
|
||||
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
|
||||
|
||||
private func applyContact(_ contact: CNContact) {
|
||||
@@ -355,6 +410,15 @@ struct AddPersonView: View {
|
||||
if let data = imported.photoData {
|
||||
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
|
||||
@@ -403,6 +467,8 @@ struct AddPersonView: View {
|
||||
interests = p.interests ?? ""
|
||||
culturalBackground = p.culturalBackground ?? ""
|
||||
generalNotes = p.generalNotes ?? ""
|
||||
phoneNumber = p.phoneNumber ?? ""
|
||||
emailAddress = p.emailAddress ?? ""
|
||||
hasBirthday = p.birthday != nil
|
||||
birthday = p.birthday ?? Date()
|
||||
nudgeFrequency = p.nudgeFrequency
|
||||
@@ -428,6 +494,9 @@ struct AddPersonView: View {
|
||||
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
||||
p.birthday = hasBirthday ? birthday : nil
|
||||
p.nudgeFrequency = nudgeFrequency
|
||||
p.phoneNumber = phoneNumber.isEmpty ? nil : phoneNumber
|
||||
p.emailAddress = emailAddress.isEmpty ? nil : emailAddress
|
||||
if let cn = pendingCnIdentifier { p.cnIdentifier = cn }
|
||||
p.touch()
|
||||
applyPhoto(newPhotoData, to: p)
|
||||
} else {
|
||||
@@ -442,6 +511,9 @@ struct AddPersonView: View {
|
||||
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
|
||||
nudgeFrequency: nudgeFrequency
|
||||
)
|
||||
person.phoneNumber = phoneNumber.isEmpty ? nil : phoneNumber
|
||||
person.emailAddress = emailAddress.isEmpty ? nil : emailAddress
|
||||
person.cnIdentifier = pendingCnIdentifier
|
||||
modelContext.insert(person)
|
||||
applyPhoto(newPhotoData, to: person)
|
||||
}
|
||||
|
||||
@@ -216,6 +216,9 @@ struct ContactImport {
|
||||
let location: String
|
||||
let birthday: Date?
|
||||
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 {
|
||||
// Mittelname einbeziehen, falls vorhanden
|
||||
@@ -271,8 +274,22 @@ struct ContactImport {
|
||||
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,
|
||||
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" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -818,7 +830,6 @@
|
||||
},
|
||||
"Anrufen" : {
|
||||
"comment" : "PersonDetailView – activity suggestion: call the person",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"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" : {
|
||||
"comment" : "OnboardingPrivacyView – headline",
|
||||
"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" : {
|
||||
"comment" : "Theme tagline for Ink",
|
||||
"localizations" : {
|
||||
@@ -2297,6 +2319,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FaceTime" : {
|
||||
|
||||
},
|
||||
"Fällig am" : {
|
||||
"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" : {
|
||||
"comment" : "TodayView GiftSuggestionRow – collapsed state button",
|
||||
"localizations" : {
|
||||
@@ -2773,6 +2809,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Glühend & intensiv" : {
|
||||
"comment" : "Theme tagline for Ember",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Glowing & intense"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Gut gemacht!" : {
|
||||
"comment" : "VisitSummaryView – completion title when all done",
|
||||
"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." : {
|
||||
"comment" : "OnboardingPrivacyView – no-account privacy row text",
|
||||
"localizations" : {
|
||||
@@ -3270,9 +3328,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Klar & kontrastreich" : {
|
||||
"comment" : "Theme tagline for Chalk",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Clear & high-contrast"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Kontakt" : {
|
||||
"comment" : "ShareExtensionView – contact selection section header",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"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." : {
|
||||
"comment" : "TourCatalog – onboarding step 4 body",
|
||||
"extractionState" : "stale",
|
||||
@@ -3835,7 +3935,6 @@
|
||||
},
|
||||
"Nachricht" : {
|
||||
"comment" : "ShareExtensionView – message text section header",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -4104,6 +4203,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Natürlich & klar" : {
|
||||
"comment" : "Theme tagline for Birch",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Natural & clear"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Natürlich & verbunden" : {
|
||||
"comment" : "Theme tagline for Grove",
|
||||
"localizations" : {
|
||||
@@ -4420,6 +4530,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"OK" : {
|
||||
|
||||
},
|
||||
"Onboarding abschließen und App starten" : {
|
||||
"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" : {
|
||||
"comment" : "PersonDetailView / ShareExtensionView – close button",
|
||||
"localizations" : {
|
||||
@@ -5046,6 +5170,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Telefon" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Phone"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Telegram" : {
|
||||
"comment" : "MomentSource.telegram raw value",
|
||||
"extractionState" : "stale",
|
||||
@@ -5636,6 +5770,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vom Kontakt aktualisieren" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Update from Contact"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vom Kontakt übernehmen" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"comment" : "LogbuchView swipe action – mark moment as important",
|
||||
"localizations" : {
|
||||
|
||||
@@ -38,6 +38,25 @@ enum NudgeFrequency: String, CaseIterable, Codable {
|
||||
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 {
|
||||
@@ -117,6 +136,9 @@ class Person {
|
||||
var interests: String?
|
||||
var generalNotes: String?
|
||||
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 nextStep: String?
|
||||
var nextStepCompleted: Bool = false
|
||||
@@ -158,6 +180,9 @@ class Person {
|
||||
self.interests = interests
|
||||
self.generalNotes = generalNotes
|
||||
self.culturalBackground = culturalBackground
|
||||
self.phoneNumber = nil
|
||||
self.emailAddress = nil
|
||||
self.cnIdentifier = nil
|
||||
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
||||
self.photoData = nil
|
||||
self.photo = nil
|
||||
@@ -196,6 +221,17 @@ class Person {
|
||||
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 {
|
||||
guard let birthday else { return false }
|
||||
let cal = Calendar.current
|
||||
|
||||
@@ -640,15 +640,141 @@ enum NahbarSchemaV7: VersionedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema V8 (aktuelles Schema)
|
||||
// Referenziert die Live-Typen aus Models.swift.
|
||||
// Beim Hinzufügen von V9 muss V8 als eingefrorener Snapshot gesichert werden.
|
||||
// MARK: - Schema V8 (eingefrorener Snapshot)
|
||||
// Exakter Zustand aller Modelle zum Zeitpunkt des V8-Deployments.
|
||||
// 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)
|
||||
|
||||
enum NahbarSchemaV8: VersionedSchema {
|
||||
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] {
|
||||
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.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] {
|
||||
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
||||
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self,
|
||||
NahbarSchemaV7.self, NahbarSchemaV8.self]
|
||||
NahbarSchemaV7.self, NahbarSchemaV8.self, NahbarSchemaV9.self]
|
||||
}
|
||||
|
||||
static var stages: [MigrationStage] {
|
||||
@@ -693,7 +819,11 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
|
||||
|
||||
// V7 → V8: Todo bekommt reminderDate = nil.
|
||||
// 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 UserNotifications
|
||||
import OSLog
|
||||
import UIKit
|
||||
|
||||
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 {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@Environment(\.modelContext) var modelContext
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.openURL) var openURL
|
||||
@Bindable var person: Person
|
||||
|
||||
@State private var showingAddMoment = false
|
||||
@@ -33,13 +43,31 @@ struct PersonDetailView: View {
|
||||
@State private var momentPendingDelete: Moment? = nil
|
||||
@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 storeManager = StoreManager.shared
|
||||
@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 {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
personHeader
|
||||
if person.phoneNumber != nil || person.emailAddress != nil {
|
||||
kontaktSection
|
||||
}
|
||||
momentsSection
|
||||
todosSection
|
||||
if !person.sortedMoments.isEmpty || !person.sortedLogEntries.isEmpty { logbuchSection }
|
||||
@@ -58,6 +86,7 @@ struct PersonDetailView: View {
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.accent)
|
||||
}
|
||||
|
||||
}
|
||||
.sheet(isPresented: $showingAddTodo) {
|
||||
AddTodoView(person: person)
|
||||
@@ -104,6 +133,12 @@ struct PersonDetailView: View {
|
||||
.sheet(item: $todoForEdit) { todo in
|
||||
EditTodoView(todo: todo)
|
||||
}
|
||||
.sheet(isPresented: $showingAIAnalysis) {
|
||||
AIAnalysisSheet(person: person)
|
||||
}
|
||||
.sheet(isPresented: $showingAIPaywall) {
|
||||
PaywallView(targeting: .max)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Moment löschen",
|
||||
isPresented: $showCalendarDeleteDialog,
|
||||
@@ -123,6 +158,36 @@ struct PersonDetailView: View {
|
||||
} message: { _ in
|
||||
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
|
||||
// CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden.
|
||||
.onReceive(
|
||||
@@ -138,7 +203,7 @@ struct PersonDetailView: View {
|
||||
// MARK: - Header
|
||||
|
||||
private var personHeader: some View {
|
||||
HStack(spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
PersonAvatar(person: person, size: 64)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
@@ -153,9 +218,128 @@ struct PersonDetailView: View {
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
}
|
||||
|
||||
if person.nudgeStatus != .never {
|
||||
nudgeChip
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
struct InfoRowView: View {
|
||||
|
||||
@@ -284,13 +284,13 @@ struct SectionHeader: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
.font(.system(size: theme.sectionHeaderSize, weight: .medium))
|
||||
.foregroundStyle(theme.sectionHeaderColor)
|
||||
Text(title)
|
||||
.textCase(.uppercase)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.system(size: theme.sectionHeaderSize, weight: .semibold))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
.foregroundStyle(theme.sectionHeaderColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,19 @@ import SwiftUI
|
||||
enum ThemeID: String, CaseIterable, Codable {
|
||||
case linen, slate, mist, grove, ink, copper
|
||||
case abyss, dusk, basalt
|
||||
case chalk, flint
|
||||
case onyx, ember, birch, vapor
|
||||
|
||||
var isPremium: Bool {
|
||||
switch self {
|
||||
case .linen, .slate, .mist: return false
|
||||
case .linen, .slate, .mist, .chalk, .flint: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
var isDark: Bool {
|
||||
switch self {
|
||||
case .copper, .abyss, .dusk, .basalt: return true
|
||||
case .copper, .abyss, .dusk, .basalt, .flint, .onyx, .ember: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
@@ -38,6 +40,12 @@ enum ThemeID: String, CaseIterable, Codable {
|
||||
case .abyss: return "Abyss"
|
||||
case .dusk: return "Dusk"
|
||||
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 .dusk: return "Warm & augenschonend · 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
|
||||
let displayDesign: Font.Design
|
||||
|
||||
// Section Headers
|
||||
let sectionHeaderSize: CGFloat
|
||||
let sectionHeaderColor: Color
|
||||
|
||||
// Shape
|
||||
let radiusCard: CGFloat
|
||||
let radiusTag: CGFloat
|
||||
@@ -99,6 +117,8 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.620, green: 0.561, blue: 0.494),
|
||||
accent: Color(red: 0.710, green: 0.443, blue: 0.290),
|
||||
displayDesign: .default,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.447, green: 0.384, blue: 0.318),
|
||||
radiusCard: 16,
|
||||
radiusTag: 8,
|
||||
reducedMotion: false
|
||||
@@ -115,6 +135,8 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.557, green: 0.596, blue: 0.643),
|
||||
accent: Color(red: 0.239, green: 0.353, blue: 0.945),
|
||||
displayDesign: .default,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.353, green: 0.388, blue: 0.431),
|
||||
radiusCard: 12,
|
||||
radiusTag: 6,
|
||||
reducedMotion: false
|
||||
@@ -131,6 +153,8 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.651, green: 0.651, blue: 0.671),
|
||||
accent: Color(red: 0.569, green: 0.541, blue: 0.745),
|
||||
displayDesign: .default,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.455, green: 0.455, blue: 0.475),
|
||||
radiusCard: 20,
|
||||
radiusTag: 10,
|
||||
reducedMotion: true
|
||||
@@ -147,6 +171,8 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.467, green: 0.573, blue: 0.455),
|
||||
accent: Color(red: 0.220, green: 0.412, blue: 0.227),
|
||||
displayDesign: .serif,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.298, green: 0.408, blue: 0.298),
|
||||
radiusCard: 16,
|
||||
radiusTag: 8,
|
||||
reducedMotion: false
|
||||
@@ -163,6 +189,8 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.541, green: 0.541, blue: 0.541),
|
||||
accent: Color(red: 0.749, green: 0.220, blue: 0.165),
|
||||
displayDesign: .serif,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.310, green: 0.310, blue: 0.310),
|
||||
radiusCard: 8,
|
||||
radiusTag: 4,
|
||||
reducedMotion: true
|
||||
@@ -179,6 +207,8 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.502, green: 0.443, blue: 0.373),
|
||||
accent: Color(red: 0.784, green: 0.514, blue: 0.227),
|
||||
displayDesign: .default,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.714, green: 0.659, blue: 0.588),
|
||||
radiusCard: 16,
|
||||
radiusTag: 8,
|
||||
reducedMotion: false
|
||||
@@ -196,6 +226,8 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.349, green: 0.408, blue: 0.502),
|
||||
accent: Color(red: 0.357, green: 0.553, blue: 0.937),
|
||||
displayDesign: .default,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.541, green: 0.612, blue: 0.710),
|
||||
radiusCard: 14,
|
||||
radiusTag: 7,
|
||||
reducedMotion: true
|
||||
@@ -213,6 +245,8 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.431, green: 0.357, blue: 0.271),
|
||||
accent: Color(red: 0.831, green: 0.573, blue: 0.271),
|
||||
displayDesign: .default,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.651, green: 0.565, blue: 0.451),
|
||||
radiusCard: 18,
|
||||
radiusTag: 9,
|
||||
reducedMotion: true
|
||||
@@ -230,11 +264,127 @@ extension NahbarTheme {
|
||||
contentTertiary: Color(red: 0.365, green: 0.365, blue: 0.365),
|
||||
accent: Color(red: 0.376, green: 0.725, blue: 0.545),
|
||||
displayDesign: .monospaced,
|
||||
sectionHeaderSize: 13,
|
||||
sectionHeaderColor: Color(red: 0.561, green: 0.561, blue: 0.561),
|
||||
radiusCard: 10,
|
||||
radiusTag: 5,
|
||||
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 {
|
||||
switch id {
|
||||
case .linen: return .linen
|
||||
@@ -246,6 +396,12 @@ extension NahbarTheme {
|
||||
case .abyss: return .abyss
|
||||
case .dusk: return .dusk
|
||||
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)
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 8 Schemas (V1–V8)")
|
||||
func migrationPlanHasEightSchemas() {
|
||||
#expect(NahbarMigrationPlan.schemas.count == 8)
|
||||
@Test("Migrationsplan enthält genau 9 Schemas (V1–V9)")
|
||||
func migrationPlanHasNineSchemas() {
|
||||
#expect(NahbarMigrationPlan.schemas.count == 9)
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)")
|
||||
func migrationPlanHasSevenStages() {
|
||||
#expect(NahbarMigrationPlan.stages.count == 7)
|
||||
@Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)")
|
||||
func migrationPlanHasEightStages() {
|
||||
#expect(NahbarMigrationPlan.stages.count == 8)
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 8 Schemas (V1–V8)")
|
||||
func migrationPlanHasEightSchemas() {
|
||||
#expect(NahbarMigrationPlan.schemas.count == 8)
|
||||
@Test("Migrationsplan enthält genau 9 Schemas (V1–V9)")
|
||||
func migrationPlanHasNineSchemas() {
|
||||
#expect(NahbarMigrationPlan.schemas.count == 9)
|
||||
}
|
||||
|
||||
@Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)")
|
||||
func migrationPlanHasSevenStages() {
|
||||
#expect(NahbarMigrationPlan.stages.count == 7)
|
||||
@Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)")
|
||||
func migrationPlanHasEightStages() {
|
||||
#expect(NahbarMigrationPlan.stages.count == 8)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user