diff --git a/nahbar/nahbar/AddPersonView.swift b/nahbar/nahbar/AddPersonView.swift index 02b15c7..007638a 100644 --- a/nahbar/nahbar/AddPersonView.swift +++ b/nahbar/nahbar/AddPersonView.swift @@ -10,6 +10,8 @@ struct AddPersonView: View { var existingPerson: Person? = nil + @Query private var allPeople: [Person] + @State private var name = "" @State private var selectedTag: PersonTag = .other @State private var occupation = "" @@ -84,7 +86,12 @@ struct AddPersonView: View { RowDivider() inlineField("Wohnort", text: $location) RowDivider() - inlineField("Interessen", text: $interests) + InterestTagEditor( + label: "Interessen", + text: $interests, + suggestions: interestSuggestions, + tagColor: .green + ) RowDivider() inlineField("Herkunft", text: $culturalBackground) RowDivider() @@ -379,6 +386,14 @@ struct AddPersonView: View { .padding(.vertical, 12) } + private var interestSuggestions: [String] { + InterestTagHelper.allSuggestions( + from: allPeople, + likes: UserProfileStore.shared.likes, + dislikes: UserProfileStore.shared.dislikes + ) + } + private func loadExisting() { guard let p = existingPerson else { return } name = p.name diff --git a/nahbar/nahbar/IchView.swift b/nahbar/nahbar/IchView.swift index f2a0183..438735c 100644 --- a/nahbar/nahbar/IchView.swift +++ b/nahbar/nahbar/IchView.swift @@ -220,11 +220,11 @@ struct IchView: View { } else { VStack(spacing: 0) { 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 { - preferenceRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red) + InterestChipRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red) } } .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 { HStack(alignment: .top, spacing: 12) { Text(label) @@ -309,6 +283,8 @@ struct IchEditView: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var profileStore: UserProfileStore + @Query private var allPeople: [Person] + @State private var name: String @State private var hasBirthday: Bool @State private var birthday: Date @@ -402,9 +378,19 @@ struct IchEditView: View { // Vorlieben formSection("Vorlieben") { VStack(spacing: 0) { - inlineField("Mag ich", text: $likes) + InterestTagEditor( + label: "Mag ich", + text: $likes, + suggestions: preferenceSuggestions, + tagColor: .green + ) Divider().padding(.leading, 16) - inlineField("Mag ich nicht", text: $dislikes) + InterestTagEditor( + label: "Mag nicht", + text: $dislikes, + suggestions: preferenceSuggestions, + tagColor: .red + ) } .background(theme.surfaceCard) .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 @ViewBuilder diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index 0a9ae83..28d9f91 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -4050,16 +4050,8 @@ } } }, - "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." - } - } - } + "noch keine" : { + }, "Noch keine Einträge" : { "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" : { "comment" : "VisitRatingFlowView / VisitSummaryView – note field label (singular)", "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." : { "comment" : "FeatureTourStep description – WhatsApp share feature", "localizations" : { diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index a80d230..46db7de 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -2,6 +2,9 @@ import SwiftUI import SwiftData import CoreData import UserNotifications +import OSLog + +private let todoNotificationLogger = Logger(subsystem: "nahbar", category: "TodoNotification") struct PersonDetailView: View { @Environment(\.nahbarTheme) var theme @@ -387,7 +390,7 @@ struct PersonDetailView: View { RowDivider() } if let interests = person.interests, !interests.isEmpty { - InfoRowView(label: "Interessen", value: interests) + InterestChipRow(label: "Interessen", text: interests, color: .green) RowDivider() } if let bg = person.culturalBackground, !bg.isEmpty { @@ -1247,12 +1250,19 @@ struct EditTodoView: View { private func scheduleReminder() { let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - guard granted else { return } + center.requestAuthorization(options: [.alert, .sound]) { granted, error in + if let error { + todoNotificationLogger.error("Berechtigung-Fehler: \(error.localizedDescription)") + } + guard granted else { + todoNotificationLogger.warning("Notification-Berechtigung abgelehnt – keine Todo-Erinnerung.") + return + } let content = UNMutableNotificationContent() content.title = todo.person?.firstName ?? "" content.body = todo.title content.sound = .default + content.userInfo = ["todoID": todo.id.uuidString] let components = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute], from: reminderDate ) @@ -1262,7 +1272,13 @@ struct EditTodoView: View { content: content, 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)") + } + } } } } diff --git a/nahbar/nahbar/SharedComponents.swift b/nahbar/nahbar/SharedComponents.swift index 517e30e..8633c15 100644 --- a/nahbar/nahbar/SharedComponents.swift +++ b/nahbar/nahbar/SharedComponents.swift @@ -1,4 +1,217 @@ 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 diff --git a/nahbar/nahbarTests/StoreTests.swift b/nahbar/nahbarTests/StoreTests.swift index ec32a5e..6a036bf 100644 --- a/nahbar/nahbarTests/StoreTests.swift +++ b/nahbar/nahbarTests/StoreTests.swift @@ -349,3 +349,141 @@ struct PaywallTargetingTests { #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) + } +}