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 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)
}
+18 -1
View File
@@ -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)
}
}
+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" : {
"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" : {
+36
View File
@@ -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 // 75100 % 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
+136 -6
View File
@@ -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)
]
}
}
+408 -1
View File
@@ -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 {
+4 -4
View File
@@ -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)
}
}
}
+158 -2
View File
@@ -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
}
}
}
+6 -6
View File
@@ -155,14 +155,14 @@ struct SchemaRegressionTests {
#expect(NahbarSchemaV3.versionIdentifier.patch == 0)
}
@Test("Migrationsplan enthält genau 8 Schemas (V1V8)")
func migrationPlanHasEightSchemas() {
#expect(NahbarMigrationPlan.schemas.count == 8)
@Test("Migrationsplan enthält genau 9 Schemas (V1V9)")
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")
+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
+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)
}
@Test("Migrationsplan enthält genau 8 Schemas (V1V8)")
func migrationPlanHasEightSchemas() {
#expect(NahbarMigrationPlan.schemas.count == 8)
@Test("Migrationsplan enthält genau 9 Schemas (V1V9)")
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)
}
}