Compare commits

..

6 Commits

8 changed files with 297 additions and 82 deletions

View File

@@ -35,6 +35,7 @@ struct FieldEditorView: View {
@State private var viewAppeared = false @State private var viewAppeared = false
@State private var showingTagPicker = false @State private var showingTagPicker = false
@State private var newTagName = "" @State private var newTagName = ""
@State private var recognitionWorkItem: DispatchWorkItem?
var body: some View { var body: some View {
NavigationView { NavigationView {
@@ -174,31 +175,31 @@ struct FieldEditorView: View {
} }
private func recognizeHandwriting(_ drawing: PKDrawing) { 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 { guard !drawing.bounds.isEmpty else {
print("Drawing bounds are empty, skipping recognition")
return return
} }
print("Starting recognition...") // Cancel any pending recognition
isRecognizing = true recognitionWorkItem?.cancel()
Task {
if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) { // Create new work item with debounce
print("Recognition successful: \(recognizedText)") let workItem = DispatchWorkItem { [drawing] in
await MainActor.run { Task { @MainActor in
isRecognizing = true
if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) {
text = recognizedText text = recognizedText
isRecognizing = false isRecognizing = false
} } else {
} else {
print("Recognition returned nil")
await MainActor.run {
isRecognizing = false isRecognizing = false
} }
} }
} }
recognitionWorkItem = workItem
// Execute after 3 seconds delay (same as NotesListView)
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
} }
} }

View File

@@ -25,6 +25,11 @@ struct HandwritingCanvasView: UIViewRepresentable {
// Store canvas in coordinator for later access // Store canvas in coordinator for later access
context.coordinator.canvasView = canvasView context.coordinator.canvasView = canvasView
// Restore saved tool if available
if let savedTool = Coordinator.savedTool {
canvasView.tool = savedTool
}
return canvasView return canvasView
} }
@@ -45,6 +50,8 @@ struct HandwritingCanvasView: UIViewRepresentable {
} }
class Coordinator: NSObject, PKCanvasViewDelegate { class Coordinator: NSObject, PKCanvasViewDelegate {
static var savedTool: PKTool?
var drawing: Binding<PKDrawing> var drawing: Binding<PKDrawing>
var onDrawingChanged: ((PKDrawing) -> Void)? var onDrawingChanged: ((PKDrawing) -> Void)?
var canvasView: PKCanvasView? var canvasView: PKCanvasView?
@@ -78,6 +85,9 @@ struct HandwritingCanvasView: UIViewRepresentable {
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
drawing.wrappedValue = canvasView.drawing drawing.wrappedValue = canvasView.drawing
onDrawingChanged?(canvasView.drawing) onDrawingChanged?(canvasView.drawing)
// Save the current tool for persistence
Coordinator.savedTool = canvasView.tool
} }
} }
} }

View File

