diff --git a/WorterBuch/FieldEditorView.swift b/WorterBuch/FieldEditorView.swift index baae3c5..4f89258 100644 --- a/WorterBuch/FieldEditorView.swift +++ b/WorterBuch/FieldEditorView.swift @@ -7,6 +7,7 @@ import SwiftUI import PencilKit +import CoreData enum FieldType { case germanWord @@ -24,12 +25,16 @@ enum FieldType { struct FieldEditorView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.managedObjectContext) private var viewContext @Binding var drawing: PKDrawing @Binding var text: String let fieldType: FieldType + var entry: VocabularyEntry? = nil // Optional entry for tag management @State private var isRecognizing = false @State private var viewAppeared = false + @State private var showingTagPicker = false + @State private var newTagName = "" var body: some View { NavigationView { @@ -91,6 +96,48 @@ struct FieldEditorView: View { } .padding() + // Tags section (only show if entry is provided) + if let entry = entry { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Tags") + .font(.headline) + .foregroundColor(.secondary) + + Spacer() + + Button(action: { showingTagPicker = true }) { + Image(systemName: "plus.circle.fill") + .foregroundColor(.blue) + } + } + + // Display current tags + if !entry.sortedTags.isEmpty { + FlowLayout(spacing: 8) { + ForEach(entry.sortedTags, id: \.id) { tag in + TagChip( + tag: tag, + onRemove: { + withAnimation { + entry.removeTag(tag) + saveContext() + } + } + ) + } + } + } else { + Text("No tags yet") + .font(.caption) + .foregroundColor(.secondary) + .padding(.vertical, 4) + } + } + .padding(.horizontal) + .padding(.bottom) + } + Spacer() } .navigationTitle(fieldType.title) @@ -109,10 +156,23 @@ struct FieldEditorView: View { .fontWeight(.semibold) } } + .sheet(isPresented: $showingTagPicker) { + if let entry = entry { + TagPickerView(entry: entry, isPresented: $showingTagPicker) + } + } } .navigationViewStyle(.stack) } + private func saveContext() { + do { + try viewContext.save() + } catch { + print("Error saving context: \(error)") + } + } + private func recognizeHandwriting(_ drawing: PKDrawing) { print("=== recognizeHandwriting called ===") print("Drawing bounds: \(drawing.bounds)") @@ -141,3 +201,232 @@ struct FieldEditorView: View { } } } + +// MARK: - Tag Chip View + +struct TagChip: View { + let tag: Tag + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 4) { + Text(tag.name ?? "") + .font(.caption) + .foregroundColor(.white) + + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(tag.isCustom ? Color.purple : Color.blue) + .cornerRadius(16) + } +} + +// MARK: - Flow Layout + +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = FlowLayoutResult( + in: proposal.replacingUnspecifiedDimensions().width, + subviews: subviews, + spacing: spacing + ) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = FlowLayoutResult( + in: bounds.width, + subviews: subviews, + spacing: spacing + ) + for (index, subview) in subviews.enumerated() { + subview.place(at: CGPoint(x: bounds.minX + result.positions[index].x, y: bounds.minY + result.positions[index].y), proposal: .unspecified) + } + } + + struct FlowLayoutResult { + var size: CGSize = .zero + var positions: [CGPoint] = [] + + init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) { + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if currentX + size.width > maxWidth && currentX > 0 { + currentX = 0 + currentY += lineHeight + spacing + lineHeight = 0 + } + + positions.append(CGPoint(x: currentX, y: currentY)) + currentX += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + + self.size = CGSize(width: maxWidth, height: currentY + lineHeight) + } + } +} + +// MARK: - Tag Picker View + +struct TagPickerView: View { + @Environment(\.managedObjectContext) private var viewContext + @ObservedObject var entry: VocabularyEntry + @Binding var isPresented: Bool + + @State private var allTags: [Tag] = [] + @State private var selectedTags: Set = [] + @State private var showingCustomTagAlert = false + @State private var customTagName = "" + + var body: some View { + NavigationView { + List { + Section(header: Text("Predefined Tags")) { + ForEach(allTags.filter { !$0.isCustom }, id: \.id) { tag in + TagRow(tag: tag, isSelected: selectedTags.contains(tag.id ?? UUID())) { + toggleTagSelection(tag) + } + } + } + + if !allTags.filter({ $0.isCustom }).isEmpty { + Section(header: Text("Custom Tags")) { + ForEach(allTags.filter { $0.isCustom }, id: \.id) { tag in + TagRow(tag: tag, isSelected: selectedTags.contains(tag.id ?? UUID())) { + toggleTagSelection(tag) + } + } + } + } + } + .navigationTitle("Select Tags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Create Custom") { + showingCustomTagAlert = true + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + isPresented = false + } + .fontWeight(.semibold) + } + } + .alert("Create Custom Tag", isPresented: $showingCustomTagAlert) { + TextField("Tag name", text: $customTagName) + Button("Cancel", role: .cancel) { + customTagName = "" + } + Button("Create") { + createCustomTag() + } + } + } + .onAppear { + loadTags() + loadSelectedTags() + } + .onDisappear { + applyChanges() + } + } + + private func loadTags() { + allTags = Tag.fetchAllTags(in: viewContext) + } + + private func loadSelectedTags() { + selectedTags = Set(entry.sortedTags.compactMap { $0.id }) + } + + private func toggleTagSelection(_ tag: Tag) { + guard let tagId = tag.id else { return } + + if selectedTags.contains(tagId) { + selectedTags.remove(tagId) + } else { + selectedTags.insert(tagId) + } + } + + private func applyChanges() { + // Remove tags that were unchecked + for tag in entry.sortedTags { + if let tagId = tag.id, !selectedTags.contains(tagId) { + entry.removeTag(tag) + } + } + + // Add tags that were checked + for tag in allTags { + if let tagId = tag.id, selectedTags.contains(tagId) && !entry.hasTag(tag) { + entry.addTag(tag) + } + } + + saveContext() + } + + private func createCustomTag() { + guard !customTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + if let newTag = Tag.fetchOrCreate(name: customTagName, isCustom: true, in: viewContext) { + if let tagId = newTag.id { + selectedTags.insert(tagId) + } + saveContext() + loadTags() + } + + customTagName = "" + } + + private func saveContext() { + do { + try viewContext.save() + print("Tags saved successfully") + } catch { + print("Error saving context: \(error)") + } + } +} + +struct TagRow: View { + let tag: Tag + let isSelected: Bool + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + HStack { + Text(tag.name ?? "") + .foregroundColor(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + } +} diff --git a/WorterBuch/Persistence.swift b/WorterBuch/Persistence.swift index 9f16b3d..8789518 100644 --- a/WorterBuch/Persistence.swift +++ b/WorterBuch/Persistence.swift @@ -15,6 +15,12 @@ struct PersistenceController { let result = PersistenceController(inMemory: true) let viewContext = result.container.viewContext + // Initialize predefined tags + Tag.initializePredefinedTags(in: viewContext) + + // Get some tags for sample data + let tags = Tag.fetchAllTags(in: viewContext) + // Create sample vocabulary entries for i in 0..<5 { let entry = VocabularyEntry.create(in: viewContext) @@ -23,6 +29,11 @@ struct PersistenceController { entry.germanWordText = "Beispielwort \(i + 1)" entry.germanExplanationText = "Dies ist eine Erklärung des deutschen Wortes" entry.englishTranslationText = "Example word \(i + 1)" + + // Add some sample tags + if i < tags.count { + entry.addTag(tags[i]) + } } // Create sample notes @@ -65,5 +76,11 @@ struct PersistenceController { } }) container.viewContext.automaticallyMergesChangesFromParent = true + + // Initialize predefined tags on first launch + if !UserDefaults.standard.bool(forKey: "PredefindTagsInitialized") { + Tag.initializePredefinedTags(in: container.viewContext) + UserDefaults.standard.set(true, forKey: "PredefinedTagsInitialized") + } } } diff --git a/WorterBuch/Tag+Extensions.swift b/WorterBuch/Tag+Extensions.swift new file mode 100644 index 0000000..c5bfcc3 --- /dev/null +++ b/WorterBuch/Tag+Extensions.swift @@ -0,0 +1,122 @@ +// +// Tag+Extensions.swift +// WorterBuch +// +// Created by Oliver Hnát on 06.12.2025. +// + +import Foundation +import CoreData + +extension Tag { + + // MARK: - Convenience Initializer + + static func create(name: String, isCustom: Bool = false, in context: NSManagedObjectContext) -> Tag { + let tag = Tag(context: context) + tag.id = UUID() + tag.name = name + tag.isCustom = isCustom + tag.createdDate = Date() + return tag + } + + // MARK: - Predefined Tags + + static let predefinedPartsOfSpeech = [ + "Noun", + "Verb", + "Adjective", + "Adverb", + "Preposition", + "Conjunction" + ] + + static let predefinedThemes = [ + "Food & Dining", + "Travel", + "Work & Business", + "Daily Life", + "School & Education", + "Health", + "Shopping", + "Hobbies" + ] + + static let predefinedLectures = [ + "Lecture 1", + "Lecture 2", + "Lecture 3", + "Lecture 4", + "Lecture 5", + "Lecture 6", + "Lecture 7", + "Lecture 8", + "Lecture 9", + "Lecture 10" + ] + + static var allPredefinedTags: [String] { + predefinedPartsOfSpeech + predefinedThemes + predefinedLectures + } + + // MARK: - Helper Methods + + /// Fetch or create a tag by name + static func fetchOrCreate(name: String, isCustom: Bool = false, in context: NSManagedObjectContext) -> Tag? { + let fetchRequest: NSFetchRequest = Tag.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "name == %@", name) + fetchRequest.fetchLimit = 1 + + do { + if let existingTag = try context.fetch(fetchRequest).first { + return existingTag + } else { + return create(name: name, isCustom: isCustom, in: context) + } + } catch { + print("Error fetching or creating tag: \(error)") + return nil + } + } + + /// Initialize all predefined tags in the context + static func initializePredefinedTags(in context: NSManagedObjectContext) { + for tagName in allPredefinedTags { + _ = fetchOrCreate(name: tagName, isCustom: false, in: context) + } + + do { + try context.save() + } catch { + print("Error saving predefined tags: \(error)") + } + } + + /// Fetch all tags sorted by name + static func fetchAllTags(in context: NSManagedObjectContext) -> [Tag] { + let fetchRequest: NSFetchRequest = Tag.fetchRequest() + fetchRequest.sortDescriptors = [ + NSSortDescriptor(key: "isCustom", ascending: true), + NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedStandardCompare(_:))) + ] + + do { + return try context.fetch(fetchRequest) + } catch { + print("Error fetching tags: \(error)") + return [] + } + } + + /// Get tag display name with icon for predefined categories + var displayName: String { + name ?? "Unknown" + } + + /// Get sorted tags array from NSSet + var sortedEntries: [VocabularyEntry] { + let entriesSet = entries as? Set ?? [] + return entriesSet.sorted { ($0.timestamp ?? Date()) > ($1.timestamp ?? Date()) } + } +} diff --git a/WorterBuch/TagManagerView.swift b/WorterBuch/TagManagerView.swift new file mode 100644 index 0000000..39a4aa4 --- /dev/null +++ b/WorterBuch/TagManagerView.swift @@ -0,0 +1,512 @@ +// +// TagManagerView.swift +// WorterBuch +// +// Created by Oliver Hnát on 06.12.2025. +// + +import SwiftUI +import CoreData + +struct TagManagerView: View { + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dismiss) private var dismiss + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \VocabularyEntry.timestamp, ascending: false)], + animation: .default + ) + private var entries: FetchedResults + + @State private var selectedEntries: Set = [] + @State private var searchText = "" + @State private var allTags: [Tag] = [] + @State private var isSelectionMode = false + @State private var showingTagPicker = false + @State private var showingTagSettings = false + + var filteredEntries: [VocabularyEntry] { + if searchText.isEmpty { + return Array(entries) + } else { + return entries.filter { entry in + (entry.germanWordText?.localizedCaseInsensitiveContains(searchText) ?? false) || + (entry.germanExplanationText?.localizedCaseInsensitiveContains(searchText) ?? false) || + (entry.englishTranslationText?.localizedCaseInsensitiveContains(searchText) ?? false) + } + } + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search vocabulary", text: $searchText) + .textFieldStyle(.plain) + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding(10) + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding() + + // Selection summary + if !selectedEntries.isEmpty { + HStack { + Text("\(selectedEntries.count) selected") + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Button("Clear") { + selectedEntries.removeAll() + } + .font(.subheadline) + } + .padding(.horizontal) + .padding(.bottom, 8) + } + + // Entries list + List { + ForEach(filteredEntries, id: \.id) { entry in + VocabularyEntryCheckboxRow( + entry: entry, + selectedEntries: selectedEntries, + isSelected: selectedEntries.contains(entry.id ?? UUID()), + onToggleSelection: { + toggleSelection(for: entry) + } + ) + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + } + } + .listStyle(.plain) + } + .navigationTitle("Tag Manager") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + HStack(spacing: 16) { + Button("Close") { + dismiss() + } + + Button(action: { showingTagSettings = true }) { + Label("Manage Tags", systemImage: "gear") + } + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 16) { + if !selectedEntries.isEmpty { + Button(action: { showingTagPicker = true }) { + Label("Add Tags", systemImage: "tag") + } + } + + Button(isSelectionMode ? "Done" : "Select All") { + if isSelectionMode { + selectedEntries.removeAll() + isSelectionMode = false + } else { + selectedEntries = Set(filteredEntries.compactMap { $0.id }) + isSelectionMode = true + } + } + } + } + } + .sheet(isPresented: $showingTagPicker) { + BulkTagPickerView( + selectedEntries: selectedEntriesArray, + isPresented: $showingTagPicker + ) + } + .sheet(isPresented: $showingTagSettings) { + TagSettingsView(isPresented: $showingTagSettings) + } + .onAppear { + loadTags() + } + } + } + + private func loadTags() { + allTags = Tag.fetchAllTags(in: viewContext) + } + + private var selectedEntriesArray: [VocabularyEntry] { + entries.filter { selectedEntries.contains($0.id ?? UUID()) } + } + + private func toggleSelection(for entry: VocabularyEntry) { + guard let entryId = entry.id else { return } + + if selectedEntries.contains(entryId) { + selectedEntries.remove(entryId) + } else { + selectedEntries.insert(entryId) + } + } +} + +// MARK: - Vocabulary Entry Checkbox Row + +struct VocabularyEntryCheckboxRow: View { + @Environment(\.managedObjectContext) private var viewContext + @ObservedObject var entry: VocabularyEntry + let selectedEntries: Set + let isSelected: Bool + let onToggleSelection: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Entry content with checkbox + HStack(spacing: 12) { + // Checkbox + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundColor(isSelected ? .blue : .gray) + + // Entry text + VStack(alignment: .leading, spacing: 4) { + if let germanWord = entry.germanWordText, !germanWord.isEmpty { + Text(germanWord) + .font(.body) + .foregroundColor(.primary) + } + + if let explanation = entry.germanExplanationText, !explanation.isEmpty { + Text(explanation) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + onToggleSelection() + } + + // Show only tags this entry has + if !entry.sortedTags.isEmpty { + FlowLayout(spacing: 6) { + ForEach(entry.sortedTags, id: \.id) { tag in + TagChipDisplay( + tag: tag, + onRemove: { [tag] in + withAnimation { + entry.removeTag(tag) + saveContext() + } + } + ) + } + } + } + } + .padding(.vertical, 4) + } + + private func saveContext() { + do { + try viewContext.save() + } catch { + print("Error saving context: \(error)") + } + } +} + +// MARK: - Tag Chip Display + +struct TagChipDisplay: View { + let tag: Tag + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 4) { + Text(tag.name ?? "") + .font(.caption) + .foregroundColor(.white) + + Button(action: { + onRemove() + }) { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tagColor) + .cornerRadius(12) + } + + private var tagColor: Color { + tag.isCustom ? .purple : .blue + } +} + +// MARK: - Bulk Tag Picker View + +struct BulkTagPickerView: View { + @Environment(\.managedObjectContext) private var viewContext + let selectedEntries: [VocabularyEntry] + @Binding var isPresented: Bool + + @State private var allTags: [Tag] = [] + @State private var tagsToAdd: Set = [] + @State private var showingCustomTagAlert = false + @State private var customTagName = "" + + var body: some View { + NavigationView { + VStack { + Text("Adding tags to \(selectedEntries.count) entries") + .font(.subheadline) + .foregroundColor(.secondary) + .padding() + + List { + Section(header: Text("Predefined Tags")) { + ForEach(allTags.filter { !$0.isCustom }, id: \.id) { tag in + TagRow(tag: tag, isSelected: tagsToAdd.contains(tag.id ?? UUID())) { + toggleTag(tag) + } + } + } + + if !allTags.filter({ $0.isCustom }).isEmpty { + Section(header: Text("Custom Tags")) { + ForEach(allTags.filter { $0.isCustom }, id: \.id) { tag in + TagRow(tag: tag, isSelected: tagsToAdd.contains(tag.id ?? UUID())) { + toggleTag(tag) + } + } + } + } + } + .listStyle(.insetGrouped) + } + .navigationTitle("Select Tags to Add") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Create Custom") { + showingCustomTagAlert = true + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Apply") { + applyTags() + isPresented = false + } + .fontWeight(.semibold) + .disabled(tagsToAdd.isEmpty) + } + } + .alert("Create Custom Tag", isPresented: $showingCustomTagAlert) { + TextField("Tag name", text: $customTagName) + Button("Cancel", role: .cancel) { + customTagName = "" + } + Button("Create") { + createCustomTag() + } + } + } + .onAppear { + loadTags() + } + } + + private func loadTags() { + allTags = Tag.fetchAllTags(in: viewContext) + } + + private func toggleTag(_ tag: Tag) { + guard let tagId = tag.id else { return } + + if tagsToAdd.contains(tagId) { + tagsToAdd.remove(tagId) + } else { + tagsToAdd.insert(tagId) + } + } + + private func applyTags() { + let tagsToApply = allTags.filter { tagsToAdd.contains($0.id ?? UUID()) } + + for entry in selectedEntries { + for tag in tagsToApply { + if !entry.hasTag(tag) { + entry.addTag(tag) + } + } + } + + saveContext() + } + + private func createCustomTag() { + guard !customTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + if let newTag = Tag.fetchOrCreate(name: customTagName, isCustom: true, in: viewContext) { + if let tagId = newTag.id { + tagsToAdd.insert(tagId) + } + saveContext() + loadTags() + } + + customTagName = "" + } + + private func saveContext() { + do { + try viewContext.save() + print("Bulk tags saved successfully") + } catch { + print("Error saving context: \(error)") + } + } +} + +// MARK: - Tag Settings View + +struct TagSettingsView: View { + @Environment(\.managedObjectContext) private var viewContext + @Binding var isPresented: Bool + + @State private var allTags: [Tag] = [] + @State private var showingCreateAlert = false + @State private var newTagName = "" + @State private var tagToDelete: Tag? + @State private var showingDeleteAlert = false + + var body: some View { + NavigationView { + List { + Section(header: Text("Predefined Tags")) { + ForEach(allTags.filter { !$0.isCustom }, id: \.id) { tag in + HStack { + Text(tag.name ?? "") + Spacer() + Text("\(tag.sortedEntries.count)") + .foregroundColor(.secondary) + .font(.caption) + } + } + } + + if !allTags.filter({ $0.isCustom }).isEmpty { + Section(header: Text("Custom Tags")) { + ForEach(allTags.filter { $0.isCustom }, id: \.id) { tag in + HStack { + Text(tag.name ?? "") + Spacer() + Text("\(tag.sortedEntries.count)") + .foregroundColor(.secondary) + .font(.caption) + Button(action: { + tagToDelete = tag + showingDeleteAlert = true + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + } + } + } + } + } + .navigationTitle("Manage Tags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Create Tag") { + showingCreateAlert = true + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + isPresented = false + } + .fontWeight(.semibold) + } + } + .alert("Create Custom Tag", isPresented: $showingCreateAlert) { + TextField("Tag name", text: $newTagName) + Button("Cancel", role: .cancel) { + newTagName = "" + } + Button("Create") { + createTag() + } + } + .alert("Delete Tag", isPresented: $showingDeleteAlert) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + if let tag = tagToDelete { + deleteTag(tag) + } + } + } message: { + if let tag = tagToDelete { + Text("Are you sure you want to delete '\(tag.name ?? "")'? This will remove it from \(tag.sortedEntries.count) entries.") + } + } + } + .onAppear { + loadTags() + } + } + + private func loadTags() { + allTags = Tag.fetchAllTags(in: viewContext) + } + + private func createTag() { + guard !newTagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + _ = Tag.fetchOrCreate(name: newTagName, isCustom: true, in: viewContext) + saveContext() + loadTags() + newTagName = "" + } + + private func deleteTag(_ tag: Tag) { + viewContext.delete(tag) + saveContext() + loadTags() + tagToDelete = nil + } + + private func saveContext() { + do { + try viewContext.save() + } catch { + print("Error saving context: \(error)") + } + } +} diff --git a/WorterBuch/VocabularyEntry+Extensions.swift b/WorterBuch/VocabularyEntry+Extensions.swift index 98a5a2f..f1944ca 100644 --- a/WorterBuch/VocabularyEntry+Extensions.swift +++ b/WorterBuch/VocabularyEntry+Extensions.swift @@ -58,4 +58,45 @@ extension VocabularyEntry { entry.englishTranslationText = "" return entry } + + // MARK: - Tag Management + + /// Get sorted tags array from NSSet + var sortedTags: [Tag] { + let tagsSet = tags as? Set ?? [] + return tagsSet.sorted { tag1, tag2 in + // Sort predefined tags first, then by name using natural sorting + if tag1.isCustom == tag2.isCustom { + return (tag1.name ?? "").localizedStandardCompare(tag2.name ?? "") == .orderedAscending + } + return !tag1.isCustom && tag2.isCustom + } + } + + /// Add a tag to this entry + func addTag(_ tag: Tag) { + let tagsSet = mutableSetValue(forKey: "tags") + tagsSet.add(tag) + } + + /// Remove a tag from this entry + func removeTag(_ tag: Tag) { + let tagsSet = mutableSetValue(forKey: "tags") + tagsSet.remove(tag) + } + + /// Check if entry has a specific tag + func hasTag(_ tag: Tag) -> Bool { + let tagsSet = tags as? Set ?? [] + return tagsSet.contains(tag) + } + + /// Toggle tag (add if not present, remove if present) + func toggleTag(_ tag: Tag) { + if hasTag(tag) { + removeTag(tag) + } else { + addTag(tag) + } + } } diff --git a/WorterBuch/VocabularyGridView.swift b/WorterBuch/VocabularyGridView.swift index a341778..6db6035 100644 --- a/WorterBuch/VocabularyGridView.swift +++ b/WorterBuch/VocabularyGridView.swift @@ -25,6 +25,7 @@ struct VocabularyGridView: View { @State private var searchText = "" @State private var fieldSelection: FieldSelection? + @State private var showingTagManager = false var filteredEntries: [VocabularyEntry] { if searchText.isEmpty { @@ -105,16 +106,26 @@ struct VocabularyGridView: View { .navigationTitle("Wörterbuch") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button(action: addEntry) { - Label("Add Entry", systemImage: "plus") + HStack(spacing: 16) { + Button(action: { showingTagManager = true }) { + Label("Manage Tags", systemImage: "tag") + } + + Button(action: addEntry) { + Label("Add Entry", systemImage: "plus") + } } } } + .sheet(isPresented: $showingTagManager) { + TagManagerView() + } .sheet(item: $fieldSelection) { selection in FieldEditorView( drawing: bindingForDrawing(entry: selection.entry, fieldType: selection.fieldType), text: bindingForText(entry: selection.entry, fieldType: selection.fieldType), - fieldType: selection.fieldType + fieldType: selection.fieldType, + entry: selection.entry ) .onDisappear { saveContext() diff --git a/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents b/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents index 8f6cc6d..ff4cbd2 100644 --- a/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents +++ b/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents @@ -9,6 +9,7 @@ + @@ -16,8 +17,16 @@ + + + + + + + + \ No newline at end of file