Files
WorterBuch/WorterBuch/FieldEditorView.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)
}
}
}
}
}