@@ -40,9 +40,13 @@ class HandwritingRecognizer {
context.setFillColor(UIColor.white.cgColor) context.setFillColor(UIColor.white.cgColor)
context.fill(CGRect(origin: .zero, size: bounds.size)) context.fill(CGRect(origin: .zero, size: bounds.size))
// Draw the PKDrawing // Force light mode appearance to ensure dark ink on white background
let drawingImage = drawing.image(from: bounds, scale: scale) let lightTraits = UITraitCollection(userInterfaceStyle: .light)
drawingImage.draw(at: .zero) var drawingImage: UIImage!
lightTraits.performAsCurrent {
drawingImage = drawing.image(from: bounds, scale: scale)
}
drawingImage.draw(at: CGPoint.zero)
guard let image = UIGraphicsGetImageFromCurrentImageContext() else { guard let image = UIGraphicsGetImageFromCurrentImageContext() else {
UIGraphicsEndImageContext() UIGraphicsEndImageContext()
@@ -83,7 +87,7 @@ class HandwritingRecognizer {
print("Recognized strings: \(recognizedStrings)") print("Recognized strings: \(recognizedStrings)")
let recognizedText = recognizedStrings.joined(separator: " ") let recognizedText = recognizedStrings.joined(separator: "\n")
completion(recognizedText.isEmpty ? nil : recognizedText) completion(recognizedText.isEmpty ? nil : recognizedText)
} }

View File

@@ -18,12 +18,25 @@ struct NotesListView: View {
private var notes: FetchedResults<Note> private var notes: FetchedResults<Note>
@State private var selectedNote: Note? @State private var selectedNote: Note?
@State private var searchText = ""
var filteredNotes: [Note] {
if searchText.isEmpty {
return Array(notes)
} else {
return notes.filter { note in
let titleMatch = note.title.localizedCaseInsensitiveContains(searchText)
let textMatch = (note.text ?? "").localizedCaseInsensitiveContains(searchText)
return titleMatch || textMatch
}
}
}
var body: some View { var body: some View {
NavigationView { NavigationView {
// Left sidebar - list of notes // Left sidebar - list of notes
List(selection: $selectedNote) { List(selection: $selectedNote) {
ForEach(notes, id: \.id) { note in ForEach(filteredNotes, id: \.id) { note in
NoteRowView(note: note) NoteRowView(note: note)
.tag(note) .tag(note)
.onTapGesture { .onTapGesture {
@@ -47,6 +60,7 @@ struct NotesListView: View {
} }
} }
} }
.searchable(text: $searchText, prompt: "Search notes")
// Right panel - note editor or placeholder // Right panel - note editor or placeholder
if let note = selectedNote { if let note = selectedNote {
@@ -108,28 +122,31 @@ struct NotesListView: View {
} }
private func deleteNotes(offsets: IndexSet) { private func deleteNotes(offsets: IndexSet) {
withAnimation { let notesToDelete = offsets.map { notes[$0] }
let notesToDelete = offsets.map { notes[$0] }
// Clear selection if we're deleting the selected note // Clear selection if we're deleting the selected note
if let selected = selectedNote, notesToDelete.contains(where: { $0.id == selected.id }) { if let selected = selectedNote, notesToDelete.contains(where: { $0.id == selected.id }) {
selectedNote = nil selectedNote = nil
} }
// Delete the notes // Small delay to ensure UI updates before deletion
notesToDelete.forEach { note in DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
viewContext.delete(note) withAnimation {
} // Delete the notes
notesToDelete.forEach { note in
viewContext.delete(note)
}
saveContext() saveContext()
// After deletion, if no notes are left, ensure selection is nil // After deletion, if no notes are left, ensure selection is nil
// Otherwise, select the first remaining note if nothing is selected // Otherwise, select the first remaining note if nothing is selected
DispatchQueue.main.async { DispatchQueue.main.async {
if notes.isEmpty { if notes.isEmpty {
selectedNote = nil selectedNote = nil
} else if selectedNote == nil && !notes.isEmpty { } else if selectedNote == nil && !notes.isEmpty {
selectedNote = notes.first selectedNote = notes.first
}
} }
} }
} }
@@ -159,7 +176,7 @@ struct NoteRowView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(note.title) Text(note.title)
.font(.body) .font(.body)
.lineLimit(2) .lineLimit(1)
if note.timestamp != nil { if note.timestamp != nil {
Text(timeAgo) Text(timeAgo)
@@ -175,6 +192,8 @@ struct NoteEditorContentView: View {
@ObservedObject var note: Note @ObservedObject var note: Note
@Environment(\.managedObjectContext) private var viewContext @Environment(\.managedObjectContext) private var viewContext
let onAddNote: () -> Void let onAddNote: () -> Void
@State private var showTranscription = false
@State private var forceTranscriptionTrigger = 0
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -194,6 +213,22 @@ struct NoteEditorContentView: View {
Spacer() Spacer()
Button(action: {
withAnimation {
let wasHidden = !showTranscription
showTranscription.toggle()
if wasHidden {
// Trigger immediate transcription when showing
forceTranscriptionTrigger += 1
}
}
}) {
Image(systemName: showTranscription ? "text.bubble.fill" : "text.bubble")
.font(.body)
.foregroundColor(showTranscription ? .blue : .primary)
}
.padding(.trailing, 8)
Button(action: onAddNote) { Button(action: onAddNote) {
Image(systemName: "plus") Image(systemName: "plus")
.font(.body) .font(.body)
@@ -219,7 +254,9 @@ struct NoteEditorContentView: View {
// Note editor content // Note editor content
NoteEditorContentOnly( NoteEditorContentOnly(
drawing: bindingForDrawing(), drawing: bindingForDrawing(),
text: bindingForText() text: bindingForText(),
showTranscription: $showTranscription,
forceTranscriptionTrigger: forceTranscriptionTrigger
) )
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
@@ -229,6 +266,8 @@ struct NoteEditorContentView: View {
Binding( Binding(
get: { note.pkDrawing }, get: { note.pkDrawing },
set: { newValue in set: { newValue in
// Guard against modifying deleted objects
guard note.managedObjectContext != nil else { return }
note.pkDrawing = newValue note.pkDrawing = newValue
saveContext() saveContext()
} }
@@ -239,6 +278,8 @@ struct NoteEditorContentView: View {
Binding( Binding(
get: { note.text ?? "" }, get: { note.text ?? "" },
set: { newValue in set: { newValue in
// Guard against modifying deleted objects
guard note.managedObjectContext != nil else { return }
note.text = newValue note.text = newValue
saveContext() saveContext()
} }
@@ -258,9 +299,10 @@ struct NoteEditorContentView: View {
struct NoteEditorContentOnly: View { struct NoteEditorContentOnly: View {
@Binding var drawing: PKDrawing @Binding var drawing: PKDrawing
@Binding var text: String @Binding var text: String
@Binding var showTranscription: Bool
let forceTranscriptionTrigger: Int
@State private var isRecognizing = false @State private var isRecognizing = false
@State private var showTranscription = false
@State private var viewAppeared = false @State private var viewAppeared = false
@State private var recognitionWorkItem: DispatchWorkItem? @State private var recognitionWorkItem: DispatchWorkItem?
@@ -334,23 +376,30 @@ struct NoteEditorContentOnly: View {
.padding() .padding()
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
} }
}
.onChange(of: forceTranscriptionTrigger) { _ in
forceRecognizeHandwriting()
}
}
// Toggle transcription button private func forceRecognizeHandwriting() {
if !showTranscription { guard !drawing.bounds.isEmpty else {
Button(action: { return
withAnimation { }
showTranscription = true
} // Cancel any pending recognition
}) { recognitionWorkItem?.cancel()
HStack {
Image(systemName: "text.bubble") // Perform recognition immediately without debounce
Text("Show Transcription") Task { @MainActor in
} isRecognizing = true
.font(.subheadline) showTranscription = true // Auto-show transcription when forcing
.foregroundColor(.blue)
.padding(.vertical, 8) if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) {
} text = recognizedText
.padding(.bottom) isRecognizing = false
} else {
isRecognizing = false
} }
} }
} }
@@ -379,7 +428,7 @@ struct NoteEditorContentOnly: View {
recognitionWorkItem = workItem recognitionWorkItem = workItem
// Execute after 800ms delay (adjust as needed) // Execute after 3 seconds delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8, execute: workItem) DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
} }
} }

View File

@@ -30,21 +30,47 @@ struct ScrollableCanvasView: UIViewRepresentable {
// Allow finger touches to pass through for scrolling // Allow finger touches to pass through for scrolling
canvasView.allowsFingerDrawing = false canvasView.allowsFingerDrawing = false
// Set initial canvas size - will be updated in updateUIView with proper bounds // Set initial canvas size based on drawing bounds
let canvasWidth: CGFloat = 800 // Placeholder width let drawingBounds = drawing.bounds
let canvasHeight: CGFloat = 3000 // Start with a large canvas let canvasWidth: CGFloat
let canvasHeight: CGFloat
if drawingBounds.isEmpty {
canvasWidth = 2000
canvasHeight = 3000
} else {
canvasWidth = max(drawingBounds.maxX + 500, 2000)
canvasHeight = max(drawingBounds.maxY + 500, 3000)
}
canvasView.frame = CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight) canvasView.frame = CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight)
scrollView.addSubview(canvasView) scrollView.addSubview(canvasView)
scrollView.contentSize = canvasView.frame.size scrollView.contentSize = canvasView.frame.size
// Allow scrolling beyond content // Allow scrolling beyond content in both directions
scrollView.alwaysBounceVertical = true scrollView.alwaysBounceVertical = true
scrollView.alwaysBounceHorizontal = true
// Enable zooming
scrollView.minimumZoomScale = 0.25 // Zoom out to 25% (see full canvas)
scrollView.maximumZoomScale = 2.0 // Zoom in to 200%
scrollView.delegate = context.coordinator
// Store references in coordinator // Store references in coordinator
context.coordinator.canvasView = canvasView context.coordinator.canvasView = canvasView
context.coordinator.scrollView = scrollView context.coordinator.scrollView = scrollView
// Add double-tap gesture for zoom toggle
let doubleTapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleDoubleTap(_:)))
doubleTapGesture.numberOfTapsRequired = 2
scrollView.addGestureRecognizer(doubleTapGesture)
// Restore saved tool if available
if let savedTool = Coordinator.savedTool {
canvasView.tool = savedTool
}
return scrollView return scrollView
} }
@@ -56,23 +82,24 @@ struct ScrollableCanvasView: UIViewRepresentable {
} }
canvasView.isUserInteractionEnabled = isEditable canvasView.isUserInteractionEnabled = isEditable
// Update canvas width to match scroll view bounds (once they're valid)
let canvasWidth = scrollView.bounds.width > 0 ? scrollView.bounds.width : 800
// Expand canvas if drawing extends beyond current bounds // Expand canvas if drawing extends beyond current bounds
let drawingBounds = drawing.bounds let drawingBounds = drawing.bounds
let currentWidth = canvasView.frame.width
let currentHeight = canvasView.frame.height let currentHeight = canvasView.frame.height
let requiredWidth: CGFloat
let requiredHeight: CGFloat let requiredHeight: CGFloat
if drawingBounds.isEmpty { if drawingBounds.isEmpty {
requiredWidth = 2000
requiredHeight = 3000 requiredHeight = 3000
} else { } else {
requiredWidth = max(drawingBounds.maxX + 500, 2000) // Add padding to the right
requiredHeight = max(drawingBounds.maxY + 500, 3000) // Add padding below requiredHeight = max(drawingBounds.maxY + 500, 3000) // Add padding below
} }
// Update canvas size if needed // Update canvas size if needed
if requiredHeight > currentHeight || abs(canvasView.frame.width - canvasWidth) > 1 { if requiredWidth > currentWidth || requiredHeight > currentHeight {
canvasView.frame = CGRect(x: 0, y: 0, width: canvasWidth, height: requiredHeight) canvasView.frame = CGRect(x: 0, y: 0, width: requiredWidth, height: requiredHeight)
scrollView.contentSize = canvasView.frame.size scrollView.contentSize = canvasView.frame.size
} }
@@ -86,7 +113,9 @@ struct ScrollableCanvasView: UIViewRepresentable {
Coordinator(drawing: $drawing, onDrawingChanged: onDrawingChanged) Coordinator(drawing: $drawing, onDrawingChanged: onDrawingChanged)
} }
class Coordinator: NSObject, PKCanvasViewDelegate { class Coordinator: NSObject, PKCanvasViewDelegate, UIScrollViewDelegate {
static var savedTool: PKTool?
var drawing: Binding<PKDrawing> var drawing: Binding<PKDrawing>
var onDrawingChanged: ((PKDrawing) -> Void)? var onDrawingChanged: ((PKDrawing) -> Void)?
var canvasView: PKCanvasView? var canvasView: PKCanvasView?
@@ -121,6 +150,54 @@ struct ScrollableCanvasView: UIViewRepresentable {
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
drawing.wrappedValue = canvasView.drawing drawing.wrappedValue = canvasView.drawing
onDrawingChanged?(canvasView.drawing) onDrawingChanged?(canvasView.drawing)
// Save the current tool for persistence
Coordinator.savedTool = canvasView.tool
}
// MARK: - UIScrollViewDelegate for zooming
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return canvasView
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
// Snap back to normal zoom (1.0) if close enough
if scale > 0.9 && scale < 1.1 {
UIView.animate(withDuration: 0.2) {
scrollView.setZoomScale(1.0, animated: false)
}
}
}
@objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
guard let scrollView = scrollView else { return }
let currentScale = scrollView.zoomScale
if abs(currentScale - 1.0) < 0.01 {
// Currently at 100%, zoom in to 1.5x
let location = gesture.location(in: canvasView)
let zoomRect = zoomRect(for: 1.5, center: location)
scrollView.zoom(to: zoomRect, animated: true)
} else {
// Not at 100%, zoom back to 100%
scrollView.setZoomScale(1.0, animated: true)
}
}
private func zoomRect(for scale: CGFloat, center: CGPoint) -> CGRect {
guard let scrollView = scrollView else { return .zero }
var zoomRect = CGRect.zero
zoomRect.size.width = scrollView.frame.size.width / scale
zoomRect.size.height = scrollView.frame.size.height / scale
// Center on the tap location
zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)
return zoomRect
} }
} }
} }

View File

@@ -0,0 +1,34 @@
//
// TranslationService.swift
// WorterBuch
//
// Created by Claude on 07.12.2025.
//
import Foundation
import Translation
@available(iOS 18.0, *)
class TranslationService {
static func translate(text: String, from sourceLanguage: String = "de", to targetLanguage: String = "en") async -> String? {
do {
let sourceLocale = Locale.Language(identifier: sourceLanguage)
let targetLocale = Locale.Language(identifier: targetLanguage)
print("Attempting translation from \(sourceLanguage) to \(targetLanguage)")
// Create session - requires language pack to be installed
let session = TranslationSession(installedSource: sourceLocale, target: targetLocale)
let response = try await session.translate(text)
return response.targetText
} catch {
print("Translation error: \(error)")
print("To enable translation:")
print("1. Open the Translate app on your iPad")
print("2. Download German language pack")
print("3. Try the translate button again")
return nil
}
}
}

View File

@@ -12,6 +12,8 @@ struct VocabularyFieldCell: View {
let drawing: PKDrawing let drawing: PKDrawing
let text: String let text: String
let onTap: () -> Void let onTap: () -> Void
var showTranslateButton: Bool = false
var onTranslate: (() -> Void)? = nil
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -41,19 +43,30 @@ struct VocabularyFieldCell: View {
} }
// Transcribed text - selectable but not editable // Transcribed text - selectable but not editable
if !text.isEmpty { HStack(spacing: 8) {
SelectableTextView( if !text.isEmpty {
text: text.lowercased(), SelectableTextView(
font: .preferredFont(forTextStyle: .body), text: text.lowercased(),
textColor: .label font: .preferredFont(forTextStyle: .body),
) textColor: .label
.frame(maxWidth: .infinity, alignment: .leading) )
.frame(height: 40)
} else {
Text(" ")
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 40) .frame(height: 40)
} else {
Text(" ")
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 40)
}
if showTranslateButton, let onTranslate = onTranslate {
Button(action: onTranslate) {
Image(systemName: "arrow.right.arrow.left.circle.fill")
.foregroundColor(.blue)
.font(.system(size: 20))
}
.frame(width: 40, height: 40)
}
} }
} }
.padding(8) .padding(8)

