feat(worterbuch): basic app implementation

This commit is contained in:
2025-12-01 15:35:13 +01:00
parent c4f8983db5
commit d4f2d928b6
12 changed files with 746 additions and 91 deletions

View File

@@ -9,78 +9,11 @@ import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
} label: {
Text(item.timestamp!, formatter: itemFormatter)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
VocabularyGridView()
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
#Preview {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}

View File

@@ -0,0 +1,32 @@
//
// DictionaryHelper.swift
// WorterBuch
//
// Created by Oliver Hnát on 01.12.2025.
//
import UIKit
class DictionaryHelper {
static func showDefinition(for text: String) {
guard UIReferenceLibraryViewController.dictionaryHasDefinition(forTerm: text) else {
print("No definition found for: \(text)")
return
}
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController else {
return
}
let dictionaryViewController = UIReferenceLibraryViewController(term: text)
var topController = rootViewController
while let presented = topController.presentedViewController {
topController = presented
}
topController.present(dictionaryViewController, animated: true, completion: nil)
}
}

View File

@@ -0,0 +1,127 @@
//
// FieldEditorView.swift
// WorterBuch
//
// Created by Oliver Hnát on 01.12.2025.
//
import SwiftUI
import PencilKit
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
@Binding var drawing: PKDrawing
@Binding var text: String
let fieldType: FieldType
@State private var isRecognizing = false
var body: some View {
NavigationView {
VStack(spacing: 0) {
// Handwriting canvas
HandwritingCanvasView(
drawing: $drawing,
onDrawingChanged: { newDrawing in
recognizeHandwriting(newDrawing)
},
isEditable: true
)
.frame(maxWidth: .infinity)
.frame(height: 400)
.background(Color(.systemGray6))
.cornerRadius(12)
.padding()
// 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()
Spacer()
}
.navigationTitle(fieldType.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Clear") {
drawing = PKDrawing()
text = ""
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
.fontWeight(.semibold)
}
}
}
.navigationViewStyle(.stack)
}
private func recognizeHandwriting(_ drawing: PKDrawing) {
print("=== recognizeHandwriting called ===")
print("Drawing bounds: \(drawing.bounds)")
print("Drawing is empty: \(drawing.bounds.isEmpty)")
guard !drawing.bounds.isEmpty else {
print("Drawing bounds are empty, skipping recognition")
return
}
print("Starting recognition...")
isRecognizing = true
Task {
if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) {
print("Recognition successful: \(recognizedText)")
await MainActor.run {
text = recognizedText
isRecognizing = false
}
} else {
print("Recognition returned nil")
await MainActor.run {
isRecognizing = false
}
}
}
}
}

View File

