Onboarding erweitert, Geschlecht hinzugefügt...

This commit is contained in:
2026-04-19 15:28:05 +02:00
parent 1c770c42d2
commit a776992f0c
27 changed files with 675 additions and 285 deletions
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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)
+28 -22
View File
@@ -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
+11 -4
View File
@@ -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 = ""
}
+29 -122
View File
@@ -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),
+50 -28
View File
@@ -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" : {
+7 -8
View File
@@ -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())
}
}
+4 -1
View File
@@ -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"
+31
View File
@@ -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: " ")
}
+38 -2
View File
@@ -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(
@@ -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
+35 -59
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
+122 -5
View File
@@ -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 {
+17 -12
View File
@@ -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)
+16
View File
@@ -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 {
+8 -6
View File
@@ -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)
+14 -2
View File
@@ -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 ?? ""
@@ -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)
+103 -2
View File
@@ -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 == "")
}
}
+15
View File
@@ -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
@@ -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)")
+57
View File
@@ -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()
+29
View File
@@ -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))
}
}
+24 -4
View File
@@ -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)
}
}