424 lines
13 KiB
Swift
424 lines
13 KiB
Swift
//
|
|
// FieldEditorView.swift
|
|
// WorterBuch
|
|
//
|
|
// Created by Oliver Hnát on 01.12.2025.
|
|
//
|
|
|
|
import SwiftUI
|
|
import PencilKit
|
|
import CoreData
|
|
|
|
enum FieldType {
|
|
case germanWord
|
|
case germanExplanation
|
|
case englishTranslation
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .germanWord: return "German Word"
|
|
case .germanExplanation: return "German Explanation"
|
|
case .englishTranslation: return "English Translation"
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = ""
|
|
@State private var recognitionWorkItem: DispatchWorkItem?
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
VStack(spacing: 0) {
|
|
// Handwriting canvas
|
|
if viewAppeared {
|
|
HandwritingCanvasView(
|
|
drawing: $drawing,
|
|
onDrawingChanged: { newDrawing in
|
|
recognizeHandwriting(newDrawing)
|
|
},
|
|
isEditable: true
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 400)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(12)
|
|
.padding()
|
|
} else {
|
|
// Placeholder while loading
|
|
Rectangle()
|
|
.fill(Color(.systemGray6))
|
|
.frame(height: 400)
|
|
.cornerRadius(12)
|
|
.padding()
|
|
.onAppear {
|
|
// Show canvas after a brief delay to ensure window is ready
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
viewAppeared = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Transcribed text section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Transcribed Text")
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
|
|
if isRecognizing {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
|
|
TextEditor(text: $text)
|
|
.font(.body)
|
|
.frame(minHeight: 100)
|
|
.padding(8)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
.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)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Clear") {
|
|
drawing = PKDrawing()
|
|
text = ""
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
.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) {
|
|
guard !drawing.bounds.isEmpty else {
|
|
return
|
|
}
|
|
|
|
// Cancel any pending recognition
|
|
recognitionWorkItem?.cancel()
|
|
|
|
// Create new work item with debounce
|
|
let workItem = DispatchWorkItem { [drawing] in
|
|
Task { @MainActor in
|
|
isRecognizing = true
|
|
|
|
if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) {
|
|
text = recognizedText
|
|
isRecognizing = false
|
|
} else {
|
|
isRecognizing = false
|
|
}
|
|
}
|
|
}
|
|
|
|
recognitionWorkItem = workItem
|
|
|
|
// Execute after 3 seconds delay (same as NotesListView)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
|
|
}
|
|
}
|
|
|
|
// 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(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("All Tags")) {
|
|
ForEach(allTags, 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 New Tag") {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|