feat(tags): kinda wobbly but added tags
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
122
WorterBuch/Tag+Extensions.swift
Normal file
122
WorterBuch/Tag+Extensions.swift
Normal 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()) }
|
||||
}
|
||||
}
|
||||
512
WorterBuch/TagManagerView.swift
Normal file
512
WorterBuch/TagManagerView.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user