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:
2026-04-22 18:17:50 +02:00
parent c4202cbf2f
commit 7605a2d30c
6 changed files with 437 additions and 45 deletions
+16 -1
View File
@@ -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
View File
@@ -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
+24 -10
View File
@@ -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" : {
+20 -4
View File
@@ -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)")
}
}
} }
} }
} }
+213
View File
@@ -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
+138
View File
@@ -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)
}
}