Fix #22, #28, #31: Kontakt-Sektion, Nudge-Chip, KI-Analyse-Button

- #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:
2026-04-23 12:07:04 +02:00
parent ec0dc68db9
commit bf1b49697b
12 changed files with 1211 additions and 40 deletions
+72
View File
@@ -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)
} }
+18 -1
View File
@@ -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)
} }
} }
+169 -14
View File
@@ -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" : {
+36
View File
@@ -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 // 75100 % 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
+136 -6
View File
@@ -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)
] ]
} }
} }
+408 -1
View File
@@ -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,9 +218,128 @@ 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
@@ -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 {
+4 -4
View File
@@ -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)
} }
} }
} }
+158 -2
View File
@@ -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
} }
} }
} }
+6 -6
View File
@@ -155,14 +155,14 @@ struct SchemaRegressionTests {
#expect(NahbarSchemaV3.versionIdentifier.patch == 0) #expect(NahbarSchemaV3.versionIdentifier.patch == 0)
} }
@Test("Migrationsplan enthält genau 8 Schemas (V1V8)") @Test("Migrationsplan enthält genau 9 Schemas (V1V9)")
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")
+74
View File
@@ -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("75100 % 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
+124
View File
@@ -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")
}
}
}
+6 -6
View File
@@ -359,13 +359,13 @@ struct SchemaV5RegressionTests {
#expect(NahbarSchemaV5.versionIdentifier.patch == 0) #expect(NahbarSchemaV5.versionIdentifier.patch == 0)
} }
@Test("Migrationsplan enthält genau 8 Schemas (V1V8)") @Test("Migrationsplan enthält genau 9 Schemas (V1V9)")
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)
} }
} }