Fix #12: Interessen als farbige Chips mit Autocomplete
- InterestTagHelper: parse/join/add/remove/suggestions (alphabetisch sortiert) - InterestChipRow: wiederverwendbare Display-Komponente (grün/rot) - InterestTagEditor: Chip-Editor mit × + Tipp-Autocomplete - AddPersonView, PersonDetailView, IchView auf neue Komponenten umgestellt - 20 InterestTagHelper-Tests in StoreTests - Lokalisierung: "Tag hinzufügen…" → "Add tag…" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ struct AddPersonView: View {
|
|||||||
|
|
||||||
var existingPerson: Person? = nil
|
var existingPerson: Person? = nil
|
||||||
|
|
||||||
|
@Query private var allPeople: [Person]
|
||||||
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var selectedTag: PersonTag = .other
|
@State private var selectedTag: PersonTag = .other
|
||||||
@State private var occupation = ""
|
@State private var occupation = ""
|
||||||
@@ -84,7 +86,12 @@ struct AddPersonView: View {
|
|||||||
RowDivider()
|
RowDivider()
|
||||||
inlineField("Wohnort", text: $location)
|
inlineField("Wohnort", text: $location)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
inlineField("Interessen", text: $interests)
|
InterestTagEditor(
|
||||||
|
label: "Interessen",
|
||||||
|
text: $interests,
|
||||||
|
suggestions: interestSuggestions,
|
||||||
|
tagColor: .green
|
||||||
|
)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
inlineField("Herkunft", text: $culturalBackground)
|
inlineField("Herkunft", text: $culturalBackground)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
@@ -379,6 +386,14 @@ struct AddPersonView: View {
|
|||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var interestSuggestions: [String] {
|
||||||
|
InterestTagHelper.allSuggestions(
|
||||||
|
from: allPeople,
|
||||||
|
likes: UserProfileStore.shared.likes,
|
||||||
|
dislikes: UserProfileStore.shared.dislikes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadExisting() {
|
private func loadExisting() {
|
||||||
guard let p = existingPerson else { return }
|
guard let p = existingPerson else { return }
|
||||||
name = p.name
|
name = p.name
|
||||||
|
|||||||
+26
-30
@@ -220,11 +220,11 @@ struct IchView: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if !profileStore.likes.isEmpty {
|
if !profileStore.likes.isEmpty {
|
||||||
preferenceRow(label: "Mag ich", text: profileStore.likes, color: .green)
|
InterestChipRow(label: "Mag ich", text: profileStore.likes, color: .green)
|
||||||
if !profileStore.dislikes.isEmpty { RowDivider() }
|
if !profileStore.dislikes.isEmpty { RowDivider() }
|
||||||
}
|
}
|
||||||
if !profileStore.dislikes.isEmpty {
|
if !profileStore.dislikes.isEmpty {
|
||||||
preferenceRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red)
|
InterestChipRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
@@ -233,32 +233,6 @@ struct IchView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func preferenceRow(label: String, text: String, color: Color) -> some View {
|
|
||||||
let items = text.split(separator: ",")
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
return VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
ForEach(items, id: \.self) { item in
|
|
||||||
Text(item)
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(color)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
.background(color.opacity(0.12))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func infoRow(label: String, value: String) -> some View {
|
private func infoRow(label: String, value: String) -> some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Text(label)
|
Text(label)
|
||||||
@@ -309,6 +283,8 @@ struct IchEditView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@EnvironmentObject var profileStore: UserProfileStore
|
@EnvironmentObject var profileStore: UserProfileStore
|
||||||
|
|
||||||
|
@Query private var allPeople: [Person]
|
||||||
|
|
||||||
@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
|
||||||
@@ -402,9 +378,19 @@ struct IchEditView: View {
|
|||||||
// Vorlieben
|
// Vorlieben
|
||||||
formSection("Vorlieben") {
|
formSection("Vorlieben") {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
inlineField("Mag ich", text: $likes)
|
InterestTagEditor(
|
||||||
|
label: "Mag ich",
|
||||||
|
text: $likes,
|
||||||
|
suggestions: preferenceSuggestions,
|
||||||
|
tagColor: .green
|
||||||
|
)
|
||||||
Divider().padding(.leading, 16)
|
Divider().padding(.leading, 16)
|
||||||
inlineField("Mag ich nicht", text: $dislikes)
|
InterestTagEditor(
|
||||||
|
label: "Mag nicht",
|
||||||
|
text: $dislikes,
|
||||||
|
suggestions: preferenceSuggestions,
|
||||||
|
tagColor: .red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
@@ -562,6 +548,16 @@ struct IchEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Suggestion Pool
|
||||||
|
|
||||||
|
private var preferenceSuggestions: [String] {
|
||||||
|
InterestTagHelper.allSuggestions(
|
||||||
|
from: allPeople,
|
||||||
|
likes: profileStore.likes,
|
||||||
|
dislikes: profileStore.dislikes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -4050,16 +4050,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Noch zu wenig Verlauf für persönliche Vorschläge." : {
|
"noch keine" : {
|
||||||
"comment" : "AddMomentView – conversation suggestions insufficient data message",
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Not enough history yet for personal suggestions."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"Noch keine Einträge" : {
|
"Noch keine Einträge" : {
|
||||||
"comment" : "LogbuchView – empty state title",
|
"comment" : "LogbuchView – empty state title",
|
||||||
@@ -4139,6 +4131,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Noch zu wenig Verlauf für persönliche Vorschläge." : {
|
||||||
|
"comment" : "AddMomentView – conversation suggestions insufficient data message",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Not enough history yet for personal suggestions."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Notiz" : {
|
"Notiz" : {
|
||||||
"comment" : "VisitRatingFlowView / VisitSummaryView – note field label (singular)",
|
"comment" : "VisitRatingFlowView / VisitSummaryView – note field label (singular)",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4809,6 +4812,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Tag hinzufügen…" : {
|
||||||
|
"comment" : "InterestTagEditor – placeholder for the tag input field",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Add tag…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Teile WhatsApp-Nachrichten direkt in nahbar – sie werden als Momente gespeichert." : {
|
"Teile WhatsApp-Nachrichten direkt in nahbar – sie werden als Momente gespeichert." : {
|
||||||
"comment" : "FeatureTourStep description – WhatsApp share feature",
|
"comment" : "FeatureTourStep description – WhatsApp share feature",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import CoreData
|
import CoreData
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let todoNotificationLogger = Logger(subsystem: "nahbar", category: "TodoNotification")
|
||||||
|
|
||||||
struct PersonDetailView: View {
|
struct PersonDetailView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@@ -387,7 +390,7 @@ struct PersonDetailView: View {
|
|||||||
RowDivider()
|
RowDivider()
|
||||||
}
|
}
|
||||||
if let interests = person.interests, !interests.isEmpty {
|
if let interests = person.interests, !interests.isEmpty {
|
||||||
InfoRowView(label: "Interessen", value: interests)
|
InterestChipRow(label: "Interessen", text: interests, color: .green)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
}
|
}
|
||||||
if let bg = person.culturalBackground, !bg.isEmpty {
|
if let bg = person.culturalBackground, !bg.isEmpty {
|
||||||
@@ -1247,12 +1250,19 @@ struct EditTodoView: View {
|
|||||||
|
|
||||||
private func scheduleReminder() {
|
private func scheduleReminder() {
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
center.requestAuthorization(options: [.alert, .sound]) { granted, error in
|
||||||
guard granted else { return }
|
if let error {
|
||||||
|
todoNotificationLogger.error("Berechtigung-Fehler: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard granted else {
|
||||||
|
todoNotificationLogger.warning("Notification-Berechtigung abgelehnt – keine Todo-Erinnerung.")
|
||||||
|
return
|
||||||
|
}
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = todo.person?.firstName ?? ""
|
content.title = todo.person?.firstName ?? ""
|
||||||
content.body = todo.title
|
content.body = todo.title
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
content.userInfo = ["todoID": todo.id.uuidString]
|
||||||
let components = Calendar.current.dateComponents(
|
let components = Calendar.current.dateComponents(
|
||||||
[.year, .month, .day, .hour, .minute], from: reminderDate
|
[.year, .month, .day, .hour, .minute], from: reminderDate
|
||||||
)
|
)
|
||||||
@@ -1262,7 +1272,13 @@ struct EditTodoView: View {
|
|||||||
content: content,
|
content: content,
|
||||||
trigger: trigger
|
trigger: trigger
|
||||||
)
|
)
|
||||||
center.add(request)
|
center.add(request) { error in
|
||||||
|
if let error {
|
||||||
|
todoNotificationLogger.error("Todo-Erinnerung konnte nicht geplant werden: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
todoNotificationLogger.info("Todo-Erinnerung geplant: \(todo.id.uuidString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,217 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
// MARK: - Interest Tag Helper
|
||||||
|
|
||||||
|
/// Rein-statische Hilfsfunktionen für kommaseparierte Interessen-Tags.
|
||||||
|
enum InterestTagHelper {
|
||||||
|
|
||||||
|
/// Zerlegt einen kommaseparierten String in bereinigte, nicht-leere Tags.
|
||||||
|
static func parse(_ text: String) -> [String] {
|
||||||
|
text.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sortiert Tags alphabetisch und verbindet sie zu einem kommaseparierten String.
|
||||||
|
static func join(_ tags: [String]) -> String {
|
||||||
|
tags.sorted(by: { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending })
|
||||||
|
.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fügt einen Tag hinzu (ignoriert Duplikate, Groß-/Kleinschreibung irrelevant).
|
||||||
|
/// Ergebnis ist alphabetisch sortiert.
|
||||||
|
static func addTag(_ tag: String, to text: String) -> String {
|
||||||
|
let trimmed = tag.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { return text }
|
||||||
|
var current = parse(text)
|
||||||
|
let alreadyExists = current.contains { $0.localizedCaseInsensitiveCompare(trimmed) == .orderedSame }
|
||||||
|
guard !alreadyExists else { return text }
|
||||||
|
current.append(trimmed)
|
||||||
|
return join(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entfernt einen Tag aus dem kommaseparierten String.
|
||||||
|
static func removeTag(_ tag: String, from text: String) -> String {
|
||||||
|
let updated = parse(text).filter { $0.localizedCaseInsensitiveCompare(tag) != .orderedSame }
|
||||||
|
return join(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sammelt alle vorhandenen Tags aus Personen-Interessen, User-Likes und Dislikes.
|
||||||
|
/// Dedupliziert und alphabetisch sortiert.
|
||||||
|
static func allSuggestions(from people: [Person], likes: String, dislikes: String) -> [String] {
|
||||||
|
let personTags = people.flatMap { parse($0.interests ?? "") }
|
||||||
|
let userTags = parse(likes) + parse(dislikes)
|
||||||
|
let combined = Set(personTags + userTags)
|
||||||
|
return combined.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interest Chip Row (Display-only)
|
||||||
|
|
||||||
|
/// Zeigt kommaseparierte Interessen-Tags als horizontale Chip-Reihe an.
|
||||||
|
/// Verwendung in PersonDetailView und IchView (Display-Modus).
|
||||||
|
struct InterestChipRow: View {
|
||||||
|
@Environment(\.nahbarTheme) private var theme
|
||||||
|
let label: String
|
||||||
|
let text: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
private var tags: [String] { InterestTagHelper.parse(text) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(tags, id: \.self) { tag in
|
||||||
|
Text(tag)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interest Tag Editor
|
||||||
|
|
||||||
|
/// Bearbeitbares Tag-Eingabefeld mit Autocomplete.
|
||||||
|
/// Bestehendes Tags werden als entfernbare farbige Chips gezeigt.
|
||||||
|
/// Beim Tippen erscheint eine horizontale Chip-Reihe passender Vorschläge.
|
||||||
|
struct InterestTagEditor: View {
|
||||||
|
@Environment(\.nahbarTheme) private var theme
|
||||||
|
let label: String
|
||||||
|
@Binding var text: String
|
||||||
|
let suggestions: [String]
|
||||||
|
let tagColor: Color
|
||||||
|
|
||||||
|
@State private var inputText = ""
|
||||||
|
@FocusState private var inputFocused: Bool
|
||||||
|
|
||||||
|
private var tags: [String] { InterestTagHelper.parse(text) }
|
||||||
|
|
||||||
|
private var filteredSuggestions: [String] {
|
||||||
|
let q = inputText.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return [] }
|
||||||
|
return suggestions.filter { suggestion in
|
||||||
|
suggestion.localizedCaseInsensitiveContains(q) &&
|
||||||
|
!tags.contains { $0.localizedCaseInsensitiveCompare(suggestion) == .orderedSame }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addTag(_ tag: String) {
|
||||||
|
text = InterestTagHelper.addTag(tag, to: text)
|
||||||
|
inputText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeTag(_ tag: String) {
|
||||||
|
text = InterestTagHelper.removeTag(tag, from: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|
||||||
|
// Zeile 1: bestehende Tags + ggf. Platzhalter
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
.padding(.top, tags.isEmpty ? 0 : 2)
|
||||||
|
|
||||||
|
if tags.isEmpty {
|
||||||
|
Text("noch keine")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentTertiary.opacity(0.45))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { inputFocused = true }
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(tags, id: \.self) { tag in
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text(tag)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
Button {
|
||||||
|
removeTag(tag)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(tagColor)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.trailing, 7)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(tagColor.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeile 2: Texteingabe
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Spacer().frame(width: 80)
|
||||||
|
TextField("Tag hinzufügen…", text: $inputText)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
.tint(theme.accent)
|
||||||
|
.focused($inputFocused)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.onSubmit {
|
||||||
|
addTag(inputText)
|
||||||
|
}
|
||||||
|
.onChange(of: inputText) { _, new in
|
||||||
|
// Komma-Eingabe als Trennzeichen
|
||||||
|
if new.hasSuffix(",") {
|
||||||
|
let candidate = String(new.dropLast())
|
||||||
|
addTag(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeile 3: Vorschlags-Chips (nur während Eingabe)
|
||||||
|
if !filteredSuggestions.isEmpty {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Spacer().frame(width: 80)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(filteredSuggestions, id: \.self) { suggestion in
|
||||||
|
Button { addTag(suggestion) } label: {
|
||||||
|
Text(suggestion)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(theme.backgroundSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(
|
||||||
|
Capsule().stroke(theme.borderSubtle, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Person Avatar
|
// MARK: - Person Avatar
|
||||||
|
|
||||||
|
|||||||
@@ -349,3 +349,141 @@ struct PaywallTargetingTests {
|
|||||||
#expect(target(isPro: false) != target(isPro: true))
|
#expect(target(isPro: false) != target(isPro: true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - InterestTagHelper Tests
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – parse")
|
||||||
|
struct InterestTagHelperParseTests {
|
||||||
|
|
||||||
|
@Test("Leerer String → leeres Array")
|
||||||
|
func emptyStringReturnsEmpty() {
|
||||||
|
#expect(InterestTagHelper.parse("") == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Einzelner Tag ohne Komma")
|
||||||
|
func singleTag() {
|
||||||
|
#expect(InterestTagHelper.parse("Fußball") == ["Fußball"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mehrere Tags kommasepariert")
|
||||||
|
func multipleTags() {
|
||||||
|
let result = InterestTagHelper.parse("Fußball, Musik, Lesen")
|
||||||
|
#expect(result == ["Fußball", "Musik", "Lesen"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Whitespace wird getrimmt")
|
||||||
|
func whitespaceTrimmed() {
|
||||||
|
let result = InterestTagHelper.parse(" Kino , Sport ")
|
||||||
|
#expect(result == ["Kino", "Sport"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leere Segmente werden gefiltert")
|
||||||
|
func emptySegmentsFiltered() {
|
||||||
|
let result = InterestTagHelper.parse("Kino,,Musik,")
|
||||||
|
#expect(result == ["Kino", "Musik"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – join")
|
||||||
|
struct InterestTagHelperJoinTests {
|
||||||
|
|
||||||
|
@Test("Leeres Array → leerer String")
|
||||||
|
func emptyArrayReturnsEmpty() {
|
||||||
|
#expect(InterestTagHelper.join([]) == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Einzelner Tag bleibt unverändert")
|
||||||
|
func singleTagUnchanged() {
|
||||||
|
#expect(InterestTagHelper.join(["Fußball"]) == "Fußball")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tags werden alphabetisch sortiert")
|
||||||
|
func tagsSortedAlphabetically() {
|
||||||
|
let result = InterestTagHelper.join(["Musik", "Fußball", "Lesen"])
|
||||||
|
#expect(result == "Fußball, Lesen, Musik")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Sortierung ist case-insensitive")
|
||||||
|
func sortingCaseInsensitive() {
|
||||||
|
let result = InterestTagHelper.join(["bier", "Apfel", "Chips"])
|
||||||
|
#expect(result == "Apfel, bier, Chips")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – addTag")
|
||||||
|
struct InterestTagHelperAddTagTests {
|
||||||
|
|
||||||
|
@Test("Tag zu leerem String hinzufügen")
|
||||||
|
func addToEmpty() {
|
||||||
|
#expect(InterestTagHelper.addTag("Kino", to: "") == "Kino")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tag zu bestehendem String – alphabetisch einsortiert")
|
||||||
|
func addSortsAlphabetically() {
|
||||||
|
let result = InterestTagHelper.addTag("Musik", to: "Fußball")
|
||||||
|
#expect(result == "Fußball, Musik")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Duplikat wird ignoriert")
|
||||||
|
func duplicateIgnored() {
|
||||||
|
let result = InterestTagHelper.addTag("Kino", to: "Kino, Sport")
|
||||||
|
#expect(result == "Kino, Sport")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Duplikat ignoriert (Groß-/Kleinschreibung)")
|
||||||
|
func duplicateCaseInsensitive() {
|
||||||
|
let result = InterestTagHelper.addTag("kino", to: "Kino")
|
||||||
|
#expect(result == "Kino")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leerer String wird ignoriert")
|
||||||
|
func emptyTagIgnored() {
|
||||||
|
let result = InterestTagHelper.addTag("", to: "Kino")
|
||||||
|
#expect(result == "Kino")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – removeTag")
|
||||||
|
struct InterestTagHelperRemoveTagTests {
|
||||||
|
|
||||||
|
@Test("Tag aus String entfernen")
|
||||||
|
func removeExistingTag() {
|
||||||
|
let result = InterestTagHelper.removeTag("Musik", from: "Fußball, Musik, Sport")
|
||||||
|
#expect(result == "Fußball, Sport")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nicht vorhandener Tag → unveränderter String")
|
||||||
|
func removeNonExistentTag() {
|
||||||
|
let result = InterestTagHelper.removeTag("Kino", from: "Fußball, Sport")
|
||||||
|
#expect(result == "Fußball, Sport")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Letzten Tag entfernen → leerer String")
|
||||||
|
func removeLastTagReturnsEmpty() {
|
||||||
|
let result = InterestTagHelper.removeTag("Kino", from: "Kino")
|
||||||
|
#expect(result == "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – allSuggestions")
|
||||||
|
struct InterestTagHelperSuggestionsTests {
|
||||||
|
|
||||||
|
@Test("Keine Personen + leere Vorlieben → leere Vorschläge")
|
||||||
|
func emptyInputsReturnEmpty() {
|
||||||
|
let result = InterestTagHelper.allSuggestions(from: [], likes: "", dislikes: "")
|
||||||
|
#expect(result.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Vorschläge aus likes und dislikes kombiniert und sortiert")
|
||||||
|
func combinesLikesAndDislikes() {
|
||||||
|
let result = InterestTagHelper.allSuggestions(from: [], likes: "Musik, Kino", dislikes: "Sport")
|
||||||
|
#expect(result == ["Kino", "Musik", "Sport"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Duplikate werden dedupliziert")
|
||||||
|
func deduplicates() {
|
||||||
|
let result = InterestTagHelper.allSuggestions(from: [], likes: "Kino, Musik", dislikes: "Kino")
|
||||||
|
#expect(!result.contains { result.filter { $0 == "Kino" }.count > 1 })
|
||||||
|
#expect(result.filter { $0 == "Kino" }.count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user