View File

@@ -86,7 +86,8 @@ struct VocabularyGridView: View {
entry: entry, entry: entry,
onSelectField: { fieldType in onSelectField: { fieldType in
openFieldEditor(for: entry, fieldType: fieldType) openFieldEditor(for: entry, fieldType: fieldType)
} },
onTranslate: translateEntry
) )
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
@@ -211,11 +212,35 @@ struct VocabularyGridView: View {
print("Error saving context: \(nsError), \(nsError.userInfo)") print("Error saving context: \(nsError), \(nsError.userInfo)")
} }
} }
private func translateEntry(_ entry: VocabularyEntry) {
guard let germanText = entry.germanWordText, !germanText.isEmpty else { return }
guard entry.englishTranslationText?.isEmpty ?? true else { return }
Task {
if #available(iOS 18.0, *) {
if let translation = await TranslationService.translate(text: germanText) {
await MainActor.run {
entry.englishTranslationText = translation
saveContext()
}
}
} else {
print("Translation requires iOS 18.0 or later")
}
}
}
} }
struct VocabularyEntryRow: View { struct VocabularyEntryRow: View {
@ObservedObject var entry: VocabularyEntry @ObservedObject var entry: VocabularyEntry
let onSelectField: (FieldType) -> Void let onSelectField: (FieldType) -> Void
let onTranslate: (VocabularyEntry) -> Void
var shouldShowTranslateButton: Bool {
!(entry.germanWordText?.isEmpty ?? true) &&
(entry.englishTranslationText?.isEmpty ?? true)
}
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -239,7 +264,9 @@ struct VocabularyEntryRow: View {
VocabularyFieldCell( VocabularyFieldCell(
drawing: entry.englishTranslationPKDrawing, drawing: entry.englishTranslationPKDrawing,
text: entry.englishTranslationText ?? "", text: entry.englishTranslationText ?? "",
onTap: { onSelectField(.englishTranslation) } onTap: { onSelectField(.englishTranslation) },
showTranslateButton: shouldShowTranslateButton,
onTranslate: { onTranslate(entry) }
) )
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }