feat(tags): kinda wobbly but added tags
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PencilKit
|
import PencilKit
|
||||||
|
import CoreData
|
||||||
|
|
||||||
enum FieldType {
|
enum FieldType {
|
||||||
case germanWord
|
case germanWord
|
||||||
@@ -24,12 +25,16 @@ enum FieldType {
|
|||||||
|
|
||||||
struct FieldEditorView: View {
|
struct FieldEditorView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
@Binding var drawing: PKDrawing
|
@Binding var drawing: PKDrawing
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let fieldType: FieldType
|
let fieldType: FieldType
|
||||||
|
var entry: VocabularyEntry? = nil // Optional entry for tag management
|
||||||
|
|
||||||
@State private var isRecognizing = false
|
@State private var isRecognizing = false
|
||||||
@State private var viewAppeared = false
|
@State private var viewAppeared = false
|
||||||
|
@State private var showingTagPicker = false
|
||||||
|
@State private var newTagName = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
@@ -91,6 +96,48 @@ struct FieldEditorView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.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()
|
Spacer()
|
||||||
}
|
}
|
||||||
.navigationTitle(fieldType.title)
|
.navigationTitle(fieldType.title)
|
||||||
@@ -109,10 +156,23 @@ struct FieldEditorView: View {
|
|||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingTagPicker) {
|
||||||
|
if let entry = entry {
|
||||||
|
TagPickerView(entry: entry, isPresented: $showingTagPicker)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationViewStyle(.stack)
|
.navigationViewStyle(.stack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func saveContext() {
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
} catch {
|
||||||
|
print("Error saving context: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func recognizeHandwriting(_ drawing: PKDrawing) {
|
private func recognizeHandwriting(_ drawing: PKDrawing) {
|
||||||
print("=== recognizeHandwriting called ===")
|
print("=== recognizeHandwriting called ===")
|
||||||
print("Drawing bounds: \(drawing.bounds)")
|
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 result = PersistenceController(inMemory: true)
|
||||||
let viewContext = result.container.viewContext
|
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
|
// Create sample vocabulary entries
|
||||||
for i in 0..<5 {
|
for i in 0..<5 {
|
||||||
let entry = VocabularyEntry.create(in: viewContext)
|
let entry = VocabularyEntry.create(in: viewContext)
|
||||||
@@ -23,6 +29,11 @@ struct PersistenceController {
|
|||||||
entry.germanWordText = "Beispielwort \(i + 1)"
|
entry.germanWordText = "Beispielwort \(i + 1)"
|
||||||
entry.germanExplanationText = "Dies ist eine Erklärung des deutschen Wortes"
|
entry.germanExplanationText = "Dies ist eine Erklärung des deutschen Wortes"
|
||||||
entry.englishTranslationText = "Example word \(i + 1)"
|
entry.englishTranslationText = "Example word \(i + 1)"
|
||||||
|
|
||||||
|
// Add some sample tags
|
||||||
|
if i < tags.count {
|
||||||
|
entry.addTag(tags[i])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create sample notes
|
// Create sample notes
|
||||||
@@ -65,5 +76,11 @@ struct PersistenceController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
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 = ""
|
entry.englishTranslationText = ""
|
||||||
return entry
|
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 searchText = ""
|
||||||
@State private var fieldSelection: FieldSelection?
|
@State private var fieldSelection: FieldSelection?
|
||||||
|
@State private var showingTagManager = false
|
||||||
|
|
||||||
var filteredEntries: [VocabularyEntry] {
|
var filteredEntries: [VocabularyEntry] {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
@@ -105,16 +106,26 @@ struct VocabularyGridView: View {
|
|||||||
.navigationTitle("Wörterbuch")
|
.navigationTitle("Wörterbuch")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button(action: { showingTagManager = true }) {
|
||||||
|
Label("Manage Tags", systemImage: "tag")
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: addEntry) {
|
Button(action: addEntry) {
|
||||||
Label("Add Entry", systemImage: "plus")
|
Label("Add Entry", systemImage: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingTagManager) {
|
||||||
|
TagManagerView()
|
||||||
|
}
|
||||||
.sheet(item: $fieldSelection) { selection in
|
.sheet(item: $fieldSelection) { selection in
|
||||||
FieldEditorView(
|
FieldEditorView(
|
||||||
drawing: bindingForDrawing(entry: selection.entry, fieldType: selection.fieldType),
|
drawing: bindingForDrawing(entry: selection.entry, fieldType: selection.fieldType),
|
||||||
text: bindingForText(entry: selection.entry, fieldType: selection.fieldType),
|
text: bindingForText(entry: selection.entry, fieldType: selection.fieldType),
|
||||||
fieldType: selection.fieldType
|
fieldType: selection.fieldType,
|
||||||
|
entry: selection.entry
|
||||||
)
|
)
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
saveContext()
|
saveContext()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<attribute name="germanExplanationText" optional="YES" attributeType="String"/>
|
<attribute name="germanExplanationText" optional="YES" attributeType="String"/>
|
||||||
<attribute name="englishTranslationDrawing" optional="YES" attributeType="Binary"/>
|
<attribute name="englishTranslationDrawing" optional="YES" attributeType="Binary"/>
|
||||||
<attribute name="englishTranslationText" optional="YES" attributeType="String"/>
|
<attribute name="englishTranslationText" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="entries" inverseEntity="Tag"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Note" representedClassName="Note" syncable="YES" codeGenerationType="class">
|
<entity name="Note" representedClassName="Note" syncable="YES" codeGenerationType="class">
|
||||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
@@ -16,8 +17,16 @@
|
|||||||
<attribute name="drawing" optional="YES" attributeType="Binary"/>
|
<attribute name="drawing" optional="YES" attributeType="Binary"/>
|
||||||
<attribute name="text" optional="YES" attributeType="String"/>
|
<attribute name="text" optional="YES" attributeType="String"/>
|
||||||
</entity>
|
</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>
|
<elements>
|
||||||
<element name="VocabularyEntry" positionX="-63" positionY="-18" width="128" height="178"/>
|
<element name="VocabularyEntry" positionX="-63" positionY="-18" width="128" height="178"/>
|
||||||
<element name="Note" positionX="-63" positionY="207" width="128" height="89"/>
|
<element name="Note" positionX="-63" positionY="207" width="128" height="89"/>
|
||||||
|
<element name="Tag" positionX="180" positionY="-18" width="128" height="104"/>
|
||||||
</elements>
|
</elements>
|
||||||
</model>
|
</model>
|
||||||
Reference in New Issue
Block a user