@@ -0,0 +1,74 @@
//
// HandwritingCanvasView.swift
// WorterBuch
//
// Created by Oliver Hnát on 01.12.2025.
//
import SwiftUI
import PencilKit
struct HandwritingCanvasView: UIViewRepresentable {
@Binding var drawing: PKDrawing
var onDrawingChanged: ((PKDrawing) -> Void)?
var isEditable: Bool = true
func makeUIView(context: Context) -> PKCanvasView {
let canvasView = PKCanvasView()
canvasView.drawing = drawing
canvasView.delegate = context.coordinator
canvasView.drawingPolicy = .anyInput
canvasView.isOpaque = false
canvasView.backgroundColor = .clear
canvasView.isUserInteractionEnabled = isEditable
// Use the shared tool picker and show it after a delay
DispatchQueue.main.async {
if isEditable, let window = canvasView.window {
let toolPicker = PKToolPicker.shared(for: window)
toolPicker?.setVisible(true, forFirstResponder: canvasView)
toolPicker?.addObserver(canvasView)
canvasView.becomeFirstResponder()
}
}
return canvasView
}
func updateUIView(_ canvasView: PKCanvasView, context: Context) {
if canvasView.drawing != drawing {
canvasView.drawing = drawing
}
canvasView.isUserInteractionEnabled = isEditable
// Ensure tool picker is visible when editable
if isEditable {
DispatchQueue.main.async {
if let window = canvasView.window {
let toolPicker = PKToolPicker.shared(for: window)
toolPicker?.setVisible(true, forFirstResponder: canvasView)
canvasView.becomeFirstResponder()
}
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(drawing: $drawing, onDrawingChanged: onDrawingChanged)
}
class Coordinator: NSObject, PKCanvasViewDelegate {
var drawing: Binding<PKDrawing>
var onDrawingChanged: ((PKDrawing) -> Void)?
init(drawing: Binding<PKDrawing>, onDrawingChanged: ((PKDrawing) -> Void)?) {
self.drawing = drawing
self.onDrawingChanged = onDrawingChanged
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
drawing.wrappedValue = canvasView.drawing
onDrawingChanged?(canvasView.drawing)
}
}
}

View File

@@ -0,0 +1,117 @@
//
// HandwritingRecognizer.swift
// WorterBuch
//
// Created by Oliver Hnát on 01.12.2025.
//
import Foundation
import PencilKit
import Vision
import UIKit
class HandwritingRecognizer {
static func recognizeText(from drawing: PKDrawing, completion: @escaping (String?) -> Void) {
// Check if drawing has content
guard !drawing.bounds.isEmpty else {
print("Drawing bounds are empty")
completion(nil)
return
}
// Convert drawing to image with high scale for better recognition
let bounds = drawing.bounds
print("Drawing bounds: \(bounds)")
// Create a high-resolution image with white background for better recognition
let scale: CGFloat = 4.0
let imageSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
// Create image with white background
UIGraphicsBeginImageContextWithOptions(bounds.size, true, scale)
guard let context = UIGraphicsGetCurrentContext() else {
print("Failed to create graphics context")
completion(nil)
return
}
// Fill with white background
context.setFillColor(UIColor.white.cgColor)
context.fill(CGRect(origin: .zero, size: bounds.size))
// Draw the PKDrawing
let drawingImage = drawing.image(from: bounds, scale: scale)
drawingImage.draw(at: .zero)
guard let image = UIGraphicsGetImageFromCurrentImageContext() else {
UIGraphicsEndImageContext()
print("Failed to create image from context")
completion(nil)
return
}
UIGraphicsEndImageContext()
guard let cgImage = image.cgImage else {
print("Failed to create CGImage from drawing")
completion(nil)
return
}
print("Created image with size: \(image.size), scale: \(image.scale), pixel size: \(imageSize)")
// Create Vision request
let request = VNRecognizeTextRequest { request, error in
if let error = error {
print("Text recognition error: \(error.localizedDescription)")
completion(nil)
return
}
guard let observations = request.results as? [VNRecognizedTextObservation] else {
print("No text observations found")
completion(nil)
return
}
print("Found \(observations.count) text observations")
// Combine all recognized text
let recognizedStrings = observations.compactMap { observation in
observation.topCandidates(1).first?.string
}
print("Recognized strings: \(recognizedStrings)")
let recognizedText = recognizedStrings.joined(separator: " ")
completion(recognizedText.isEmpty ? nil : recognizedText)
}
// Configure for German language and handwriting
request.recognitionLanguages = ["de-DE", "en-US"]
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
// Enable automatic language detection as fallback
request.automaticallyDetectsLanguage = true
// Perform request
let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:])
DispatchQueue.global(qos: .userInitiated).async {
do {
try requestHandler.perform([request])
} catch {
print("Failed to perform text recognition: \(error.localizedDescription)")
completion(nil)
}
}
}
static func recognizeTextAsync(from drawing: PKDrawing) async -> String? {
await withCheckedContinuation { continuation in
recognizeText(from: drawing) { result in
continuation.resume(returning: result)
}
}
}
}

View File

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
<dict/>
</plist>

View File

