From bf1b49697b964880028d5f326ecd4f7518d2b38c Mon Sep 17 00:00:00 2001 From: Sven Date: Thu, 23 Apr 2026 12:07:04 +0200 Subject: [PATCH] Fix #22, #28, #31: Kontakt-Sektion, Nudge-Chip, KI-Analyse-Button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- nahbar/nahbar/AddPersonView.swift | 72 ++++ nahbar/nahbar/ContactPickerView.swift | 19 +- nahbar/nahbar/Localizable.xcstrings | 183 +++++++++- nahbar/nahbar/Models.swift | 36 ++ nahbar/nahbar/NahbarMigration.swift | 142 +++++++- nahbar/nahbar/PersonDetailView.swift | 409 +++++++++++++++++++++- nahbar/nahbar/SharedComponents.swift | 8 +- nahbar/nahbar/ThemeSystem.swift | 160 ++++++++- nahbar/nahbarTests/AppEventLogTests.swift | 12 +- nahbar/nahbarTests/ModelTests.swift | 74 ++++ nahbar/nahbarTests/ThemeTests.swift | 124 +++++++ nahbar/nahbarTests/VisitRatingTests.swift | 12 +- 12 files changed, 1211 insertions(+), 40 deletions(-) create mode 100644 nahbar/nahbarTests/ThemeTests.swift diff --git a/nahbar/nahbar/AddPersonView.swift b/nahbar/nahbar/AddPersonView.swift index 007638a..363dd15 100644 --- a/nahbar/nahbar/AddPersonView.swift +++ b/nahbar/nahbar/AddPersonView.swift @@ -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) } diff --git a/nahbar/nahbar/ContactPickerView.swift b/nahbar/nahbar/ContactPickerView.swift index 5c77947..62252ba 100644 --- a/nahbar/nahbar/ContactPickerView.swift +++ b/nahbar/nahbar/ContactPickerView.swift @@ -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", "_$!!$_", "_$!
!$_"] + 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) } } diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index 9d67f4e..45e6bde 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -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" : { diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift index b2a8833..4ff903b 100644 --- a/nahbar/nahbar/Models.swift +++ b/nahbar/nahbar/Models.swift @@ -38,6 +38,25 @@ enum NudgeFrequency: String, CaseIterable, Codable { case .quarterly: return 90 } } + + /// Lesbares Label für den Nudge-Chip im Header + var displayLabel: String { + switch self { + case .never: return "Nie" + case .weekly: return "Wöchentlich" + case .biweekly: return "Alle 2 Wochen" + case .monthly: return "Monatlich" + case .quarterly: return "Quartalsweise" + } + } +} + +/// Ampelstatus des Nudge-Intervalls einer Person +enum NudgeStatus: Equatable { + case never // kein Intervall gesetzt + case ok // < 75 % des Intervalls verstrichen + case soon // 75–100 % verstrichen + case overdue // > 100 % verstrichen } enum MomentType: String, Codable { @@ -117,6 +136,9 @@ class Person { var interests: String? var generalNotes: String? var culturalBackground: String? = nil // V6: kultureller Hintergrund + var phoneNumber: String? = nil // V9: primäre Telefonnummer + var emailAddress: String? = nil // V9: primäre E-Mail-Adresse + var cnIdentifier: String? = nil // V9: Apple Contacts-ID für "Vom Kontakt aktualisieren" var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue var nextStep: String? var nextStepCompleted: Bool = false @@ -158,6 +180,9 @@ class Person { self.interests = interests self.generalNotes = generalNotes self.culturalBackground = culturalBackground + self.phoneNumber = nil + self.emailAddress = nil + self.cnIdentifier = nil self.nudgeFrequencyRaw = nudgeFrequency.rawValue self.photoData = nil self.photo = nil @@ -196,6 +221,17 @@ class Person { return Date().timeIntervalSince(createdAt) > Double(days * 86400) } + /// Dreistufiger Ampelstatus basierend auf verstrichener Zeit vs. Nudge-Intervall + var nudgeStatus: NudgeStatus { + guard nudgeFrequency != .never, let days = nudgeFrequency.days else { return .never } + let interval = Double(days * 86400) + let reference = lastMomentDate ?? createdAt + let elapsed = Date().timeIntervalSince(reference) + if elapsed >= interval { return .overdue } + if elapsed >= interval * 0.75 { return .soon } + return .ok + } + func hasBirthdayWithin(days: Int) -> Bool { guard let birthday else { return false } let cal = Calendar.current diff --git a/nahbar/nahbar/NahbarMigration.swift b/nahbar/nahbar/NahbarMigration.swift index 714eced..1195c28 100644 --- a/nahbar/nahbar/NahbarMigration.swift +++ b/nahbar/nahbar/NahbarMigration.swift @@ -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) ] } } diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index da0b3a6..213cd0b 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -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,11 +218,130 @@ 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 private var momentsSection: some View { @@ -1286,6 +1470,229 @@ struct EditTodoView: View { } } +// MARK: - AI Analysis Sheet + +private struct AIAnalysisSheet: View { + @Environment(\.nahbarTheme) var theme + @Environment(\.dismiss) var dismiss + @StateObject private var store = StoreManager.shared + let person: Person + + @State private var analysisState: AnalysisState = .idle + @State private var showAIConsent = false + @State private var showPaywall = false + @State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests + @AppStorage("aiConsentGiven") private var aiConsentGiven = false + + private var canUseAI: Bool { + store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Header mit MAX-Badge + HStack(spacing: 6) { + SectionHeader(title: "KI-Auswertung", icon: "sparkles") + MaxBadge() + if !store.isMax && canUseAI { + Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(theme.contentTertiary) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(theme.backgroundSecondary) + .clipShape(Capsule()) + } + } + + // Inhalt + VStack(alignment: .leading, spacing: 0) { + switch analysisState { + case .idle: + Button { + if aiConsentGiven { + Task { await runAnalysis() } + } else { + showAIConsent = true + } + } label: { + HStack(spacing: 10) { + Image(systemName: "sparkles") + .foregroundStyle(theme.accent) + Text("\(person.firstName) analysieren") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(theme.contentPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } + .padding(16) + } + + case .loading: + HStack(spacing: 12) { + ProgressView().tint(theme.accent) + VStack(alignment: .leading, spacing: 2) { + Text("Analysiere Logbuch…") + .font(.system(size: 14)) + .foregroundStyle(theme.contentSecondary) + Text("Das kann bis zu einer Minute dauern.") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } + } + .padding(16) + + case .result(let result, let date): + VStack(alignment: .leading, spacing: 0) { + analysisSection(icon: "waveform.path", title: "Muster & Themen", text: result.patterns) + RowDivider() + analysisSection(icon: "person.2", title: "Beziehungsqualität", text: result.relationship) + RowDivider() + analysisSection(icon: "arrow.right.circle", title: "Empfehlung", text: result.recommendation) + RowDivider() + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 1) { + Text("Analysiert") + .font(.system(size: 11)) + .foregroundStyle(theme.contentTertiary) + Text(date.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale.current))) + .font(.system(size: 11)) + .foregroundStyle(theme.contentTertiary) + } + .padding(.leading, 16) + .padding(.vertical, 12) + + Spacer() + + Button { + Task { await runAnalysis() } + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12)) + Text(remainingRequests > 0 + ? "Aktualisieren (\(remainingRequests))" + : "Limit erreicht") + .font(.system(size: 13)) + } + .foregroundStyle(remainingRequests > 0 ? theme.accent : theme.contentTertiary) + } + .disabled(remainingRequests == 0 || isAnalyzing) + .padding(.trailing, 16) + .padding(.vertical, 12) + } + } + + case .error(let msg): + VStack(alignment: .leading, spacing: 8) { + Label("Analyse fehlgeschlagen", systemImage: "exclamationmark.triangle") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(theme.contentSecondary) + Text(msg) + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + Button { + Task { await runAnalysis() } + } label: { + Text("Erneut versuchen") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(theme.accent) + } + } + .padding(16) + } + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + } + .padding(20) + } + .background(theme.backgroundPrimary.ignoresSafeArea()) + .navigationTitle("KI-Analyse") + .navigationBarTitleDisplayMode(.inline) + .themedNavBar() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Schließen") { dismiss() } + .foregroundStyle(theme.accent) + } + } + .sheet(isPresented: $showAIConsent) { + AIConsentSheet { + aiConsentGiven = true + Task { await runAnalysis() } + } + } + .sheet(isPresented: $showPaywall) { + PaywallView(targeting: .max) + } + } + .onAppear { + // Cache laden + if let cached = AIAnalysisService.shared.loadCached(for: person) { + analysisState = .result(cached.asResult, cached.analyzedAt) + } + remainingRequests = AIAnalysisService.shared.remainingRequests + + // Auto-start: kein Cache → direkt starten wenn möglich + if case .idle = analysisState { + if canUseAI && aiConsentGiven { + Task { await runAnalysis() } + } else if canUseAI && !aiConsentGiven { + showAIConsent = true + } + } + } + } + + private func analysisSection(icon: String, title: String, text: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 13)) + .foregroundStyle(theme.accent) + .frame(width: 20) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(theme.contentSecondary) + Text(LocalizedStringKey(text)) + .font(.system(size: 14, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var isAnalyzing: Bool { + if case .loading = analysisState { return true } + return false + } + + private func runAnalysis() async { + guard !AIAnalysisService.shared.isRateLimited else { return } + analysisState = .loading + do { + let result = try await AIAnalysisService.shared.analyze(person: person) + remainingRequests = AIAnalysisService.shared.remainingRequests + if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() } + analysisState = .result(result, Date()) + } catch { + if let cached = AIAnalysisService.shared.loadCached(for: person) { + analysisState = .result(cached.asResult, cached.analyzedAt) + } else { + analysisState = .error(error.localizedDescription) + } + } + } +} + // MARK: - Info Row struct InfoRowView: View { diff --git a/nahbar/nahbar/SharedComponents.swift b/nahbar/nahbar/SharedComponents.swift index 8633c15..468c4bf 100644 --- a/nahbar/nahbar/SharedComponents.swift +++ b/nahbar/nahbar/SharedComponents.swift @@ -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) } } } diff --git a/nahbar/nahbar/ThemeSystem.swift b/nahbar/nahbar/ThemeSystem.swift index 99ac3b2..57399f3 100644 --- a/nahbar/nahbar/ThemeSystem.swift +++ b/nahbar/nahbar/ThemeSystem.swift @@ -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 } } } diff --git a/nahbar/nahbarTests/AppEventLogTests.swift b/nahbar/nahbarTests/AppEventLogTests.swift index cdbdb8b..f63f228 100644 --- a/nahbar/nahbarTests/AppEventLogTests.swift +++ b/nahbar/nahbarTests/AppEventLogTests.swift @@ -155,14 +155,14 @@ struct SchemaRegressionTests { #expect(NahbarSchemaV3.versionIdentifier.patch == 0) } - @Test("Migrationsplan enthält genau 8 Schemas (V1–V8)") - func migrationPlanHasEightSchemas() { - #expect(NahbarMigrationPlan.schemas.count == 8) + @Test("Migrationsplan enthält genau 9 Schemas (V1–V9)") + func migrationPlanHasNineSchemas() { + #expect(NahbarMigrationPlan.schemas.count == 9) } - @Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)") - func migrationPlanHasSevenStages() { - #expect(NahbarMigrationPlan.stages.count == 7) + @Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)") + func migrationPlanHasEightStages() { + #expect(NahbarMigrationPlan.stages.count == 8) } @Test("ContainerFallback-Gleichheit funktioniert korrekt") diff --git a/nahbar/nahbarTests/ModelTests.swift b/nahbar/nahbarTests/ModelTests.swift index 3034cad..c46e574 100644 --- a/nahbar/nahbarTests/ModelTests.swift +++ b/nahbar/nahbarTests/ModelTests.swift @@ -43,6 +43,80 @@ struct NudgeFrequencyTests { } } } + + @Test("displayLabel ist nicht leer für alle Fälle") + func displayLabelNotEmpty() { + for freq in NudgeFrequency.allCases { + #expect(!freq.displayLabel.isEmpty) + } + } + + @Test("biweekly displayLabel enthält '2 Wochen'") + func biweeklyDisplayLabel() { + #expect(NudgeFrequency.biweekly.displayLabel.contains("2 Wochen")) + } +} + +// MARK: - NudgeStatus Tests + +@Suite("NudgeStatus") +struct NudgeStatusTests { + + private func makePerson(frequency: NudgeFrequency, lastContact: Date?) -> Person { + let p = Person(name: "Test", tag: .friends) + p.nudgeFrequency = frequency + // lastMomentDate ist computed aus moments — wir simulieren via createdAt + // Wir setzen createdAt auf den gewünschten Referenzzeitpunkt + if let date = lastContact { + p.createdAt = date + } + return p + } + + @Test("never → .never Status") + func neverFrequencyReturnsNever() { + let p = makePerson(frequency: .never, lastContact: nil) + #expect(p.nudgeStatus == .never) + } + + @Test("kürzlicher Kontakt → .ok") + func recentContactReturnsOk() { + // Letzte Aktivität: gestern → weit unter 75 % des monatlichen Intervalls + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + let p = makePerson(frequency: .monthly, lastContact: yesterday) + #expect(p.nudgeStatus == .ok) + } + + @Test("75–100 % des Intervalls → .soon") + func approachingDeadlineReturnsSoon() { + // 25 Tage her bei monatlichem Intervall (30 Tage) = 83 % + let almostDue = Calendar.current.date(byAdding: .day, value: -25, to: Date())! + let p = makePerson(frequency: .monthly, lastContact: almostDue) + #expect(p.nudgeStatus == .soon) + } + + @Test("über 100 % des Intervalls → .overdue") + func overdueReturnsOverdue() { + // 40 Tage her bei monatlichem Intervall (30 Tage) + let tooLong = Calendar.current.date(byAdding: .day, value: -40, to: Date())! + let p = makePerson(frequency: .monthly, lastContact: tooLong) + #expect(p.nudgeStatus == .overdue) + } + + @Test("wöchentlich + 8 Tage her → .overdue") + func weeklyOverdue() { + let eightDaysAgo = Calendar.current.date(byAdding: .day, value: -8, to: Date())! + let p = makePerson(frequency: .weekly, lastContact: eightDaysAgo) + #expect(p.nudgeStatus == .overdue) + } + + @Test("nudgeStatus stimmt mit needsAttention überein wenn overdue") + func nudgeStatusConsistentWithNeedsAttention() { + let tooLong = Calendar.current.date(byAdding: .day, value: -40, to: Date())! + let p = makePerson(frequency: .monthly, lastContact: tooLong) + #expect(p.nudgeStatus == .overdue) + #expect(p.needsAttention == true) + } } // MARK: - PersonTag Tests diff --git a/nahbar/nahbarTests/ThemeTests.swift b/nahbar/nahbarTests/ThemeTests.swift new file mode 100644 index 0000000..898d1e8 --- /dev/null +++ b/nahbar/nahbarTests/ThemeTests.swift @@ -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") + } + } +} diff --git a/nahbar/nahbarTests/VisitRatingTests.swift b/nahbar/nahbarTests/VisitRatingTests.swift index 68bcbce..bd319c7 100644 --- a/nahbar/nahbarTests/VisitRatingTests.swift +++ b/nahbar/nahbarTests/VisitRatingTests.swift @@ -359,13 +359,13 @@ struct SchemaV5RegressionTests { #expect(NahbarSchemaV5.versionIdentifier.patch == 0) } - @Test("Migrationsplan enthält genau 8 Schemas (V1–V8)") - func migrationPlanHasEightSchemas() { - #expect(NahbarMigrationPlan.schemas.count == 8) + @Test("Migrationsplan enthält genau 9 Schemas (V1–V9)") + func migrationPlanHasNineSchemas() { + #expect(NahbarMigrationPlan.schemas.count == 9) } - @Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)") - func migrationPlanHasSevenStages() { - #expect(NahbarMigrationPlan.stages.count == 7) + @Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)") + func migrationPlanHasEightStages() { + #expect(NahbarMigrationPlan.stages.count == 8) } }