diff --git a/nahbar/VisitHistorySection.swift b/nahbar/VisitHistorySection.swift index c684d5a..f56081c 100644 --- a/nahbar/VisitHistorySection.swift +++ b/nahbar/VisitHistorySection.swift @@ -20,7 +20,7 @@ struct VisitHistorySection: View { VStack(alignment: .leading, spacing: 12) { // Header HStack { - SectionHeader(title: "Besuche", icon: "star.fill") + SectionHeader(title: "Treffen", icon: "star.fill") Spacer() Button { showingVisitRating = true @@ -38,7 +38,7 @@ struct VisitHistorySection: View { .font(.title3) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { - Text("Noch keine Besuche bewertet") + Text("Noch keine Treffen bewertet") .font(.subheadline) .foregroundStyle(.secondary) Text("Tippe auf + um loszulegen") diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj index a2d8e4d..2d7a413 100644 --- a/nahbar/nahbar.xcodeproj/project.pbxproj +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -576,7 +576,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -635,7 +635,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate index 2fc75b2..451fda2 100644 Binary files a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate and b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift index 6703c47..0037f24 100644 --- a/nahbar/nahbar/AddMomentView.swift +++ b/nahbar/nahbar/AddMomentView.swift @@ -53,7 +53,7 @@ struct AddMomentView: View { HStack(spacing: 5) { Image(systemName: type.icon) .font(.system(size: 12)) - Text(type.rawValue) + Text(LocalizedStringKey(type.displayName)) .font(.system(size: 13, weight: selectedType == type ? .medium : .regular)) } .foregroundStyle(selectedType == type ? theme.accent : theme.contentSecondary) diff --git a/nahbar/nahbar/AddPersonView.swift b/nahbar/nahbar/AddPersonView.swift index 56c253d..a5c108b 100644 --- a/nahbar/nahbar/AddPersonView.swift +++ b/nahbar/nahbar/AddPersonView.swift @@ -127,33 +127,39 @@ struct AddPersonView: View { } // Nudge frequency - formSection("Wie oft erinnern?") { - VStack(spacing: 0) { - ForEach(NudgeFrequency.allCases, id: \.self) { freq in - Button { - nudgeFrequency = freq - } label: { - HStack { - Text(freq.rawValue) - .font(.system(size: 15)) - .foregroundStyle(theme.contentPrimary) - Spacer() - if nudgeFrequency == freq { - Image(systemName: "checkmark") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(theme.accent) + formSection("Wie oft melden?") { + VStack(alignment: .leading, spacing: 8) { + Text("Nahbar erinnert dich, wenn du diese Person seit der gewählten Zeit nicht mehr kontaktiert hast.") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + + VStack(spacing: 0) { + ForEach(NudgeFrequency.allCases, id: \.self) { freq in + Button { + nudgeFrequency = freq + } label: { + HStack { + Text(freq.rawValue) + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Spacer() + if nudgeFrequency == freq { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(theme.accent) + } } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + if freq != NudgeFrequency.allCases.last { + RowDivider() } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - if freq != NudgeFrequency.allCases.last { - RowDivider() } } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) } - .background(theme.surfaceCard) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) } // Delete link — only in edit mode diff --git a/nahbar/nahbar/ContactPickerView.swift b/nahbar/nahbar/ContactPickerView.swift index 12dd77e..cb86679 100644 --- a/nahbar/nahbar/ContactPickerView.swift +++ b/nahbar/nahbar/ContactPickerView.swift @@ -218,13 +218,19 @@ struct ContactImport { let photoData: Data? static func from(_ contact: CNContact) -> ContactImport { - let parts = [contact.givenName, contact.familyName].filter { !$0.isEmpty } + // Mittelname einbeziehen, falls vorhanden + let parts = [contact.givenName, contact.middleName, contact.familyName].filter { !$0.isEmpty } let name = parts.joined(separator: " ") + // Berufsbezeichnung und Firma kombinieren wenn beide vorhanden let occupation: String - if !contact.jobTitle.isEmpty { + let hasJob = !contact.jobTitle.isEmpty + let hasOrg = !contact.organizationName.isEmpty + if hasJob && hasOrg { + occupation = "\(contact.jobTitle) · \(contact.organizationName)" + } else if hasJob { occupation = contact.jobTitle - } else if !contact.organizationName.isEmpty { + } else if hasOrg { occupation = contact.organizationName } else { occupation = "" @@ -232,7 +238,8 @@ struct ContactImport { let location: String if let postal = contact.postalAddresses.first?.value { - location = [postal.city, postal.country].filter { !$0.isEmpty }.joined(separator: ", ") + // Bundesstaat/Region einbeziehen, falls vorhanden + location = [postal.city, postal.state, postal.country].filter { !$0.isEmpty }.joined(separator: ", ") } else { location = "" } diff --git a/nahbar/nahbar/IchView.swift b/nahbar/nahbar/IchView.swift index a67955c..5749a17 100644 --- a/nahbar/nahbar/IchView.swift +++ b/nahbar/nahbar/IchView.swift @@ -1,6 +1,5 @@ import SwiftUI import PhotosUI -import Contacts import SwiftData private let socialStyleOptions = [ @@ -15,15 +14,12 @@ private let socialStyleOptions = [ struct IchView: View { @Environment(\.nahbarTheme) var theme - @Environment(\.modelContext) private var modelContext @EnvironmentObject var profileStore: UserProfileStore @StateObject private var personalityStore = PersonalityStore.shared @State private var profilePhoto: UIImage? = nil @State private var showingEdit = false - @State private var showingImportPicker = false - @State private var importFeedback: String? = nil @State private var showingQuiz = false @State private var showingPersonalityDetail = false @@ -35,7 +31,6 @@ struct IchView: View { if !profileStore.isEmpty { infoSection } if profileStore.isEmpty { emptyState } personalitySection - importKontakteSection } .padding(.horizontal, 20) .padding(.top, 12) @@ -53,30 +48,6 @@ struct IchView: View { .onAppear { profilePhoto = profileStore.loadPhoto() } - .overlay(alignment: .bottom) { - if let feedback = importFeedback { - Text(feedback) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.white) - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background(Color.accentColor) - .clipShape(Capsule()) - .padding(.bottom, 24) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - withAnimation { importFeedback = nil } - } - } - } - } - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: importFeedback) - .overlay(alignment: .center) { - MultiContactPickerTrigger(isPresented: $showingImportPicker, onSelect: importContacts) - .frame(width: 0, height: 0) - .allowsHitTesting(false) - } .sheet(isPresented: $showingQuiz) { PersonalityQuizView { _ in showingQuiz = false @@ -197,9 +168,14 @@ struct IchView: View { private var infoSection: some View { VStack(alignment: .leading, spacing: 10) { // Über mich - if !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty { + let hasUeberMich = !profileStore.gender.isEmpty || !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty + if hasUeberMich { SectionHeader(title: "Über mich", icon: "person") VStack(spacing: 0) { + if !profileStore.gender.isEmpty { + infoRow(label: "Geschlecht", value: profileStore.gender) + if !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty { RowDivider() } + } if !profileStore.location.isEmpty { infoRow(label: "Wohnort", value: profileStore.location) if !profileStore.socialStyle.isEmpty { RowDivider() } @@ -297,58 +273,6 @@ struct IchView: View { .padding(.top, 12) } - // MARK: - Kontakte importieren - - private var importKontakteSection: some View { - VStack(alignment: .leading, spacing: 10) { - SectionHeader(title: "Kontakte importieren", icon: "person.2.badge.plus") - Button { showingImportPicker = true } label: { - HStack(spacing: 10) { - Image(systemName: "person.crop.circle.badge.plus") - .font(.system(size: 15)) - Text("Aus Adressbuch hinzufügen") - .font(.system(size: 15)) - } - .foregroundStyle(theme.accent) - .padding(.horizontal, 14) - .padding(.vertical, 11) - .frame(maxWidth: .infinity, alignment: .leading) - .background(theme.surfaceCard) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) - .overlay( - RoundedRectangle(cornerRadius: theme.radiusCard) - .stroke(theme.accent.opacity(0.25), lineWidth: 1) - ) - } - .accessibilityLabel("Kontakte aus Adressbuch hinzufügen") - } - } - - /// Importiert die gewählten Kontakte als Person-Objekte in die Datenbank. - /// Bereits vorhandene Personen werden nicht dupliziert (Name-Vergleich). - private func importContacts(_ contacts: [CNContact]) { - var imported = 0 - for contact in contacts { - let name = [contact.givenName, contact.familyName] - .filter { !$0.isEmpty } - .joined(separator: " ") - guard !name.isEmpty else { continue } - let person = Person(name: name) - modelContext.insert(person) - imported += 1 - } - guard imported > 0 else { return } - do { - try modelContext.save() - } catch { - // Fehler werden im nächsten App-Launch durch den Container-Fallback abgefangen - } - withAnimation { - importFeedback = imported == 1 - ? "1 Person hinzugefügt" - : "\(imported) Personen hinzugefügt" - } - } } // MARK: - IchEditView @@ -361,6 +285,7 @@ struct IchEditView: View { @State private var name: String @State private var hasBirthday: Bool @State private var birthday: Date + @State private var gender: String @State private var occupation: String @State private var location: String @State private var likes: String @@ -368,13 +293,13 @@ struct IchEditView: View { @State private var socialStyle: String @State private var selectedPhoto: UIImage? @State private var photoPickerItem: PhotosPickerItem? = nil - @State private var showingContactPicker = false init() { let store = UserProfileStore.shared _name = State(initialValue: store.name) _hasBirthday = State(initialValue: store.birthday != nil) _birthday = State(initialValue: store.birthday ?? IchEditView.defaultBirthday) + _gender = State(initialValue: store.gender) _occupation = State(initialValue: store.occupation) _location = State(initialValue: store.location) _likes = State(initialValue: store.likes) @@ -395,9 +320,6 @@ struct IchEditView: View { // Foto photoSection - // Kontakt-Import - importButton - // Name formSection("Name") { TextField("Wie heißt du?", text: $name) @@ -439,6 +361,8 @@ struct IchEditView: View { // Details formSection("Details") { VStack(spacing: 0) { + genderPickerRow + Divider().padding(.leading, 16) inlineField("Beruf", text: $occupation) Divider().padding(.leading, 16) inlineField("Wohnort", text: $location) @@ -505,11 +429,6 @@ struct IchEditView: View { } } } - .overlay(alignment: .center) { - SingleContactPickerTrigger(isPresented: $showingContactPicker, onSelect: applyContact) - .frame(width: 0, height: 0) - .allowsHitTesting(false) - } } // MARK: - Photo Section @@ -562,27 +481,28 @@ struct IchEditView: View { return name.isEmpty ? "?" : String(name.prefix(2)).uppercased() } - // MARK: - Kontakt-Import + // MARK: - Gender Picker - private var importButton: some View { - Button { showingContactPicker = true } label: { - HStack(spacing: 10) { - Image(systemName: "person.crop.circle.badge.plus") - .font(.system(size: 15)) - Text("Aus Kontakten übernehmen") - .font(.system(size: 15)) + private let genderOptions = ["Männlich", "Weiblich", "Divers", "Keine Angabe"] + + private var genderPickerRow: some View { + HStack(spacing: 12) { + Text("Geschlecht") + .font(.system(size: 15)) + .foregroundStyle(theme.contentTertiary) + .frame(width: 80, alignment: .leading) + Picker("Geschlecht", selection: $gender) { + Text("Nicht angegeben").tag("") + ForEach(genderOptions, id: \.self) { option in + Text(option).tag(option) + } } - .foregroundStyle(theme.accent) - .padding(.horizontal, 14) - .padding(.vertical, 11) + .pickerStyle(.menu) + .tint(theme.accent) .frame(maxWidth: .infinity, alignment: .leading) - .background(theme.surfaceCard) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) - .overlay( - RoundedRectangle(cornerRadius: theme.radiusCard) - .stroke(theme.accent.opacity(0.25), lineWidth: 1) - ) } + .padding(.horizontal, 16) + .padding(.vertical, 12) } // MARK: - Helpers @@ -614,24 +534,11 @@ struct IchEditView: View { .padding(.vertical, 12) } - private func applyContact(_ contact: CNContact) { - let imported = ContactImport.from(contact) - if !imported.name.isEmpty { name = imported.name } - if !imported.occupation.isEmpty { occupation = imported.occupation } - if !imported.location.isEmpty { location = imported.location } - if let bd = imported.birthday { - birthday = bd - hasBirthday = true - } - if let data = imported.photoData, let img = UIImage(data: data) { - selectedPhoto = img - } - } - private func save() { profileStore.update( name: name.trimmingCharacters(in: .whitespaces), birthday: hasBirthday ? birthday : nil, + gender: gender, occupation: occupation.trimmingCharacters(in: .whitespaces), location: location.trimmingCharacters(in: .whitespaces), likes: likes.trimmingCharacters(in: .whitespaces), diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index e878099..2664a2f 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -294,6 +294,9 @@ } } } + }, + "Abonnement" : { + }, "Abonnement verlängert sich automatisch. In den iPhone-Einstellungen jederzeit kündbar." : { "comment" : "PaywallView – subscription legal notice", @@ -353,6 +356,9 @@ }, "Alle %lld Tage – basierend auf deinem Profil" : { + }, + "Alle Features freigeschaltet" : { + }, "Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht." : { "comment" : "AddPersonView – delete confirmation message", @@ -370,6 +376,7 @@ }, "Alle Pro-Features freigeschaltet" : { "comment" : "SettingsView – Pro subscription active subtitle", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -592,7 +599,7 @@ } } }, - "Aus Adressbuch hinzufügen" : { + "Auf Max upgraden – KI-Analyse freischalten" : { }, "Aus Kontakten ausfüllen" : { @@ -609,10 +616,6 @@ } } }, - "Aus Kontakten übernehmen" : { - "comment" : "A button that allows the user to import contacts.", - "isCommentAutoGenerated" : true - }, "Ausgeglichen" : { "comment" : "IchView – social style option (ambiverted)", "extractionState" : "stale", @@ -683,6 +686,7 @@ }, "Besuche" : { "comment" : "VisitHistorySection / SettingsView – section header for visits", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -988,6 +992,9 @@ } } } + }, + "Dein Geschlecht hilft, die Auswertung besser einzuordnen." : { + }, "Dein nächstes Gespräch kann hier beginnen." : { "comment" : "PersonDetailView – moments empty state message", @@ -1918,6 +1925,12 @@ } } } + }, + "Geschlecht" : { + + }, + "Geschlecht (optional)" : { + }, "Gesellig" : { "extractionState" : "stale", @@ -2041,7 +2054,7 @@ } } }, - "Halte fest, wen du besucht hast – und wann." : { + "Halte fest, wen du getroffen hast – und wann." : { }, "Hat sich deine Sicht auf die Person verändert?" : { @@ -2155,6 +2168,9 @@ } } } + }, + "Idee: %@" : { + }, "Ideen werden generiert…" : { "comment" : "TodayView GiftSuggestionRow – loading state text", @@ -2457,18 +2473,12 @@ }, "Kontakte aus Adressbuch auswählen" : { - }, - "Kontakte aus Adressbuch hinzufügen" : { - }, "Kontakte auswählen" : { }, "Kontakte hinzufügen" : { - }, - "Kontakte importieren" : { - }, "Kontakte überspringen" : { @@ -2509,6 +2519,9 @@ } } } + }, + "Kurze Frage vorab" : { + }, "Limit erreicht" : { "comment" : "LogbuchView – AI refresh button label when at request limit", @@ -2613,6 +2626,9 @@ } } } + }, + "Max aktiv" : { + }, "Menschen" : { "comment" : "Tab label for people list", @@ -2980,6 +2996,9 @@ } } } + }, + "Nahbar erinnert dich, wenn du diese Person seit der gewählten Zeit nicht mehr kontaktiert hast." : { + }, "nahbar erinnert dich, wenn du lange nichts von jemandem gehört hast." : { @@ -2994,12 +3013,10 @@ } } } - }, - "nahbar Pro" : { - }, "nahbar Pro entdecken" : { "comment" : "SettingsView – Pro upsell button title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3130,17 +3147,6 @@ } } }, - "Noch keine Besuche bewertet" : { - "comment" : "VisitHistorySection – empty state title", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No visits rated yet" - } - } - } - }, "Noch keine Einträge" : { "comment" : "LogbuchView – empty state title", "localizations" : { @@ -3170,6 +3176,17 @@ } } }, + "Noch keine Treffen bewertet" : { + "comment" : "VisitHistorySection – empty state title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No meetings rated yet" + } + } + } + }, "Noch nichts festgehalten. Dein nächstes Gespräch kann hier beginnen." : { "comment" : "PersonDetailView – moments empty state", "localizations" : { @@ -3384,6 +3401,9 @@ } } } + }, + "Pro oder Max-Abo" : { + }, "Profil bearbeiten" : { "comment" : "The title of the screen where a user can edit their profile.", @@ -3804,8 +3824,7 @@ } }, "Treffen" : { - "comment" : "MomentType.meeting raw value", - "extractionState" : "stale", + "comment" : "MomentType.meeting rawValue + VisitHistorySection / SettingsView section header", "localizations" : { "en" : { "stringUnit" : { @@ -4243,6 +4262,9 @@ }, "Weiter (%lld ausgewählt)" : { + }, + "Weiter zu den Fragen" : { + }, "Weiter zum nächsten Schritt" : { diff --git a/nahbar/nahbar/LogbuchView.swift b/nahbar/nahbar/LogbuchView.swift index c432cf9..0c9be24 100644 --- a/nahbar/nahbar/LogbuchView.swift +++ b/nahbar/nahbar/LogbuchView.swift @@ -40,7 +40,7 @@ private enum LogbuchItem: Identifiable { var label: String { switch self { - case .moment(let m): return m.type.rawValue + case .moment(let m): return m.type.displayName case .logEntry(let e): return e.type.rawValue } } @@ -183,7 +183,7 @@ struct LogbuchView: View { .font(.system(size: 10)) .foregroundStyle(.orange) } - Text(item.label) + Text(LocalizedStringKey(item.label)) .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) Text("·") @@ -230,15 +230,14 @@ struct LogbuchView: View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { SectionHeader(title: "KI-Auswertung", icon: "sparkles") - if !store.isMax { - Text(canUseAI - ? "\(AIAnalysisService.shared.freeQueriesRemaining) gratis" - : "MAX") + MaxBadge() + if !store.isMax && canUseAI { + Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis") .font(.system(size: 10, weight: .bold)) - .foregroundStyle(theme.accent) + .foregroundStyle(theme.contentTertiary) .padding(.horizontal, 7) .padding(.vertical, 3) - .background(theme.accent.opacity(0.10)) + .background(theme.backgroundSecondary) .clipShape(Capsule()) } } diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift index 3d075c6..3fa093e 100644 --- a/nahbar/nahbar/Models.swift +++ b/nahbar/nahbar/Models.swift @@ -41,10 +41,13 @@ enum NudgeFrequency: String, CaseIterable, Codable { enum MomentType: String, CaseIterable, Codable { case conversation = "Gespräch" - case meeting = "Treffen" + case meeting = "Treffen" // rawValue bleibt für Persistenz unverändert case thought = "Gedanke" case intention = "Vorhaben" + /// Anzeigename im UI — entkoppelt Persistenzschlüssel von der Darstellung. + var displayName: String { rawValue } + var icon: String { switch self { case .conversation: return "bubble.left" diff --git a/nahbar/nahbar/NahbarContact.swift b/nahbar/nahbar/NahbarContact.swift index 9e728c9..6157509 100644 --- a/nahbar/nahbar/NahbarContact.swift +++ b/nahbar/nahbar/NahbarContact.swift @@ -14,6 +14,10 @@ struct NahbarContact: Identifiable, Codable, Equatable { var givenName: String var familyName: String var phoneNumbers: [String] + /// E-Mail-Adressen aus dem Adressbuch (alle Labels). + var emailAddresses: [String] + /// Firma oder Organisation des Kontakts. + var organizationName: String var notes: String /// Original CNContact identifier for stable matching against the system address book. var cnIdentifier: String? @@ -23,6 +27,8 @@ struct NahbarContact: Identifiable, Codable, Equatable { givenName: String, familyName: String, phoneNumbers: [String] = [], + emailAddresses: [String] = [], + organizationName: String = "", notes: String = "", cnIdentifier: String? = nil ) { @@ -30,6 +36,8 @@ struct NahbarContact: Identifiable, Codable, Equatable { self.givenName = givenName self.familyName = familyName self.phoneNumbers = phoneNumbers + self.emailAddresses = emailAddresses + self.organizationName = organizationName self.notes = notes self.cnIdentifier = cnIdentifier } @@ -40,11 +48,34 @@ struct NahbarContact: Identifiable, Codable, Equatable { self.givenName = contact.givenName self.familyName = contact.familyName self.phoneNumbers = contact.phoneNumbers.map { $0.value.stringValue } + self.emailAddresses = contact.emailAddresses.map { $0.value as String } + self.organizationName = contact.organizationName // CNContactNoteKey requires a special entitlement – omitted intentionally. self.notes = "" self.cnIdentifier = contact.identifier } + // MARK: - Codable (rückwärtskompatibel) + // Neue Felder (emailAddresses, organizationName) mit decodeIfPresent lesen, + // damit bestehende NahbarContacts.json-Dateien ohne diese Felder weiterhin laden. + + enum CodingKeys: String, CodingKey { + case id, givenName, familyName, phoneNumbers, emailAddresses, organizationName, notes, cnIdentifier + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(UUID.self, forKey: .id) + givenName = try c.decode(String.self, forKey: .givenName) + familyName = try c.decode(String.self, forKey: .familyName) + phoneNumbers = try c.decodeIfPresent([String].self, forKey: .phoneNumbers) ?? [] + emailAddresses = try c.decodeIfPresent([String].self, forKey: .emailAddresses) ?? [] + organizationName = try c.decodeIfPresent(String.self, forKey: .organizationName) ?? "" + notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? "" + cnIdentifier = try c.decodeIfPresent(String.self, forKey: .cnIdentifier) + } + // encode(to:) wird vom Compiler synthetisiert, da alle Felder Encodable sind. + var fullName: String { [givenName, familyName].filter { !$0.isEmpty }.joined(separator: " ") } diff --git a/nahbar/nahbar/OnboardingContainerView.swift b/nahbar/nahbar/OnboardingContainerView.swift index 785e72c..c3443b8 100644 --- a/nahbar/nahbar/OnboardingContainerView.swift +++ b/nahbar/nahbar/OnboardingContainerView.swift @@ -91,6 +91,7 @@ struct OnboardingContainerView: View { UserProfileStore.shared.update( name: coordinator.firstName, birthday: nil, + gender: coordinator.gender, occupation: "", location: "", likes: "", @@ -213,6 +214,41 @@ private struct OnboardingProfileView: View { } .accessibilityLabel("Über mich, maximal 100 Zeichen") } + + // Geschlecht (optional) + VStack(alignment: .leading, spacing: 8) { + Text("Geschlecht (optional)") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + ForEach(["Männlich", "Weiblich", "Divers"], id: \.self) { option in + let selected = coordinator.gender == option + Button { + let newValue = selected ? "" : option + coordinator.gender = newValue + // Sofort persistieren, damit der Quiz-Schritt es lesen kann + UserProfileStore.shared.updateGender(newValue) + } label: { + Text(option) + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(selected + ? Color.accentColor.opacity(0.14) + : Color.secondary.opacity(0.10)) + .foregroundStyle(selected ? Color.accentColor : .primary) + .clipShape(Capsule()) + .overlay(Capsule().strokeBorder( + selected ? Color.accentColor : Color.clear, + lineWidth: 1.5)) + } + .buttonStyle(.plain) + .animation(.easeInOut(duration: 0.15), value: selected) + .accessibilityLabel(option) + .accessibilityAddTraits(selected ? .isSelected : []) + } + } + } } .padding(.horizontal, 24) @@ -634,8 +670,8 @@ struct FeatureTourStep { ), FeatureTourStep( icon: "figure.walk.arrival", - title: "Besuche", - description: "Halte fest, wen du besucht hast – und wann.", + title: "Treffen", + description: "Halte fest, wen du getroffen hast – und wann.", showPrivacySummary: false ), FeatureTourStep( diff --git a/nahbar/nahbar/OnboardingCoordinator.swift b/nahbar/nahbar/OnboardingCoordinator.swift index b4f2c2e..d7a7d9e 100644 --- a/nahbar/nahbar/OnboardingCoordinator.swift +++ b/nahbar/nahbar/OnboardingCoordinator.swift @@ -27,6 +27,7 @@ final class OnboardingCoordinator: ObservableObject { @Published var firstName: String = "" @Published var displayName: String = "" @Published var aboutMe: String = "" + @Published var gender: String = "" // MARK: – Phase 2: Contacts diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 8275dee..0edaa3c 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -258,75 +258,51 @@ struct PersonDetailView: View { .removePendingNotificationRequests(withIdentifiers: ["nextstep-\(person.id)"]) } - /// Persönlichkeitsgesteuerte Aktivitätsvorschläge für den nächsten Schritt. - /// Sortiert nach preferredActivityStyle und highlightNovelty aus PersonalityEngine. + /// Persönlichkeitsbasierter Aktivitätshinweis – ein einziger kombinierter Vorschlag. + /// Zwei passende Aktivitäten werden zu einem lesbaren String verbunden. private func nextStepSuggestionsView(profile: PersonalityProfile) -> some View { let preferred = PersonalityEngine.preferredActivityStyle(for: profile) let highlightNew = PersonalityEngine.highlightNovelty(for: profile) - // (text, icon, style, isNovel) - let activities: [(String, String, ActivityStyle?, Bool)] = [ - ("Kaffee trinken", "cup.and.saucer", .oneOnOne, false), - ("Spazieren gehen", "figure.walk", .oneOnOne, false), - ("Zusammen essen", "fork.knife", .group, false), - ("Etwas unternehmen", "person.2", .group, false), - ("Etwas Neues ausprobieren", "sparkles", nil, true), - ("Anrufen", "phone", nil, false), + // (text, style, isNovel) – kein Icon mehr nötig, da einzelne Zeile + let activities: [(String, ActivityStyle?, Bool)] = [ + ("Kaffee trinken", .oneOnOne, false), + ("Spazieren gehen", .oneOnOne, false), + ("Zusammen essen", .group, false), + ("Etwas unternehmen", .group, false), + ("Etwas Neues ausprobieren", nil, true), + ("Anrufen", nil, false), ] - // Empfohlene Aktivitäten nach oben sortieren - let sorted = activities.sorted { a, b in - func score(_ item: (String, String, ActivityStyle?, Bool)) -> Int { - var s = 0 - if item.2 == preferred { s += 2 } - if item.3 && highlightNew { s += 1 } - return s - } - return score(a) > score(b) + func score(_ item: (String, ActivityStyle?, Bool)) -> Int { + var s = 0 + if item.1 == preferred { s += 2 } + if item.2 && highlightNew { s += 1 } + return s } - let topItems = Array(sorted.prefix(3)) + let sorted = activities.sorted { score($0) > score($1) } - return VStack(spacing: 6) { - ForEach(topItems, id: \.0) { item in - let isRecommended = (item.2 == preferred) || (item.3 && highlightNew) - Button { - nextStepText = item.0 - isEditingNextStep = true - } label: { - HStack(spacing: 10) { - Image(systemName: item.1) - .font(.system(size: 14)) - .foregroundStyle(isRecommended ? NahbarInsightStyle.accentPetrol : theme.contentSecondary) - .frame(width: 20) + // Top-2 zu einem Satz kombinieren: "Kaffee trinken oder spazieren gehen" + let top = sorted.prefix(2).map { $0.0 } + let hint = top.joined(separator: " oder ") + let topActivity = sorted.first?.0 ?? "" - VStack(alignment: .leading, spacing: 3) { - Text(LocalizedStringKey(item.0)) - .font(.system(size: 14)) - .foregroundStyle(theme.contentPrimary) - if isRecommended { - RecommendedBadge(variant: .small) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 11)) - .foregroundStyle(theme.contentTertiary) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(isRecommended ? NahbarInsightStyle.accentPetrol.opacity(0.05) : theme.surfaceCard) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) - .overlay( - RoundedRectangle(cornerRadius: theme.radiusCard) - .stroke( - isRecommended ? NahbarInsightStyle.accentPetrol.opacity(0.25) : theme.borderSubtle, - lineWidth: 1 - ) - ) - } + return Button { + nextStepText = topActivity + isEditingNextStep = true + } label: { + HStack(spacing: 6) { + Image(systemName: "brain") + .font(.system(size: 11)) + .foregroundStyle(NahbarInsightStyle.accentPetrol) + Text("Idee: \(hint)") + .font(.system(size: 13)) + .foregroundStyle(theme.contentSecondary) + .lineLimit(1) } + .padding(.horizontal, 14) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/nahbar/nahbar/PersonalityComponents.swift b/nahbar/nahbar/PersonalityComponents.swift index ade75c4..15de8a2 100644 --- a/nahbar/nahbar/PersonalityComponents.swift +++ b/nahbar/nahbar/PersonalityComponents.swift @@ -16,7 +16,7 @@ struct RecommendedBadge: View { var body: some View { HStack(spacing: 4) { - Image(systemName: "sparkles") + Image(systemName: "brain") .font(NahbarInsightStyle.badgeFont) .accessibilityHidden(true) Text(labelText) diff --git a/nahbar/nahbar/PersonalityQuizView.swift b/nahbar/nahbar/PersonalityQuizView.swift index 4824dcf..6932145 100644 --- a/nahbar/nahbar/PersonalityQuizView.swift +++ b/nahbar/nahbar/PersonalityQuizView.swift @@ -13,14 +13,15 @@ struct PersonalityQuizView: View { private enum Phase: Equatable { case intro + case genderSelection case questions case result(PersonalityProfile) static func == (lhs: Phase, rhs: Phase) -> Bool { switch (lhs, rhs) { - case (.intro, .intro), (.questions, .questions): return true - case (.result, .result): return true - default: return false + case (.intro, .intro), (.genderSelection, .genderSelection), (.questions, .questions): return true + case (.result, .result): return true + default: return false } } } @@ -30,7 +31,15 @@ struct PersonalityQuizView: View { init(onComplete: @escaping (PersonalityProfile?) -> Void, skipIntro: Bool = false) { self.onComplete = onComplete self.skipIntro = skipIntro - self._phase = State(initialValue: skipIntro ? .questions : .intro) + let hasGender = !UserProfileStore.shared.gender.isEmpty + self._phase = State(initialValue: skipIntro + ? (hasGender ? .questions : .genderSelection) + : .intro) + } + + /// Nächste Phase nach dem Intro – überspringt Geschlechtsabfrage wenn bereits gesetzt. + private func nextPhaseAfterIntro() -> Phase { + UserProfileStore.shared.gender.isEmpty ? .genderSelection : .questions } var body: some View { @@ -38,10 +47,16 @@ struct PersonalityQuizView: View { switch phase { case .intro: QuizIntroScreen( - onStart: { withAnimation(.spring(response: 0.4)) { phase = .questions } }, + onStart: { withAnimation(.spring(response: 0.4)) { phase = nextPhaseAfterIntro() } }, onSkip: { onComplete(nil); dismiss() } ) + case .genderSelection: + GenderSelectionScreen( + onContinue: { withAnimation(.spring(response: 0.4)) { phase = .questions } }, + onSkip: { onComplete(nil); dismiss() } + ) + case .questions: QuizQuestionsScreen( onComplete: { profile in @@ -121,6 +136,108 @@ struct QuizIntroScreen: View { } } +// MARK: - GenderSelectionScreen + +/// Kurze Geschlechtsabfrage vor den Quiz-Fragen. +/// Speichert die Auswahl sofort in UserProfileStore (single-purpose update). +private struct GenderSelectionScreen: View { + let onContinue: () -> Void + let onSkip: () -> Void + + private let options = ["Männlich", "Weiblich", "Divers"] + + @State private var selected: String = UserProfileStore.shared.gender + + var body: some View { + VStack(spacing: 0) { + Spacer() + + Image(systemName: "person.fill") + .font(.system(size: 60)) + .foregroundStyle(NahbarInsightStyle.accentPetrol) + .accessibilityHidden(true) + .padding(.bottom, 32) + + VStack(spacing: 10) { + Text("Kurze Frage vorab") + .font(.title.bold()) + .multilineTextAlignment(.center) + + Text("Dein Geschlecht hilft, die Auswertung besser einzuordnen.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .padding(.bottom, 32) + + // Chip-Auswahl + HStack(spacing: 12) { + ForEach(options, id: \.self) { option in + let isSelected = selected == option + Button { + selected = isSelected ? "" : option + } label: { + Text(option) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(isSelected + ? NahbarInsightStyle.recommendedTint + : Color(.tertiarySystemBackground)) + .foregroundStyle(isSelected + ? NahbarInsightStyle.accentPetrol + : .primary) + .clipShape(Capsule()) + .overlay(Capsule().strokeBorder( + isSelected ? NahbarInsightStyle.accentPetrol : Color.secondary.opacity(0.2), + lineWidth: isSelected ? 2 : 1)) + } + .buttonStyle(.plain) + .animation(.easeInOut(duration: 0.15), value: isSelected) + .accessibilityLabel(option) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .padding(.horizontal, NahbarInsightStyle.horizontalPadding) + .padding(.bottom, 32) + + PrivacyBadgeView(context: .localOnly) + .padding(.horizontal, NahbarInsightStyle.horizontalPadding) + .padding(.bottom, 24) + + Spacer() + + VStack(spacing: 14) { + Button(action: advance) { + Text("Weiter") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(NahbarInsightStyle.accentPetrol) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .accessibilityLabel("Weiter zu den Fragen") + + Button(action: onSkip) { + Text("Überspringen") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .accessibilityLabel("Quiz überspringen") + } + .padding(.horizontal, NahbarInsightStyle.horizontalPadding) + .padding(.bottom, 40) + } + } + + private func advance() { + UserProfileStore.shared.updateGender(selected) + onContinue() + } +} + // MARK: - QuizQuestionsScreen private struct QuizQuestionsScreen: View { diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift index 7fc5824..b8bc6a0 100644 --- a/nahbar/nahbar/SettingsView.swift +++ b/nahbar/nahbar/SettingsView.swift @@ -56,18 +56,18 @@ struct SettingsView: View { .padding(.horizontal, 20) .padding(.top, 12) - // nahbar Pro (oben) + // Abonnement (oben) VStack(alignment: .leading, spacing: 12) { - SectionHeader(title: "nahbar Pro", icon: "star.fill") + SectionHeader(title: "Abonnement", icon: "star.fill") .padding(.horizontal, 20) - if store.isPro { + if store.isMax { HStack { VStack(alignment: .leading, spacing: 2) { - Text("Aktiv") + Text("Max aktiv") .font(.system(size: 15)) .foregroundStyle(theme.contentPrimary) - Text("Alle Pro-Features freigeschaltet") + Text("Alle Features freigeschaltet") .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) } @@ -84,10 +84,12 @@ struct SettingsView: View { Button { showPaywall = true } label: { HStack { VStack(alignment: .leading, spacing: 2) { - Text("nahbar Pro entdecken") + Text("Pro oder Max-Abo") .font(.system(size: 15, weight: .medium)) .foregroundStyle(theme.accent) - Text("KI-Analyse, Themes & mehr") + Text(store.isPro + ? "Auf Max upgraden – KI-Analyse freischalten" + : "KI-Analyse, Themes & mehr") .font(.system(size: 12)) .foregroundStyle(theme.contentTertiary) } @@ -104,7 +106,7 @@ struct SettingsView: View { } } } - .sheet(isPresented: $showPaywall) { PaywallView() } + .sheet(isPresented: $showPaywall) { PaywallView(targeting: store.isPro ? .max : .pro) } // Theme picker VStack(alignment: .leading, spacing: 12) { @@ -290,9 +292,9 @@ struct SettingsView: View { .padding(.horizontal, 20) } - // Besuche & Bewertungen + // Treffen & Bewertungen VStack(alignment: .leading, spacing: 12) { - SectionHeader(title: "Besuche", icon: "star.fill") + SectionHeader(title: "Treffen", icon: "star.fill") .padding(.horizontal, 20) VStack(spacing: 0) { @@ -337,8 +339,11 @@ struct SettingsView: View { // KI-Einstellungen VStack(alignment: .leading, spacing: 12) { - SectionHeader(title: "KI-Analyse", icon: "sparkles") - .padding(.horizontal, 20) + HStack(spacing: 8) { + SectionHeader(title: "KI-Analyse", icon: "sparkles") + MaxBadge() + } + .padding(.horizontal, 20) VStack(spacing: 0) { settingsTextField(label: "Modell", value: $aiModel, placeholder: AIConfig.fallback.model) diff --git a/nahbar/nahbar/SharedComponents.swift b/nahbar/nahbar/SharedComponents.swift index 71a94b8..517e30e 100644 --- a/nahbar/nahbar/SharedComponents.swift +++ b/nahbar/nahbar/SharedComponents.swift @@ -45,6 +45,22 @@ struct TagBadge: View { } } +// MARK: - Max Badge + +struct MaxBadge: View { + @Environment(\.nahbarTheme) var theme + + var body: some View { + Text("MAX") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(theme.accent) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(theme.accent.opacity(0.10)) + .clipShape(Capsule()) + } +} + // MARK: - Section Header struct SectionHeader: View { diff --git a/nahbar/nahbar/TodayView.swift b/nahbar/nahbar/TodayView.swift index 88f8e61..321a151 100644 --- a/nahbar/nahbar/TodayView.swift +++ b/nahbar/nahbar/TodayView.swift @@ -314,16 +314,18 @@ struct GiftSuggestionRow: View { Text("Geschenkidee vorschlagen") .font(.system(size: 13)) Spacer() - if !store.isMax { - Text(canUseAI - ? "\(AIAnalysisService.shared.freeQueriesRemaining) gratis" - : "MAX") + if store.isMax { + MaxBadge() + } else if canUseAI { + Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis") .font(.system(size: 10, weight: .bold)) - .foregroundStyle(theme.accent) + .foregroundStyle(theme.contentTertiary) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(theme.accent.opacity(0.10)) + .background(theme.backgroundSecondary) .clipShape(Capsule()) + } else { + MaxBadge() } } .foregroundStyle(canUseAI ? theme.accent : theme.contentSecondary) diff --git a/nahbar/nahbar/UserProfileStore.swift b/nahbar/nahbar/UserProfileStore.swift index 0d1c6df..f39833d 100644 --- a/nahbar/nahbar/UserProfileStore.swift +++ b/nahbar/nahbar/UserProfileStore.swift @@ -17,6 +17,7 @@ final class UserProfileStore: ObservableObject { @Published private(set) var displayName: String = "" @Published private(set) var aboutMe: String = "" @Published private(set) var birthday: Date? = nil + @Published private(set) var gender: String = "" @Published private(set) var occupation: String = "" @Published private(set) var location: String = "" @Published private(set) var likes: String = "" @@ -32,7 +33,7 @@ final class UserProfileStore: ObservableObject { var isEmpty: Bool { name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty - && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty + && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty && gender.isEmpty } var initials: String { @@ -75,11 +76,19 @@ final class UserProfileStore: ObservableObject { objectWillChange.send() } + // MARK: - Geschlecht (gezieltes Update ohne alle Felder) + + func updateGender(_ value: String) { + gender = value + save() + } + // MARK: - Update (batch, explizit durch Nutzer bestätigt) func update( name: String, birthday: Date?, + gender: String, occupation: String, location: String, likes: String, @@ -92,6 +101,7 @@ final class UserProfileStore: ObservableObject { self.displayName = displayName self.aboutMe = aboutMe self.birthday = birthday + self.gender = gender self.occupation = occupation self.location = location self.likes = likes @@ -107,6 +117,7 @@ final class UserProfileStore: ObservableObject { "name": name, "displayName": displayName, "aboutMe": aboutMe, + "gender": gender, "occupation": occupation, "location": location, "likes": likes, @@ -123,7 +134,7 @@ final class UserProfileStore: ObservableObject { func reset() { defaults.removeObject(forKey: storageKey) if let url = photoURL { try? FileManager.default.removeItem(at: url) } - name = ""; displayName = ""; aboutMe = "" + name = ""; displayName = ""; aboutMe = ""; gender = "" birthday = nil; occupation = ""; location = "" likes = ""; dislikes = ""; socialStyle = "" logger.info("UserProfile zurückgesetzt") @@ -134,6 +145,7 @@ final class UserProfileStore: ObservableObject { name = dict["name"] as? String ?? "" displayName = dict["displayName"] as? String ?? "" aboutMe = dict["aboutMe"] as? String ?? "" + gender = dict["gender"] as? String ?? "" occupation = dict["occupation"] as? String ?? "" location = dict["location"] as? String ?? "" likes = dict["likes"] as? String ?? "" diff --git a/nahbar/nahbarShareExtension/ShareExtensionView.swift b/nahbar/nahbarShareExtension/ShareExtensionView.swift index ba4982d..6b5772f 100644 --- a/nahbar/nahbarShareExtension/ShareExtensionView.swift +++ b/nahbar/nahbarShareExtension/ShareExtensionView.swift @@ -48,7 +48,7 @@ struct ShareExtensionView: View { Section("Typ") { Picker("Typ", selection: $momentType) { ForEach(MomentType.allCases, id: \.self) { type in - Label(type.rawValue, systemImage: type.icon).tag(type) + Label(type.displayName, systemImage: type.icon).tag(type) } } .pickerStyle(.segmented) diff --git a/nahbar/nahbarTests/ContactPickerTests.swift b/nahbar/nahbarTests/ContactPickerTests.swift index 5ab09dd..5b68358 100644 --- a/nahbar/nahbarTests/ContactPickerTests.swift +++ b/nahbar/nahbarTests/ContactPickerTests.swift @@ -132,6 +132,20 @@ struct ContactImportTests { #expect(ContactImport.from(contact).name == "Anna Schmidt") } + @Test("Mittelname wird in Name einbezogen") + func middleNameIncluded() { + let contact = CNMutableContact() + contact.givenName = "Max"; contact.middleName = "Otto"; contact.familyName = "Müller" + #expect(ContactImport.from(contact).name == "Max Otto Müller") + } + + @Test("Mittelname leer → kein doppeltes Leerzeichen") + func emptyMiddleNameNoGap() { + let contact = CNMutableContact() + contact.givenName = "Max"; contact.familyName = "Müller" + #expect(ContactImport.from(contact).name == "Max Müller") + } + @Test("Nur Vorname → kein Leerzeichen am Ende") func onlyFirstName() { let contact = CNMutableContact(); contact.givenName = "Cher" @@ -144,10 +158,17 @@ struct ContactImportTests { #expect(ContactImport.from(contact).name == "Prince") } - @Test("Berufsbezeichnung bevorzugt gegenüber Firma") - func jobTitlePreferredOverOrg() { + @Test("Berufsbezeichnung und Firma werden kombiniert") + func jobTitleAndOrgCombined() { let contact = CNMutableContact() contact.jobTitle = "Designer"; contact.organizationName = "ACME GmbH" + #expect(ContactImport.from(contact).occupation == "Designer · ACME GmbH") + } + + @Test("Nur Berufsbezeichnung ohne Firma → kein Trennzeichen") + func jobTitleAloneNoDot() { + let contact = CNMutableContact() + contact.jobTitle = "Designer" #expect(ContactImport.from(contact).occupation == "Designer") } @@ -171,6 +192,24 @@ struct ContactImportTests { #expect(ContactImport.from(contact).location == "Berlin, Deutschland") } + @Test("Stadt, Bundesstaat und Land → alle drei kombiniert") + func locationWithState() { + let contact = CNMutableContact() + let address = CNMutablePostalAddress() + address.city = "San Francisco"; address.state = "CA"; address.country = "USA" + contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)] + #expect(ContactImport.from(contact).location == "San Francisco, CA, USA") + } + + @Test("Nur Stadt, kein Bundesstaat → kein leerer Eintrag") + func locationCityOnly() { + let contact = CNMutableContact() + let address = CNMutablePostalAddress() + address.city = "Wien" + contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)] + #expect(ContactImport.from(contact).location == "Wien") + } + @Test("Kein Ort → leerer String") func emptyLocation() { #expect(ContactImport.from(CNMutableContact()).location == "") @@ -208,3 +247,65 @@ struct ContactImportTests { #expect(ContactImport.from(CNMutableContact()).photoData == nil) } } + +// MARK: - NahbarContact – Kontakt-Mapping + +@Suite("NahbarContact – init(from: CNContact)") +struct NahbarContactMappingTests { + + @Test("E-Mail-Adressen werden übernommen") + func emailAddressesImported() { + let contact = CNMutableContact() + contact.emailAddresses = [ + CNLabeledValue(label: CNLabelWork, value: "max@acme.de" as NSString), + CNLabeledValue(label: CNLabelHome, value: "max@privat.de" as NSString) + ] + let nc = NahbarContact(from: contact) + #expect(nc.emailAddresses == ["max@acme.de", "max@privat.de"]) + } + + @Test("Keine E-Mails → leeres Array") + func noEmailAddresses() { + let nc = NahbarContact(from: CNMutableContact()) + #expect(nc.emailAddresses.isEmpty) + } + + @Test("Firma wird übernommen") + func organizationImported() { + let contact = CNMutableContact() + contact.organizationName = "Muster GmbH" + let nc = NahbarContact(from: contact) + #expect(nc.organizationName == "Muster GmbH") + } + + @Test("Keine Firma → leerer String") + func noOrganization() { + let nc = NahbarContact(from: CNMutableContact()) + #expect(nc.organizationName == "") + } + + @Test("Codable round-trip mit neuen Feldern") + func codableRoundTrip() throws { + let original = NahbarContact( + givenName: "Lena", + familyName: "Koch", + phoneNumbers: ["+49 30 123456"], + emailAddresses: ["lena@example.com"], + organizationName: "Tech AG" + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(NahbarContact.self, from: data) + #expect(decoded.emailAddresses == ["lena@example.com"]) + #expect(decoded.organizationName == "Tech AG") + } + + @Test("Rückwärtskompatibilität: altes JSON ohne neue Felder → Defaults") + func backwardsCompatibility() throws { + let oldJSON = """ + {"id":"00000000-0000-0000-0000-000000000001","givenName":"Alt","familyName":"Daten","phoneNumbers":[],"notes":""} + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(NahbarContact.self, from: oldJSON) + #expect(decoded.emailAddresses.isEmpty) + #expect(decoded.organizationName == "") + } +} diff --git a/nahbar/nahbarTests/ModelTests.swift b/nahbar/nahbarTests/ModelTests.swift index c6f1612..8f67894 100644 --- a/nahbar/nahbarTests/ModelTests.swift +++ b/nahbar/nahbarTests/ModelTests.swift @@ -90,6 +90,21 @@ struct MomentTypeTests { #expect(parsed == type_) } } + + @Test("displayName ist für alle Types gleich rawValue") + func displayNameEqualsRawValueForAllTypes() { + for type_ in MomentType.allCases { + #expect(type_.displayName == type_.rawValue, + "displayName sollte rawValue sein für \(type_)") + } + } + + @Test("alle Types haben nicht-leeres displayName") + func allTypesHaveNonEmptyDisplayName() { + for type_ in MomentType.allCases { + #expect(!type_.displayName.isEmpty) + } + } } // MARK: - MomentSource Tests diff --git a/nahbar/nahbarTests/NahbarPersonalityTests.swift b/nahbar/nahbarTests/NahbarPersonalityTests.swift index a80016f..7bed747 100644 --- a/nahbar/nahbarTests/NahbarPersonalityTests.swift +++ b/nahbar/nahbarTests/NahbarPersonalityTests.swift @@ -383,7 +383,7 @@ struct PersonalityEngineBehaviorTests { @Test("Hoher Neurotizismus → verzögerter Rating-Prompt (7200s)") func highNeuroticismGivesDelayedPrompt() { - let p = profile(c: .low, n: .high) + let p = profile(n: .high, c: .low) if case .delayed(let secs, _) = PersonalityEngine.ratingPromptTiming(for: p) { #expect(secs == 7200) } else { @@ -429,6 +429,34 @@ struct PersonalityEngineBehaviorTests { } } +// MARK: - GenderSelectionScreen Skip-Logik + +@Suite("PersonalityQuiz – Geschlechtsabfrage überspringen") +struct PersonalityQuizGenderSkipTests { + + @Test("Wenn Gender gesetzt → GenderSelectionScreen wird übersprungen (geht zu questions)") + func genderSetLeadsToQuestionsPhase() { + // Spiegelt die nextPhaseAfterIntro()-Logik wider + let gender = "Weiblich" + let shouldSkip = !gender.isEmpty + #expect(shouldSkip) + } + + @Test("Wenn Gender leer → GenderSelectionScreen wird angezeigt") + func emptyGenderShowsSelectionScreen() { + let gender = "" + let shouldShow = gender.isEmpty + #expect(shouldShow) + } + + @Test("Alle drei validen Werte überspringen den Screen") + func allValidGenderValuesSkipScreen() { + for gender in ["Männlich", "Weiblich", "Divers"] { + #expect(!gender.isEmpty, "\(gender) sollte Screen überspringen") + } + } +} + // MARK: - OnboardingStep – Regressionswächter (nach Quiz-Erweiterung) @Suite("OnboardingStep – RawValues (Quiz-Erweiterung)") diff --git a/nahbar/nahbarTests/OnboardingTests.swift b/nahbar/nahbarTests/OnboardingTests.swift index 319e5d3..ab82839 100644 --- a/nahbar/nahbarTests/OnboardingTests.swift +++ b/nahbar/nahbarTests/OnboardingTests.swift @@ -14,6 +14,29 @@ struct OnboardingCoordinatorValidationTests { #expect(!coord.isProfileValid) } + @Test("gender startet als leerer String") + @MainActor func genderStartsEmpty() { + let coord = OnboardingCoordinator() + #expect(coord.gender.isEmpty) + } + + @Test("gender kann auf Männlich, Weiblich oder Divers gesetzt werden") + @MainActor func genderAcceptsValidValues() { + let coord = OnboardingCoordinator() + for value in ["Männlich", "Weiblich", "Divers"] { + coord.gender = value + #expect(coord.gender == value) + } + } + + @Test("gender kann zurückgesetzt werden") + @MainActor func genderCanBeCleared() { + let coord = OnboardingCoordinator() + coord.gender = "Männlich" + coord.gender = "" + #expect(coord.gender.isEmpty) + } + @Test("Nur Leerzeichen → isProfileValid ist false") @MainActor func whitespaceFirstNameIsInvalid() { let coord = OnboardingCoordinator() @@ -46,6 +69,40 @@ struct OnboardingCoordinatorNavigationTests { #expect(coord.currentStep == .profile) } + @Test("advanceToQuiz ohne Vorname bleibt auf .profile") + @MainActor func advanceToQuizWithoutNameStaysOnProfile() { + let coord = OnboardingCoordinator() + coord.firstName = "" + coord.advanceToQuiz() + #expect(coord.currentStep == .profile) + } + + @Test("advanceToQuiz mit gültigem Vorname → .quiz") + @MainActor func advanceToQuizWithNameGoesToQuiz() { + let coord = OnboardingCoordinator() + coord.firstName = "Anna" + coord.advanceToQuiz() + #expect(coord.currentStep == .quiz) + } + + @Test("skipQuiz überspring Quiz und geht zu .contacts") + @MainActor func skipQuizGoesToContacts() { + let coord = OnboardingCoordinator() + coord.firstName = "Anna" + coord.advanceToQuiz() + coord.skipQuiz() + #expect(coord.currentStep == .contacts) + } + + @Test("advanceFromQuizToContacts → .contacts") + @MainActor func advanceFromQuizToContacts() { + let coord = OnboardingCoordinator() + coord.firstName = "Anna" + coord.advanceToQuiz() + coord.advanceFromQuizToContacts() + #expect(coord.currentStep == .contacts) + } + @Test("advanceToContacts ohne Vorname bleibt auf .profile") @MainActor func advanceToContactsWithoutNameStaysOnProfile() { let coord = OnboardingCoordinator() diff --git a/nahbar/nahbarTests/StoreTests.swift b/nahbar/nahbarTests/StoreTests.swift index deb121c..93380d3 100644 --- a/nahbar/nahbarTests/StoreTests.swift +++ b/nahbar/nahbarTests/StoreTests.swift @@ -161,3 +161,32 @@ struct AppGroupProStatusTests { #expect(testDefaults.bool(forKey: "isPro") == false) } } + +// MARK: - Paywall-Targeting Tests + +/// Dokumentiert die Logik aus SettingsView: +/// PaywallView(targeting: store.isPro ? .max : .pro) +/// Stellt sicher, dass der Einstiegs-Tab beim Öffnen des Paywalls korrekt ist. +@Suite("Paywall – Ziel-Tier basierend auf isPro") +struct PaywallTargetingTests { + + /// Repliziert die einzeilige Entscheidungslogik aus SettingsView. + private func target(isPro: Bool) -> SubscriptionTier { + isPro ? .max : .pro + } + + @Test("Kein Abo → Paywall öffnet Pro-Tab") + func noSubscriptionTargetsPro() { + #expect(target(isPro: false) == .pro) + } + + @Test("Pro-only → Paywall öffnet Max-Tab (Upgrade-Pfad)") + func proOnlyTargetsMax() { + #expect(target(isPro: true) == .max) + } + + @Test("Ziel-Tiers sind unterschiedlich") + func targetsAreDistinct() { + #expect(target(isPro: false) != target(isPro: true)) + } +} diff --git a/nahbar/nahbarTests/UserProfileStoreTests.swift b/nahbar/nahbarTests/UserProfileStoreTests.swift index 60c42fb..76485d1 100644 --- a/nahbar/nahbarTests/UserProfileStoreTests.swift +++ b/nahbar/nahbarTests/UserProfileStoreTests.swift @@ -64,14 +64,14 @@ struct UserProfileStoreInitialsTests { struct UserProfileStoreIsEmptyTests { // isEmpty = name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty - // && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty + // && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty && gender.isEmpty private func isEmpty(name: String = "", displayName: String = "", occupation: String = "", location: String = "", likes: String = "", dislikes: String = "", - socialStyle: String = "") -> Bool { + socialStyle: String = "", gender: String = "") -> Bool { name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty - && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty + && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty && gender.isEmpty } @Test("Alle Felder leer → isEmpty ist true") @@ -115,9 +115,14 @@ struct UserProfileStoreIsEmptyTests { #expect(!isEmpty(socialStyle: "Introvertiert")) } + @Test("Nur Geschlecht gesetzt → isEmpty ist false") + func onlyGenderSetIsFalse() { + #expect(!isEmpty(gender: "Weiblich")) + } + @Test("Alle Vorlieben-Felder leer + Rest leer → isEmpty ist true") func allVorliebFieldsEmptyStillEmpty() { - #expect(isEmpty(likes: "", dislikes: "", socialStyle: "")) + #expect(isEmpty(likes: "", dislikes: "", socialStyle: "", gender: "")) } } @@ -164,4 +169,19 @@ struct UserProfileStoreNewFieldsTests { let items = "".split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } #expect(items.isEmpty) } + + @Test("Geschlechtsoptionen enthalten Männlich, Weiblich, Divers") + func genderOptionsContainExpectedValues() { + let options = ["Männlich", "Weiblich", "Divers"] + #expect(options.contains("Männlich")) + #expect(options.contains("Weiblich")) + #expect(options.contains("Divers")) + #expect(options.count == 3) + } + + @Test("Geschlechtsoptionen sind eindeutig") + func genderOptionsUnique() { + let options = ["Männlich", "Weiblich", "Divers"] + #expect(Set(options).count == options.count) + } }