@@ -14,15 +14,17 @@ struct PersistenceController {
static let preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
for i in 0..<5 {
let entry = VocabularyEntry.create(in: viewContext)
entry.timestamp = Date()
entry.id = UUID()
entry.germanWordText = "Beispielwort \(i + 1)"
entry.germanExplanationText = "Dies ist eine Erklärung des deutschen Wortes"
entry.englishTranslationText = "Example word \(i + 1)"
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}

View File

@@ -0,0 +1,61 @@
//
// VocabularyEntry+Extensions.swift
// WorterBuch
//
// Created by Oliver Hnát on 01.12.2025.
//
import Foundation
import PencilKit
import CoreData
extension VocabularyEntry {
// MARK: - German Word Drawing
var germanWordPKDrawing: PKDrawing {
get {
guard let data = germanWordDrawing else { return PKDrawing() }
return (try? PKDrawing(data: data)) ?? PKDrawing()
}
set {
germanWordDrawing = newValue.dataRepresentation()
}
}
// MARK: - German Explanation Drawing
var germanExplanationPKDrawing: PKDrawing {
get {
guard let data = germanExplanationDrawing else { return PKDrawing() }
return (try? PKDrawing(data: data)) ?? PKDrawing()
}
set {
germanExplanationDrawing = newValue.dataRepresentation()
}
}
// MARK: - English Translation Drawing
var englishTranslationPKDrawing: PKDrawing {
get {
guard let data = englishTranslationDrawing else { return PKDrawing() }
return (try? PKDrawing(data: data)) ?? PKDrawing()
}
set {
englishTranslationDrawing = newValue.dataRepresentation()
}
}
// MARK: - Convenience Initializer
static func create(in context: NSManagedObjectContext) -> VocabularyEntry {
let entry = VocabularyEntry(context: context)
entry.id = UUID()
entry.timestamp = Date()
entry.germanWordText = ""
entry.germanExplanationText = ""
entry.englishTranslationText = ""
return entry
}
}

View File

@@ -0,0 +1,61 @@
//
// VocabularyFieldCell.swift
// WorterBuch
//
// Created by Oliver Hnát on 01.12.2025.
//
import SwiftUI
import PencilKit
struct VocabularyFieldCell: View {
let drawing: PKDrawing
let text: String
let onTap: () -> Void
let onLongPress: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
// Handwriting preview
if !drawing.bounds.isEmpty {
Image(uiImage: drawing.image(from: drawing.bounds, scale: 2.0))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 60)
.frame(maxWidth: .infinity)
.background(Color(.systemGray6))
} else {
Rectangle()
.fill(Color(.systemGray6))
.frame(height: 60)
.overlay(
Text("Tap to write")
.font(.caption)
.foregroundColor(.secondary)
)
}
// Transcribed text
Text(text.isEmpty ? " " : text.lowercased())
.font(.caption)
.foregroundColor(.primary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 30)
}
.padding(8)
.background(Color(.systemBackground))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
)
.contentShape(Rectangle())
.onTapGesture {
onTap()
}
.onLongPressGesture {
onLongPress()
}
}
}

View File

