Onboarding erweitert, Geschlecht hinzugefügt...
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -127,7 +127,12 @@ struct AddPersonView: View {
|
||||
}
|
||||
|
||||
// Nudge frequency
|
||||
formSection("Wie oft erinnern?") {
|
||||
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 {
|
||||
@@ -155,6 +160,7 @@ struct AddPersonView: View {
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete link — only in edit mode
|
||||
if isEditing {
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
+28
-121
@@ -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")
|
||||
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),
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: " ")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
func score(_ item: (String, ActivityStyle?, Bool)) -> Int {
|
||||
var s = 0
|
||||
if item.2 == preferred { s += 2 }
|
||||
if item.3 && highlightNew { s += 1 }
|
||||
if item.1 == preferred { s += 2 }
|
||||
if item.2 && 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) {
|
||||
ForEach(topItems, id: \.0) { item in
|
||||
let isRecommended = (item.2 == preferred) || (item.3 && highlightNew)
|
||||
Button {
|
||||
nextStepText = item.0
|
||||
// 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 ?? ""
|
||||
|
||||
return Button {
|
||||
nextStepText = topActivity
|
||||
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) {
|
||||
Text(LocalizedStringKey(item.0))
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
if isRecommended {
|
||||
RecommendedBadge(variant: .small)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
.foregroundStyle(NahbarInsightStyle.accentPetrol)
|
||||
Text("Idee: \(hint)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(theme.contentSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.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(.vertical, 7)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,12 +13,13 @@ 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 (.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,7 +47,13 @@ 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() }
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,7 +339,10 @@ struct SettingsView: View {
|
||||
|
||||
// KI-Einstellungen
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
SectionHeader(title: "KI-Analyse", icon: "sparkles")
|
||||
MaxBadge()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user