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) { VStack(alignment: .leading, spacing: 12) {
// Header // Header
HStack { HStack {
SectionHeader(title: "Besuche", icon: "star.fill") SectionHeader(title: "Treffen", icon: "star.fill")
Spacer() Spacer()
Button { Button {
showingVisitRating = true showingVisitRating = true
@@ -38,7 +38,7 @@ struct VisitHistorySection: View {
.font(.title3) .font(.title3)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Noch keine Besuche bewertet") Text("Noch keine Treffen bewertet")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("Tippe auf + um loszulegen") Text("Tippe auf + um loszulegen")
+2 -2
View File
@@ -576,7 +576,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.4; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
@@ -635,7 +635,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.4; IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
+1 -1
View File
@@ -53,7 +53,7 @@ struct AddMomentView: View {
HStack(spacing: 5) { HStack(spacing: 5) {
Image(systemName: type.icon) Image(systemName: type.icon)
.font(.system(size: 12)) .font(.system(size: 12))
Text(type.rawValue) Text(LocalizedStringKey(type.displayName))
.font(.system(size: 13, weight: selectedType == type ? .medium : .regular)) .font(.system(size: 13, weight: selectedType == type ? .medium : .regular))
} }
.foregroundStyle(selectedType == type ? theme.accent : theme.contentSecondary) .foregroundStyle(selectedType == type ? theme.accent : theme.contentSecondary)
+28 -22
View File
@@ -127,33 +127,39 @@ struct AddPersonView: View {
} }
// Nudge frequency // Nudge frequency
formSection("Wie oft erinnern?") { formSection("Wie oft melden?") {
VStack(spacing: 0) { VStack(alignment: .leading, spacing: 8) {
ForEach(NudgeFrequency.allCases, id: \.self) { freq in Text("Nahbar erinnert dich, wenn du diese Person seit der gewählten Zeit nicht mehr kontaktiert hast.")
Button { .font(.system(size: 12))
nudgeFrequency = freq .foregroundStyle(theme.contentTertiary)
} label: {
HStack { VStack(spacing: 0) {
Text(freq.rawValue) ForEach(NudgeFrequency.allCases, id: \.self) { freq in
.font(.system(size: 15)) Button {
.foregroundStyle(theme.contentPrimary) nudgeFrequency = freq
Spacer() } label: {
if nudgeFrequency == freq { HStack {
Image(systemName: "checkmark") Text(freq.rawValue)
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 15))
.foregroundStyle(theme.accent) .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 // Delete link only in edit mode
+11 -4
View File
@@ -218,13 +218,19 @@ struct ContactImport {
let photoData: Data? let photoData: Data?
static func from(_ contact: CNContact) -> ContactImport { 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: " ") let name = parts.joined(separator: " ")
// Berufsbezeichnung und Firma kombinieren wenn beide vorhanden
let occupation: String 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 occupation = contact.jobTitle
} else if !contact.organizationName.isEmpty { } else if hasOrg {
occupation = contact.organizationName occupation = contact.organizationName
} else { } else {
occupation = "" occupation = ""
@@ -232,7 +238,8 @@ struct ContactImport {
let location: String let location: String
if let postal = contact.postalAddresses.first?.value { 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 { } else {
location = "" location = ""
} }
+29 -122
View File
@@ -1,6 +1,5 @@
import SwiftUI import SwiftUI
import PhotosUI import PhotosUI
import Contacts
import SwiftData import SwiftData
private let socialStyleOptions = [ private let socialStyleOptions = [
@@ -15,15 +14,12 @@ private let socialStyleOptions = [
struct IchView: View { struct IchView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var profileStore: UserProfileStore @EnvironmentObject var profileStore: UserProfileStore
@StateObject private var personalityStore = PersonalityStore.shared @StateObject private var personalityStore = PersonalityStore.shared
@State private var profilePhoto: UIImage? = nil @State private var profilePhoto: UIImage? = nil
@State private var showingEdit = false @State private var showingEdit = false
@State private var showingImportPicker = false
@State private var importFeedback: String? = nil
@State private var showingQuiz = false @State private var showingQuiz = false
@State private var showingPersonalityDetail = false @State private var showingPersonalityDetail = false
@@ -35,7 +31,6 @@ struct IchView: View {
if !profileStore.isEmpty { infoSection } if !profileStore.isEmpty { infoSection }
if profileStore.isEmpty { emptyState } if profileStore.isEmpty { emptyState }
personalitySection personalitySection
importKontakteSection
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 12) .padding(.top, 12)
@@ -53,30 +48,6 @@ struct IchView: View {
.onAppear { .onAppear {
profilePhoto = profileStore.loadPhoto() 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) { .sheet(isPresented: $showingQuiz) {
PersonalityQuizView { _ in PersonalityQuizView { _ in
showingQuiz = false showingQuiz = false
@@ -197,9 +168,14 @@ struct IchView: View {
private var infoSection: some View { private var infoSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
// Über mich // Ü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") SectionHeader(title: "Über mich", icon: "person")
VStack(spacing: 0) { 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 { if !profileStore.location.isEmpty {
infoRow(label: "Wohnort", value: profileStore.location) infoRow(label: "Wohnort", value: profileStore.location)
if !profileStore.socialStyle.isEmpty { RowDivider() } if !profileStore.socialStyle.isEmpty { RowDivider() }
@@ -297,58 +273,6 @@ struct IchView: View {
.padding(.top, 12) .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 // MARK: - IchEditView
@@ -361,6 +285,7 @@ struct IchEditView: View {
@State private var name: String @State private var name: String
@State private var hasBirthday: Bool @State private var hasBirthday: Bool
@State private var birthday: Date @State private var birthday: Date
@State private var gender: String
@State private var occupation: String @State private var occupation: String
@State private var location: String @State private var location: String
@State private var likes: String @State private var likes: String
@@ -368,13 +293,13 @@ struct IchEditView: View {
@State private var socialStyle: String @State private var socialStyle: String
@State private var selectedPhoto: UIImage? @State private var selectedPhoto: UIImage?
@State private var photoPickerItem: PhotosPickerItem? = nil @State private var photoPickerItem: PhotosPickerItem? = nil
@State private var showingContactPicker = false
init() { init() {
let store = UserProfileStore.shared let store = UserProfileStore.shared
_name = State(initialValue: store.name) _name = State(initialValue: store.name)
_hasBirthday = State(initialValue: store.birthday != nil) _hasBirthday = State(initialValue: store.birthday != nil)
_birthday = State(initialValue: store.birthday ?? IchEditView.defaultBirthday) _birthday = State(initialValue: store.birthday ?? IchEditView.defaultBirthday)
_gender = State(initialValue: store.gender)
_occupation = State(initialValue: store.occupation) _occupation = State(initialValue: store.occupation)
_location = State(initialValue: store.location) _location = State(initialValue: store.location)
_likes = State(initialValue: store.likes) _likes = State(initialValue: store.likes)
@@ -395,9 +320,6 @@ struct IchEditView: View {
// Foto // Foto
photoSection photoSection
// Kontakt-Import
importButton
// Name // Name
formSection("Name") { formSection("Name") {
TextField("Wie heißt du?", text: $name) TextField("Wie heißt du?", text: $name)
@@ -439,6 +361,8 @@ struct IchEditView: View {
// Details // Details
formSection("Details") { formSection("Details") {
VStack(spacing: 0) { VStack(spacing: 0) {
genderPickerRow
Divider().padding(.leading, 16)
inlineField("Beruf", text: $occupation) inlineField("Beruf", text: $occupation)
Divider().padding(.leading, 16) Divider().padding(.leading, 16)
inlineField("Wohnort", text: $location) 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 // MARK: - Photo Section
@@ -562,27 +481,28 @@ struct IchEditView: View {
return name.isEmpty ? "?" : String(name.prefix(2)).uppercased() return name.isEmpty ? "?" : String(name.prefix(2)).uppercased()
} }
// MARK: - Kontakt-Import // MARK: - Gender Picker
private var importButton: some View { private let genderOptions = ["Männlich", "Weiblich", "Divers", "Keine Angabe"]
Button { showingContactPicker = true } label: {
HStack(spacing: 10) { private var genderPickerRow: some View {
Image(systemName: "person.crop.circle.badge.plus") HStack(spacing: 12) {
.font(.system(size: 15)) Text("Geschlecht")
Text("Aus Kontakten übernehmen") .font(.system(size: 15))
.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) .pickerStyle(.menu)
.padding(.horizontal, 14) .tint(theme.accent)
.padding(.vertical, 11)
.frame(maxWidth: .infinity, alignment: .leading) .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 // MARK: - Helpers
@@ -614,24 +534,11 @@ struct IchEditView: View {
.padding(.vertical, 12) .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() { private func save() {
profileStore.update( profileStore.update(
name: name.trimmingCharacters(in: .whitespaces), name: name.trimmingCharacters(in: .whitespaces),
birthday: hasBirthday ? birthday : nil, birthday: hasBirthday ? birthday : nil,
gender: gender,
occupation: occupation.trimmingCharacters(in: .whitespaces), occupation: occupation.trimmingCharacters(in: .whitespaces),
location: location.trimmingCharacters(in: .whitespaces), location: location.trimmingCharacters(in: .whitespaces),
likes: likes.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." : { "Abonnement verlängert sich automatisch. In den iPhone-Einstellungen jederzeit kündbar." : {
"comment" : "PaywallView subscription legal notice", "comment" : "PaywallView subscription legal notice",
@@ -353,6 +356,9 @@
}, },
"Alle %lld Tage basierend auf deinem Profil" : { "Alle %lld Tage basierend auf deinem Profil" : {
},
"Alle Features freigeschaltet" : {
}, },
"Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht." : { "Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht." : {
"comment" : "AddPersonView delete confirmation message", "comment" : "AddPersonView delete confirmation message",
@@ -370,6 +376,7 @@
}, },
"Alle Pro-Features freigeschaltet" : { "Alle Pro-Features freigeschaltet" : {
"comment" : "SettingsView Pro subscription active subtitle", "comment" : "SettingsView Pro subscription active subtitle",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -592,7 +599,7 @@
} }
} }
}, },
"Aus Adressbuch hinzufügen" : { "Auf Max upgraden KI-Analyse freischalten" : {
}, },
"Aus Kontakten ausfüllen" : { "Aus Kontakten ausfüllen" : {
@@ -609,10 +616,6 @@
} }
} }
}, },
"Aus Kontakten übernehmen" : {
"comment" : "A button that allows the user to import contacts.",
"isCommentAutoGenerated" : true
},
"Ausgeglichen" : { "Ausgeglichen" : {
"comment" : "IchView social style option (ambiverted)", "comment" : "IchView social style option (ambiverted)",
"extractionState" : "stale", "extractionState" : "stale",
@@ -683,6 +686,7 @@
}, },
"Besuche" : { "Besuche" : {
"comment" : "VisitHistorySection / SettingsView section header for visits", "comment" : "VisitHistorySection / SettingsView section header for visits",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -988,6 +992,9 @@
} }
} }
} }
},
"Dein Geschlecht hilft, die Auswertung besser einzuordnen." : {
}, },
"Dein nächstes Gespräch kann hier beginnen." : { "Dein nächstes Gespräch kann hier beginnen." : {
"comment" : "PersonDetailView moments empty state message", "comment" : "PersonDetailView moments empty state message",
@@ -1918,6 +1925,12 @@
} }
} }
} }
},
"Geschlecht" : {
},
"Geschlecht (optional)" : {
}, },
"Gesellig" : { "Gesellig" : {
"extractionState" : "stale", "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?" : { "Hat sich deine Sicht auf die Person verändert?" : {
@@ -2155,6 +2168,9 @@
} }
} }
} }
},
"Idee: %@" : {
}, },
"Ideen werden generiert…" : { "Ideen werden generiert…" : {
"comment" : "TodayView GiftSuggestionRow loading state text", "comment" : "TodayView GiftSuggestionRow loading state text",
@@ -2457,18 +2473,12 @@
}, },
"Kontakte aus Adressbuch auswählen" : { "Kontakte aus Adressbuch auswählen" : {
},
"Kontakte aus Adressbuch hinzufügen" : {
}, },
"Kontakte auswählen" : { "Kontakte auswählen" : {
}, },
"Kontakte hinzufügen" : { "Kontakte hinzufügen" : {
},
"Kontakte importieren" : {
}, },
"Kontakte überspringen" : { "Kontakte überspringen" : {
@@ -2509,6 +2519,9 @@
} }
} }
} }
},
"Kurze Frage vorab" : {
}, },
"Limit erreicht" : { "Limit erreicht" : {
"comment" : "LogbuchView AI refresh button label when at request limit", "comment" : "LogbuchView AI refresh button label when at request limit",
@@ -2613,6 +2626,9 @@
} }
} }
} }
},
"Max aktiv" : {
}, },
"Menschen" : { "Menschen" : {
"comment" : "Tab label for people list", "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." : { "nahbar erinnert dich, wenn du lange nichts von jemandem gehört hast." : {
@@ -2994,12 +3013,10 @@
} }
} }
} }
},
"nahbar Pro" : {
}, },
"nahbar Pro entdecken" : { "nahbar Pro entdecken" : {
"comment" : "SettingsView Pro upsell button title", "comment" : "SettingsView Pro upsell button title",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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" : { "Noch keine Einträge" : {
"comment" : "LogbuchView empty state title", "comment" : "LogbuchView empty state title",
"localizations" : { "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." : { "Noch nichts festgehalten. Dein nächstes Gespräch kann hier beginnen." : {
"comment" : "PersonDetailView moments empty state", "comment" : "PersonDetailView moments empty state",
"localizations" : { "localizations" : {
@@ -3384,6 +3401,9 @@
} }
} }
} }
},
"Pro oder Max-Abo" : {
}, },
"Profil bearbeiten" : { "Profil bearbeiten" : {
"comment" : "The title of the screen where a user can edit their profile.", "comment" : "The title of the screen where a user can edit their profile.",
@@ -3804,8 +3824,7 @@
} }
}, },
"Treffen" : { "Treffen" : {
"comment" : "MomentType.meeting raw value", "comment" : "MomentType.meeting rawValue + VisitHistorySection / SettingsView section header",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -4243,6 +4262,9 @@
}, },
"Weiter (%lld ausgewählt)" : { "Weiter (%lld ausgewählt)" : {
},
"Weiter zu den Fragen" : {
}, },
"Weiter zum nächsten Schritt" : { "Weiter zum nächsten Schritt" : {
+7 -8
View File
@@ -40,7 +40,7 @@ private enum LogbuchItem: Identifiable {
var label: String { var label: String {
switch self { 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 case .logEntry(let e): return e.type.rawValue
} }
} }
@@ -183,7 +183,7 @@ struct LogbuchView: View {
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
Text(item.label) Text(LocalizedStringKey(item.label))
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
Text("·") Text("·")
@@ -230,15 +230,14 @@ struct LogbuchView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) { HStack(spacing: 6) {
SectionHeader(title: "KI-Auswertung", icon: "sparkles") SectionHeader(title: "KI-Auswertung", icon: "sparkles")
if !store.isMax { MaxBadge()
Text(canUseAI if !store.isMax && canUseAI {
? "\(AIAnalysisService.shared.freeQueriesRemaining) gratis" Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
: "MAX")
.font(.system(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.accent) .foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 7) .padding(.horizontal, 7)
.padding(.vertical, 3) .padding(.vertical, 3)
.background(theme.accent.opacity(0.10)) .background(theme.backgroundSecondary)
.clipShape(Capsule()) .clipShape(Capsule())
} }
} }
+4 -1
View File
@@ -41,10 +41,13 @@ enum NudgeFrequency: String, CaseIterable, Codable {
enum MomentType: String, CaseIterable, Codable { enum MomentType: String, CaseIterable, Codable {
case conversation = "Gespräch" case conversation = "Gespräch"
case meeting = "Treffen" case meeting = "Treffen" // rawValue bleibt für Persistenz unverändert
case thought = "Gedanke" case thought = "Gedanke"
case intention = "Vorhaben" case intention = "Vorhaben"
/// Anzeigename im UI entkoppelt Persistenzschlüssel von der Darstellung.
var displayName: String { rawValue }
var icon: String { var icon: String {
switch self { switch self {
case .conversation: return "bubble.left" case .conversation: return "bubble.left"
+31
View File
@@ -14,6 +14,10 @@ struct NahbarContact: Identifiable, Codable, Equatable {
var givenName: String var givenName: String
var familyName: String var familyName: String
var phoneNumbers: [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 var notes: String
/// Original CNContact identifier for stable matching against the system address book. /// Original CNContact identifier for stable matching against the system address book.
var cnIdentifier: String? var cnIdentifier: String?
@@ -23,6 +27,8 @@ struct NahbarContact: Identifiable, Codable, Equatable {
givenName: String, givenName: String,
familyName: String, familyName: String,
phoneNumbers: [String] = [], phoneNumbers: [String] = [],
emailAddresses: [String] = [],
organizationName: String = "",
notes: String = "", notes: String = "",
cnIdentifier: String? = nil cnIdentifier: String? = nil
) { ) {
@@ -30,6 +36,8 @@ struct NahbarContact: Identifiable, Codable, Equatable {
self.givenName = givenName self.givenName = givenName
self.familyName = familyName self.familyName = familyName
self.phoneNumbers = phoneNumbers self.phoneNumbers = phoneNumbers
self.emailAddresses = emailAddresses
self.organizationName = organizationName
self.notes = notes self.notes = notes
self.cnIdentifier = cnIdentifier self.cnIdentifier = cnIdentifier
} }
@@ -40,11 +48,34 @@ struct NahbarContact: Identifiable, Codable, Equatable {
self.givenName = contact.givenName self.givenName = contact.givenName
self.familyName = contact.familyName self.familyName = contact.familyName
self.phoneNumbers = contact.phoneNumbers.map { $0.value.stringValue } 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. // CNContactNoteKey requires a special entitlement omitted intentionally.
self.notes = "" self.notes = ""
self.cnIdentifier = contact.identifier 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 { var fullName: String {
[givenName, familyName].filter { !$0.isEmpty }.joined(separator: " ") [givenName, familyName].filter { !$0.isEmpty }.joined(separator: " ")
} }
+38 -2
View File
@@ -91,6 +91,7 @@ struct OnboardingContainerView: View {
UserProfileStore.shared.update( UserProfileStore.shared.update(
name: coordinator.firstName, name: coordinator.firstName,
birthday: nil, birthday: nil,
gender: coordinator.gender,
occupation: "", occupation: "",
location: "", location: "",
likes: "", likes: "",
@@ -213,6 +214,41 @@ private struct OnboardingProfileView: View {
} }
.accessibilityLabel("Über mich, maximal 100 Zeichen") .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) .padding(.horizontal, 24)
@@ -634,8 +670,8 @@ struct FeatureTourStep {
), ),
FeatureTourStep( FeatureTourStep(
icon: "figure.walk.arrival", icon: "figure.walk.arrival",
title: "Besuche", title: "Treffen",
description: "Halte fest, wen du besucht hast und wann.", description: "Halte fest, wen du getroffen hast und wann.",
showPrivacySummary: false showPrivacySummary: false
), ),
FeatureTourStep( FeatureTourStep(
@@ -27,6 +27,7 @@ final class OnboardingCoordinator: ObservableObject {
@Published var firstName: String = "" @Published var firstName: String = ""
@Published var displayName: String = "" @Published var displayName: String = ""
@Published var aboutMe: String = "" @Published var aboutMe: String = ""
@Published var gender: String = ""
// MARK: Phase 2: Contacts // MARK: Phase 2: Contacts
+35 -59
View File
@@ -258,75 +258,51 @@ struct PersonDetailView: View {
.removePendingNotificationRequests(withIdentifiers: ["nextstep-\(person.id)"]) .removePendingNotificationRequests(withIdentifiers: ["nextstep-\(person.id)"])
} }
/// Persönlichkeitsgesteuerte Aktivitätsvorschläge für den nächsten Schritt. /// Persönlichkeitsbasierter Aktivitätshinweis ein einziger kombinierter Vorschlag.
/// Sortiert nach preferredActivityStyle und highlightNovelty aus PersonalityEngine. /// Zwei passende Aktivitäten werden zu einem lesbaren String verbunden.
private func nextStepSuggestionsView(profile: PersonalityProfile) -> some View { private func nextStepSuggestionsView(profile: PersonalityProfile) -> some View {
let preferred = PersonalityEngine.preferredActivityStyle(for: profile) let preferred = PersonalityEngine.preferredActivityStyle(for: profile)
let highlightNew = PersonalityEngine.highlightNovelty(for: profile) let highlightNew = PersonalityEngine.highlightNovelty(for: profile)
// (text, icon, style, isNovel) // (text, style, isNovel) kein Icon mehr nötig, da einzelne Zeile
let activities: [(String, String, ActivityStyle?, Bool)] = [ let activities: [(String, ActivityStyle?, Bool)] = [
("Kaffee trinken", "cup.and.saucer", .oneOnOne, false), ("Kaffee trinken", .oneOnOne, false),
("Spazieren gehen", "figure.walk", .oneOnOne, false), ("Spazieren gehen", .oneOnOne, false),
("Zusammen essen", "fork.knife", .group, false), ("Zusammen essen", .group, false),
("Etwas unternehmen", "person.2", .group, false), ("Etwas unternehmen", .group, false),
("Etwas Neues ausprobieren", "sparkles", nil, true), ("Etwas Neues ausprobieren", nil, true),
("Anrufen", "phone", nil, false), ("Anrufen", nil, false),
] ]
// Empfohlene Aktivitäten nach oben sortieren func score(_ item: (String, ActivityStyle?, Bool)) -> Int {
let sorted = activities.sorted { a, b in var s = 0
func score(_ item: (String, String, ActivityStyle?, Bool)) -> Int { if item.1 == preferred { s += 2 }
var s = 0 if item.2 && highlightNew { s += 1 }
if item.2 == preferred { s += 2 } return s
if item.3 && highlightNew { s += 1 }
return s
}
return score(a) > score(b)
} }
let topItems = Array(sorted.prefix(3)) let sorted = activities.sorted { score($0) > score($1) }
return VStack(spacing: 6) { // Top-2 zu einem Satz kombinieren: "Kaffee trinken oder spazieren gehen"
ForEach(topItems, id: \.0) { item in let top = sorted.prefix(2).map { $0.0 }
let isRecommended = (item.2 == preferred) || (item.3 && highlightNew) let hint = top.joined(separator: " oder ")
Button { let topActivity = sorted.first?.0 ?? ""
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)
VStack(alignment: .leading, spacing: 3) { return Button {
Text(LocalizedStringKey(item.0)) nextStepText = topActivity
.font(.system(size: 14)) isEditingNextStep = true
.foregroundStyle(theme.contentPrimary) } label: {
if isRecommended { HStack(spacing: 6) {
RecommendedBadge(variant: .small) Image(systemName: "brain")
} .font(.system(size: 11))
} .foregroundStyle(NahbarInsightStyle.accentPetrol)
Text("Idee: \(hint)")
Spacer() .font(.system(size: 13))
.foregroundStyle(theme.contentSecondary)
Image(systemName: "chevron.right") .lineLimit(1)
.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
)
)
}
} }
.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 { var body: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "sparkles") Image(systemName: "brain")
.font(NahbarInsightStyle.badgeFont) .font(NahbarInsightStyle.badgeFont)
.accessibilityHidden(true) .accessibilityHidden(true)
Text(labelText) Text(labelText)
+122 -5
View File
@@ -13,14 +13,15 @@ struct PersonalityQuizView: View {
private enum Phase: Equatable { private enum Phase: Equatable {
case intro case intro
case genderSelection
case questions case questions
case result(PersonalityProfile) case result(PersonalityProfile)
static func == (lhs: Phase, rhs: Phase) -> Bool { static func == (lhs: Phase, rhs: Phase) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.intro, .intro), (.questions, .questions): return true case (.intro, .intro), (.genderSelection, .genderSelection), (.questions, .questions): return true
case (.result, .result): return true case (.result, .result): return true
default: return false default: return false
} }
} }
} }
@@ -30,7 +31,15 @@ struct PersonalityQuizView: View {
init(onComplete: @escaping (PersonalityProfile?) -> Void, skipIntro: Bool = false) { init(onComplete: @escaping (PersonalityProfile?) -> Void, skipIntro: Bool = false) {
self.onComplete = onComplete self.onComplete = onComplete
self.skipIntro = skipIntro 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 { var body: some View {
@@ -38,10 +47,16 @@ struct PersonalityQuizView: View {
switch phase { switch phase {
case .intro: case .intro:
QuizIntroScreen( QuizIntroScreen(
onStart: { withAnimation(.spring(response: 0.4)) { phase = .questions } }, onStart: { withAnimation(.spring(response: 0.4)) { phase = nextPhaseAfterIntro() } },
onSkip: { onComplete(nil); dismiss() } onSkip: { onComplete(nil); dismiss() }
) )
case .genderSelection:
GenderSelectionScreen(
onContinue: { withAnimation(.spring(response: 0.4)) { phase = .questions } },
onSkip: { onComplete(nil); dismiss() }
)
case .questions: case .questions:
QuizQuestionsScreen( QuizQuestionsScreen(
onComplete: { profile in 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 // MARK: - QuizQuestionsScreen
private struct QuizQuestionsScreen: View { private struct QuizQuestionsScreen: View {
+17 -12
View File
@@ -56,18 +56,18 @@ struct SettingsView: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 12) .padding(.top, 12)
// nahbar Pro (oben) // Abonnement (oben)
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "nahbar Pro", icon: "star.fill") SectionHeader(title: "Abonnement", icon: "star.fill")
.padding(.horizontal, 20) .padding(.horizontal, 20)
if store.isPro { if store.isMax {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Aktiv") Text("Max aktiv")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("Alle Pro-Features freigeschaltet") Text("Alle Features freigeschaltet")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
} }
@@ -84,10 +84,12 @@ struct SettingsView: View {
Button { showPaywall = true } label: { Button { showPaywall = true } label: {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("nahbar Pro entdecken") Text("Pro oder Max-Abo")
.font(.system(size: 15, weight: .medium)) .font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.accent) .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)) .font(.system(size: 12))
.foregroundStyle(theme.contentTertiary) .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 // Theme picker
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -290,9 +292,9 @@ struct SettingsView: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
} }
// Besuche & Bewertungen // Treffen & Bewertungen
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Besuche", icon: "star.fill") SectionHeader(title: "Treffen", icon: "star.fill")
.padding(.horizontal, 20) .padding(.horizontal, 20)
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -337,8 +339,11 @@ struct SettingsView: View {
// KI-Einstellungen // KI-Einstellungen
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "KI-Analyse", icon: "sparkles") HStack(spacing: 8) {
.padding(.horizontal, 20) SectionHeader(title: "KI-Analyse", icon: "sparkles")
MaxBadge()
}
.padding(.horizontal, 20)
VStack(spacing: 0) { VStack(spacing: 0) {
settingsTextField(label: "Modell", value: $aiModel, placeholder: AIConfig.fallback.model) 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 // MARK: - Section Header
struct SectionHeader: View { struct SectionHeader: View {
+8 -6
View File
@@ -314,16 +314,18 @@ struct GiftSuggestionRow: View {
Text("Geschenkidee vorschlagen") Text("Geschenkidee vorschlagen")
.font(.system(size: 13)) .font(.system(size: 13))
Spacer() Spacer()
if !store.isMax { if store.isMax {
Text(canUseAI MaxBadge()
? "\(AIAnalysisService.shared.freeQueriesRemaining) gratis" } else if canUseAI {
: "MAX") Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
.font(.system(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.accent) .foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(theme.accent.opacity(0.10)) .background(theme.backgroundSecondary)
.clipShape(Capsule()) .clipShape(Capsule())
} else {
MaxBadge()
} }
} }
.foregroundStyle(canUseAI ? theme.accent : theme.contentSecondary) .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 displayName: String = ""
@Published private(set) var aboutMe: String = "" @Published private(set) var aboutMe: String = ""
@Published private(set) var birthday: Date? = nil @Published private(set) var birthday: Date? = nil
@Published private(set) var gender: String = ""
@Published private(set) var occupation: String = "" @Published private(set) var occupation: String = ""
@Published private(set) var location: String = "" @Published private(set) var location: String = ""
@Published private(set) var likes: String = "" @Published private(set) var likes: String = ""
@@ -32,7 +33,7 @@ final class UserProfileStore: ObservableObject {
var isEmpty: Bool { var isEmpty: Bool {
name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty 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 { var initials: String {
@@ -75,11 +76,19 @@ final class UserProfileStore: ObservableObject {
objectWillChange.send() objectWillChange.send()
} }
// MARK: - Geschlecht (gezieltes Update ohne alle Felder)
func updateGender(_ value: String) {
gender = value
save()
}
// MARK: - Update (batch, explizit durch Nutzer bestätigt) // MARK: - Update (batch, explizit durch Nutzer bestätigt)
func update( func update(
name: String, name: String,
birthday: Date?, birthday: Date?,
gender: String,
occupation: String, occupation: String,
location: String, location: String,
likes: String, likes: String,
@@ -92,6 +101,7 @@ final class UserProfileStore: ObservableObject {
self.displayName = displayName self.displayName = displayName
self.aboutMe = aboutMe self.aboutMe = aboutMe
self.birthday = birthday self.birthday = birthday
self.gender = gender
self.occupation = occupation self.occupation = occupation
self.location = location self.location = location
self.likes = likes self.likes = likes
@@ -107,6 +117,7 @@ final class UserProfileStore: ObservableObject {
"name": name, "name": name,
"displayName": displayName, "displayName": displayName,
"aboutMe": aboutMe, "aboutMe": aboutMe,
"gender": gender,
"occupation": occupation, "occupation": occupation,
"location": location, "location": location,
"likes": likes, "likes": likes,
@@ -123,7 +134,7 @@ final class UserProfileStore: ObservableObject {
func reset() { func reset() {
defaults.removeObject(forKey: storageKey) defaults.removeObject(forKey: storageKey)
if let url = photoURL { try? FileManager.default.removeItem(at: url) } if let url = photoURL { try? FileManager.default.removeItem(at: url) }
name = ""; displayName = ""; aboutMe = "" name = ""; displayName = ""; aboutMe = ""; gender = ""
birthday = nil; occupation = ""; location = "" birthday = nil; occupation = ""; location = ""
likes = ""; dislikes = ""; socialStyle = "" likes = ""; dislikes = ""; socialStyle = ""
logger.info("UserProfile zurückgesetzt") logger.info("UserProfile zurückgesetzt")
@@ -134,6 +145,7 @@ final class UserProfileStore: ObservableObject {
name = dict["name"] as? String ?? "" name = dict["name"] as? String ?? ""
displayName = dict["displayName"] as? String ?? "" displayName = dict["displayName"] as? String ?? ""
aboutMe = dict["aboutMe"] as? String ?? "" aboutMe = dict["aboutMe"] as? String ?? ""
gender = dict["gender"] as? String ?? ""
occupation = dict["occupation"] as? String ?? "" occupation = dict["occupation"] as? String ?? ""
location = dict["location"] as? String ?? "" location = dict["location"] as? String ?? ""
likes = dict["likes"] as? String ?? "" likes = dict["likes"] as? String ?? ""
@@ -48,7 +48,7 @@ struct ShareExtensionView: View {
Section("Typ") { Section("Typ") {
Picker("Typ", selection: $momentType) { Picker("Typ", selection: $momentType) {
ForEach(MomentType.allCases, id: \.self) { type in 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) .pickerStyle(.segmented)
+103 -2
View File
@@ -132,6 +132,20 @@ struct ContactImportTests {
#expect(ContactImport.from(contact).name == "Anna Schmidt") #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") @Test("Nur Vorname → kein Leerzeichen am Ende")
func onlyFirstName() { func onlyFirstName() {
let contact = CNMutableContact(); contact.givenName = "Cher" let contact = CNMutableContact(); contact.givenName = "Cher"
@@ -144,10 +158,17 @@ struct ContactImportTests {
#expect(ContactImport.from(contact).name == "Prince") #expect(ContactImport.from(contact).name == "Prince")
} }
@Test("Berufsbezeichnung bevorzugt gegenüber Firma") @Test("Berufsbezeichnung und Firma werden kombiniert")
func jobTitlePreferredOverOrg() { func jobTitleAndOrgCombined() {
let contact = CNMutableContact() let contact = CNMutableContact()
contact.jobTitle = "Designer"; contact.organizationName = "ACME GmbH" 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") #expect(ContactImport.from(contact).occupation == "Designer")
} }
@@ -171,6 +192,24 @@ struct ContactImportTests {
#expect(ContactImport.from(contact).location == "Berlin, Deutschland") #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") @Test("Kein Ort → leerer String")
func emptyLocation() { func emptyLocation() {
#expect(ContactImport.from(CNMutableContact()).location == "") #expect(ContactImport.from(CNMutableContact()).location == "")
@@ -208,3 +247,65 @@ struct ContactImportTests {
#expect(ContactImport.from(CNMutableContact()).photoData == nil) #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_) #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 // MARK: - MomentSource Tests
@@ -383,7 +383,7 @@ struct PersonalityEngineBehaviorTests {
@Test("Hoher Neurotizismus → verzögerter Rating-Prompt (7200s)") @Test("Hoher Neurotizismus → verzögerter Rating-Prompt (7200s)")
func highNeuroticismGivesDelayedPrompt() { 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) { if case .delayed(let secs, _) = PersonalityEngine.ratingPromptTiming(for: p) {
#expect(secs == 7200) #expect(secs == 7200)
} else { } 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) // MARK: - OnboardingStep Regressionswächter (nach Quiz-Erweiterung)
@Suite("OnboardingStep RawValues (Quiz-Erweiterung)") @Suite("OnboardingStep RawValues (Quiz-Erweiterung)")
+57
View File
@@ -14,6 +14,29 @@ struct OnboardingCoordinatorValidationTests {
#expect(!coord.isProfileValid) #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") @Test("Nur Leerzeichen → isProfileValid ist false")
@MainActor func whitespaceFirstNameIsInvalid() { @MainActor func whitespaceFirstNameIsInvalid() {
let coord = OnboardingCoordinator() let coord = OnboardingCoordinator()
@@ -46,6 +69,40 @@ struct OnboardingCoordinatorNavigationTests {
#expect(coord.currentStep == .profile) #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") @Test("advanceToContacts ohne Vorname bleibt auf .profile")
@MainActor func advanceToContactsWithoutNameStaysOnProfile() { @MainActor func advanceToContactsWithoutNameStaysOnProfile() {
let coord = OnboardingCoordinator() let coord = OnboardingCoordinator()
+29
View File
@@ -161,3 +161,32 @@ struct AppGroupProStatusTests {
#expect(testDefaults.bool(forKey: "isPro") == false) #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 { struct UserProfileStoreIsEmptyTests {
// isEmpty = name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty // 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 = "", private func isEmpty(name: String = "", displayName: String = "",
occupation: String = "", location: String = "", occupation: String = "", location: String = "",
likes: String = "", dislikes: String = "", likes: String = "", dislikes: String = "",
socialStyle: String = "") -> Bool { socialStyle: String = "", gender: String = "") -> Bool {
name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty 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") @Test("Alle Felder leer → isEmpty ist true")
@@ -115,9 +115,14 @@ struct UserProfileStoreIsEmptyTests {
#expect(!isEmpty(socialStyle: "Introvertiert")) #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") @Test("Alle Vorlieben-Felder leer + Rest leer → isEmpty ist true")
func allVorliebFieldsEmptyStillEmpty() { 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 } let items = "".split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
#expect(items.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)
}
} }