feat(tags): kinda wobbly but added tags

This commit is contained in:
2025-12-06 15:41:11 +01:00
parent eb5a5ddd6e
commit 642a113a97
7 changed files with 1004 additions and 3 deletions

View File

@@ -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<UUID> = []
@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)
}
}
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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> = 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> = 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<VocabularyEntry> ?? []
return entriesSet.sorted { ($0.timestamp ?? Date()) > ($1.timestamp ?? Date()) }
}
}

View File

@@ -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<VocabularyEntry>
@State private var selectedEntries: Set<UUID> = []
@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<UUID>
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<UUID> = []
@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)")
}
}
}

View File

@@ -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<Tag> ?? []
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<Tag> ?? []
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)
}
}
}

View File

@@ -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()

View File

@@ -9,6 +9,7 @@
<attribute name="germanExplanationText" optional="YES" attributeType="String"/>
<attribute name="englishTranslationDrawing" optional="YES" attributeType="Binary"/>
<attribute name="englishTranslationText" optional="YES" attributeType="String"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="entries" inverseEntity="Tag"/>
</entity>
<entity name="Note" representedClassName="Note" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
@@ -16,8 +17,16 @@
<attribute name="drawing" optional="YES" attributeType="Binary"/>
<attribute name="text" optional="YES" attributeType="String"/>
</entity>
<entity name="Tag" representedClassName="Tag" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="isCustom" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="createdDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="entries" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="VocabularyEntry" inverseName="tags" inverseEntity="VocabularyEntry"/>
</entity>
<elements>
<element name="VocabularyEntry" positionX="-63" positionY="-18" width="128" height="178"/>
<element name="Note" positionX="-63" positionY="207" width="128" height="89"/>
<element name="Tag" positionX="180" positionY="-18" width="128" height="104"/>
</elements>
</model>