@@ -0,0 +1,255 @@
//
// VocabularyGridView.swift
// WorterBuch
//
// Created by Oliver Hnát on 01.12.2025.
//
import SwiftUI
import PencilKit
import CoreData
struct VocabularyGridView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \VocabularyEntry.timestamp, ascending: false)],
animation: .default
)
private var entries: FetchedResults<VocabularyEntry>
@State private var searchText = ""
@State private var selectedEntry: VocabularyEntry?
@State private var selectedFieldType: FieldType?
@State private var showingFieldEditor = false
// Temporary bindings for editing
@State private var editingDrawing: PKDrawing = PKDrawing()
@State private var editingText: String = ""
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 words, explanations, or translations", 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()
// Column headers
HStack(spacing: 12) {
Text("German Word")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Text("German Explanation")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Text("English Translation")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal)
.padding(.bottom, 8)
Divider()
// Entries list
ScrollView {
LazyVStack(spacing: 12) {
ForEach(filteredEntries, id: \.id) { entry in
VocabularyEntryRow(
entry: entry,
onSelectField: { fieldType in
openFieldEditor(for: entry, fieldType: fieldType)
},
onLongPress: { fieldType in
showDefinition(for: entry, fieldType: fieldType)
}
)
}
.onDelete(perform: deleteEntries)
}
.padding()
}
}
.navigationTitle("Wörterbuch")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addEntry) {
Label("Add Entry", systemImage: "plus")
}
}
}
.sheet(isPresented: $showingFieldEditor) {
if let entry = selectedEntry, let fieldType = selectedFieldType {
FieldEditorView(
drawing: bindingForDrawing(entry: entry, fieldType: fieldType),
text: bindingForText(entry: entry, fieldType: fieldType),
fieldType: fieldType
)
.onDisappear {
saveContext()
}
}
}
}
.navigationViewStyle(.stack)
}
private func openFieldEditor(for entry: VocabularyEntry, fieldType: FieldType) {
selectedEntry = entry
selectedFieldType = fieldType
showingFieldEditor = true
}
private func bindingForDrawing(entry: VocabularyEntry, fieldType: FieldType) -> Binding<PKDrawing> {
Binding(
get: {
switch fieldType {
case .germanWord:
return entry.germanWordPKDrawing
case .germanExplanation:
return entry.germanExplanationPKDrawing
case .englishTranslation:
return entry.englishTranslationPKDrawing
}
},
set: { newValue in
switch fieldType {
case .germanWord:
entry.germanWordPKDrawing = newValue
case .germanExplanation:
entry.germanExplanationPKDrawing = newValue
case .englishTranslation:
entry.englishTranslationPKDrawing = newValue
}
}
)
}
private func bindingForText(entry: VocabularyEntry, fieldType: FieldType) -> Binding<String> {
Binding(
get: {
switch fieldType {
case .germanWord:
return entry.germanWordText ?? ""
case .germanExplanation:
return entry.germanExplanationText ?? ""
case .englishTranslation:
return entry.englishTranslationText ?? ""
}
},
set: { newValue in
switch fieldType {
case .germanWord:
entry.germanWordText = newValue
case .germanExplanation:
entry.germanExplanationText = newValue
case .englishTranslation:
entry.englishTranslationText = newValue
}
}
)
}
private func addEntry() {
withAnimation {
let _ = VocabularyEntry.create(in: viewContext)
saveContext()
}
}
private func deleteEntries(offsets: IndexSet) {
withAnimation {
offsets.map { filteredEntries[$0] }.forEach(viewContext.delete)
saveContext()
}
}
private func saveContext() {
do {
try viewContext.save()
} catch {
let nsError = error as NSError
print("Error saving context: \(nsError), \(nsError.userInfo)")
}
}
private func showDefinition(for entry: VocabularyEntry, fieldType: FieldType) {
let text: String
switch fieldType {
case .germanWord:
text = entry.germanWordText ?? ""
case .germanExplanation:
text = entry.germanExplanationText ?? ""
case .englishTranslation:
text = entry.englishTranslationText ?? ""
}
if !text.isEmpty {
DictionaryHelper.showDefinition(for: text)
}
}
}
struct VocabularyEntryRow: View {
@ObservedObject var entry: VocabularyEntry
let onSelectField: (FieldType) -> Void
let onLongPress: (FieldType) -> Void
var body: some View {
HStack(spacing: 12) {
// German word
VocabularyFieldCell(
drawing: entry.germanWordPKDrawing,
text: entry.germanWordText ?? "",
onTap: { onSelectField(.germanWord) },
onLongPress: { onLongPress(.germanWord) }
)
.frame(maxWidth: .infinity)
// German explanation
VocabularyFieldCell(
drawing: entry.germanExplanationPKDrawing,
text: entry.germanExplanationText ?? "",
onTap: { onSelectField(.germanExplanation) },
onLongPress: { onLongPress(.germanExplanation) }
)
.frame(maxWidth: .infinity)
// English translation
VocabularyFieldCell(
drawing: entry.englishTranslationPKDrawing,
text: entry.englishTranslationText ?? "",
onTap: { onSelectField(.englishTranslation) },
onLongPress: { onLongPress(.englishTranslation) }
)
.frame(maxWidth: .infinity)
}
}
}

View File

@@ -1,14 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array/>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
</dict>
<dict/>
</plist>

View File

@@ -1,9 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="true" userDefinedModelVersionIdentifier="">
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
<entity name="VocabularyEntry" representedClassName="VocabularyEntry" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="germanWordDrawing" optional="YES" attributeType="Binary"/>
<attribute name="germanWordText" optional="YES" attributeType="String"/>
<attribute name="germanExplanationDrawing" optional="YES" attributeType="Binary"/>
<attribute name="germanExplanationText" optional="YES" attributeType="String"/>
<attribute name="englishTranslationDrawing" optional="YES" attributeType="Binary"/>
<attribute name="englishTranslationText" optional="YES" attributeType="String"/>
</entity>
<elements>
<element name="Item" positionX="-63" positionY="-18" width="128" height="44"/>
<element name="VocabularyEntry" positionX="-63" positionY="-18" width="128" height="178"/>
</elements>
</model>