diff --git a/WorterBuch/ContentView.swift b/WorterBuch/ContentView.swift index 402d3ab..cd9a040 100644 --- a/WorterBuch/ContentView.swift +++ b/WorterBuch/ContentView.swift @@ -10,7 +10,17 @@ import CoreData struct ContentView: View { var body: some View { - VocabularyGridView() + TabView { + VocabularyGridView() + .tabItem { + Label("Dictionary", systemImage: "book.fill") + } + + NotesListView() + .tabItem { + Label("Notes", systemImage: "note.text") + } + } } } diff --git a/WorterBuch/Note+Extensions.swift b/WorterBuch/Note+Extensions.swift new file mode 100644 index 0000000..84ca751 --- /dev/null +++ b/WorterBuch/Note+Extensions.swift @@ -0,0 +1,52 @@ +// +// Note+Extensions.swift +// WorterBuch +// +// Created by Oliver Hnát on 06.12.2025. +// + +import Foundation +import PencilKit +import CoreData + +extension Note { + + // MARK: - Drawing + + var pkDrawing: PKDrawing { + get { + guard let data = drawing else { return PKDrawing() } + return (try? PKDrawing(data: data)) ?? PKDrawing() + } + set { + drawing = newValue.dataRepresentation() + } + } + + // MARK: - Title Extraction + + var title: String { + // Extract first line from text, or use timestamp as fallback + guard let noteText = text, !noteText.isEmpty else { + return "Note \(timestamp?.formatted(date: .abbreviated, time: .shortened) ?? "")" + } + + // Get first non-empty line + let lines = noteText.components(separatedBy: .newlines) + if let firstLine = lines.first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) { + return firstLine.trimmingCharacters(in: .whitespaces) + } + + return "Note \(timestamp?.formatted(date: .abbreviated, time: .shortened) ?? "")" + } + + // MARK: - Convenience Initializer + + static func create(in context: NSManagedObjectContext) -> Note { + let note = Note(context: context) + note.id = UUID() + note.timestamp = Date() + note.text = "" + return note + } +} diff --git a/WorterBuch/NoteEditorView.swift b/WorterBuch/NoteEditorView.swift new file mode 100644 index 0000000..e477c88 --- /dev/null +++ b/WorterBuch/NoteEditorView.swift @@ -0,0 +1,152 @@ +// +// NoteEditorView.swift +// WorterBuch +// +// Created by Oliver Hnát on 06.12.2025. +// + +import SwiftUI +import PencilKit + +struct NoteEditorView: View { + @Environment(\.dismiss) private var dismiss + @Binding var drawing: PKDrawing + @Binding var text: String + + @State private var isRecognizing = false + @State private var showTranscription = false + @State private var viewAppeared = false + + var body: some View { + NavigationView { + VStack(spacing: 0) { + // Handwriting canvas - scrollable + if viewAppeared { + ScrollableCanvasView( + drawing: $drawing, + onDrawingChanged: { newDrawing in + recognizeHandwriting(newDrawing) + }, + isEditable: true + ) + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .background(Color(.systemGray6)) + .cornerRadius(12) + .padding() + } else { + // Placeholder while loading + Rectangle() + .fill(Color(.systemGray6)) + .frame(maxHeight: .infinity) + .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 - collapsible + if showTranscription { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Transcribed Text") + .font(.headline) + .foregroundColor(.secondary) + + if isRecognizing { + ProgressView() + .scaleEffect(0.8) + } + + Spacer() + + Button(action: { + withAnimation { + showTranscription = false + } + }) { + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + } + } + + TextEditor(text: $text) + .font(.body) + .frame(minHeight: 150) + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .padding() + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .navigationTitle(text.isEmpty ? "New Note" : extractTitle(from: text)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Clear") { + drawing = PKDrawing() + text = "" + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 16) { + Button(action: { + withAnimation { + showTranscription.toggle() + } + }) { + Image(systemName: showTranscription ? "text.bubble.fill" : "text.bubble") + } + + Button("Done") { + dismiss() + } + .fontWeight(.semibold) + } + } + } + } + .navigationViewStyle(.stack) + } + + private func extractTitle(from text: String) -> String { + // Get first line or first 30 characters + let lines = text.components(separatedBy: .newlines) + if let firstLine = lines.first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) { + let trimmed = firstLine.trimmingCharacters(in: .whitespaces) + return trimmed.count > 30 ? String(trimmed.prefix(30)) + "..." : trimmed + } + return "New Note" + } + + private func recognizeHandwriting(_ drawing: PKDrawing) { + guard !drawing.bounds.isEmpty else { + return + } + + isRecognizing = true + Task { + if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) { + await MainActor.run { + text = recognizedText + isRecognizing = false + } + } else { + await MainActor.run { + isRecognizing = false + } + } + } + } +} diff --git a/WorterBuch/NotesListView.swift b/WorterBuch/NotesListView.swift new file mode 100644 index 0000000..d93082c --- /dev/null +++ b/WorterBuch/NotesListView.swift @@ -0,0 +1,385 @@ +// +// NotesListView.swift +// WorterBuch +// +// Created by Oliver Hnát on 06.12.2025. +// + +import SwiftUI +import PencilKit +import CoreData + +struct NotesListView: View { + @Environment(\.managedObjectContext) private var viewContext + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Note.timestamp, ascending: false)], + animation: .default + ) + private var notes: FetchedResults + + @State private var selectedNote: Note? + + var body: some View { + NavigationView { + // Left sidebar - list of notes + List(selection: $selectedNote) { + ForEach(notes, id: \.id) { note in + NoteRowView(note: note) + .tag(note) + .onTapGesture { + // Save current note before switching + saveContext() + // Small delay to ensure save completes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + selectedNote = note + } + } + } + .onDelete(perform: deleteNotes) + } + .listStyle(.sidebar) + .navigationTitle("Notes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: addNote) { + Image(systemName: "plus") + } + } + } + + // Right panel - note editor or placeholder + if let note = selectedNote { + NoteEditorContentView(note: note, onAddNote: addNote) + .id(note.id) // Force recreation when switching notes + .navigationBarHidden(true) + } else { + VStack(spacing: 20) { + HStack { + Spacer() + Button(action: addNote) { + Image(systemName: "plus") + .font(.title2) + } + .padding() + } + + Spacer() + + Image(systemName: "note.text") + .font(.system(size: 60)) + .foregroundColor(.secondary) + Text("Select a note or create a new one") + .font(.title3) + .foregroundColor(.secondary) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .navigationBarHidden(true) + } + } + .onAppear { + // Select first note if none selected + if selectedNote == nil && !notes.isEmpty { + selectedNote = notes.first + } + } + } + + private func addNote() { + // Save current note first + saveContext() + + // Create new note on main thread + DispatchQueue.main.async { + withAnimation { + let newNote = Note.create(in: viewContext) + do { + try viewContext.save() + selectedNote = newNote + } catch { + print("Error creating note: \(error)") + viewContext.rollback() + } + } + } + } + + private func deleteNotes(offsets: IndexSet) { + withAnimation { + let notesToDelete = offsets.map { notes[$0] } + + // Clear selection if we're deleting the selected note + if let selected = selectedNote, notesToDelete.contains(where: { $0.id == selected.id }) { + selectedNote = nil + } + + // Delete the notes + notesToDelete.forEach { note in + viewContext.delete(note) + } + + saveContext() + + // After deletion, if no notes are left, ensure selection is nil + // Otherwise, select the first remaining note if nothing is selected + DispatchQueue.main.async { + if notes.isEmpty { + selectedNote = nil + } else if selectedNote == nil && !notes.isEmpty { + selectedNote = notes.first + } + } + } + } + + private func saveContext() { + do { + try viewContext.save() + } catch { + let nsError = error as NSError + print("Error saving context: \(nsError), \(nsError.userInfo)") + } + } +} + +struct NoteRowView: View { + @ObservedObject var note: Note + + private var timeAgo: String { + guard let timestamp = note.timestamp else { return "" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: timestamp, relativeTo: Date()) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(note.title) + .font(.body) + .lineLimit(2) + + if note.timestamp != nil { + Text(timeAgo) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 4) + } +} + +struct NoteEditorContentView: View { + @ObservedObject var note: Note + @Environment(\.managedObjectContext) private var viewContext + let onAddNote: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Title/header area + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(note.title) + .font(.headline) + .fontWeight(.semibold) + + if let timestamp = note.timestamp { + Text(timestamp, style: .date) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + Spacer() + + Button(action: onAddNote) { + Image(systemName: "plus") + .font(.body) + } + .padding(.trailing, 8) + + Button(action: { + note.pkDrawing = PKDrawing() + note.text = "" + saveContext() + }) { + Label("Clear", systemImage: "trash") + .foregroundColor(.red) + .font(.caption) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemBackground)) + + Divider() + + // Note editor content + NoteEditorContentOnly( + drawing: bindingForDrawing(), + text: bindingForText() + ) + } + .background(Color(.systemGroupedBackground)) + } + + private func bindingForDrawing() -> Binding { + Binding( + get: { note.pkDrawing }, + set: { newValue in + note.pkDrawing = newValue + saveContext() + } + ) + } + + private func bindingForText() -> Binding { + Binding( + get: { note.text ?? "" }, + set: { newValue in + note.text = newValue + saveContext() + } + ) + } + + private func saveContext() { + do { + try viewContext.save() + } catch { + let nsError = error as NSError + print("Error saving context: \(nsError), \(nsError.userInfo)") + } + } +} + +struct NoteEditorContentOnly: View { + @Binding var drawing: PKDrawing + @Binding var text: String + + @State private var isRecognizing = false + @State private var showTranscription = false + @State private var viewAppeared = false + @State private var recognitionWorkItem: DispatchWorkItem? + + var body: some View { + VStack(spacing: 0) { + // Handwriting canvas - scrollable + if viewAppeared { + ScrollableCanvasView( + drawing: $drawing, + onDrawingChanged: { newDrawing in + recognizeHandwriting(newDrawing) + }, + isEditable: true + ) + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .background(Color(.systemGray6)) + .cornerRadius(12) + .padding() + } else { + // Placeholder while loading + Rectangle() + .fill(Color(.systemGray6)) + .frame(maxHeight: .infinity) + .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 - collapsible + if showTranscription { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Transcribed Text") + .font(.headline) + .foregroundColor(.secondary) + + if isRecognizing { + ProgressView() + .scaleEffect(0.8) + } + + Spacer() + + Button(action: { + withAnimation { + showTranscription = false + } + }) { + Image(systemName: "chevron.down") + .foregroundColor(.secondary) + } + } + + TextEditor(text: $text) + .font(.body) + .frame(minHeight: 150) + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .padding() + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Toggle transcription button + if !showTranscription { + Button(action: { + withAnimation { + showTranscription = true + } + }) { + HStack { + Image(systemName: "text.bubble") + Text("Show Transcription") + } + .font(.subheadline) + .foregroundColor(.blue) + .padding(.vertical, 8) + } + .padding(.bottom) + } + } + } + + 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 800ms delay (adjust as needed) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8, execute: workItem) + } +} diff --git a/WorterBuch/Persistence.swift b/WorterBuch/Persistence.swift index a2b86c9..9f16b3d 100644 --- a/WorterBuch/Persistence.swift +++ b/WorterBuch/Persistence.swift @@ -14,6 +14,8 @@ struct PersistenceController { static let preview: PersistenceController = { let result = PersistenceController(inMemory: true) let viewContext = result.container.viewContext + + // Create sample vocabulary entries for i in 0..<5 { let entry = VocabularyEntry.create(in: viewContext) entry.timestamp = Date() @@ -22,6 +24,14 @@ struct PersistenceController { entry.germanExplanationText = "Dies ist eine Erklärung des deutschen Wortes" entry.englishTranslationText = "Example word \(i + 1)" } + + // Create sample notes + for i in 0..<3 { + let note = Note.create(in: viewContext) + note.timestamp = Date().addingTimeInterval(-Double(i * 3600)) // Stagger timestamps + note.text = "Sample Note \(i + 1)\nThis is the content of the note." + } + do { try viewContext.save() } catch { diff --git a/WorterBuch/ScrollableCanvasView.swift b/WorterBuch/ScrollableCanvasView.swift new file mode 100644 index 0000000..b885447 --- /dev/null +++ b/WorterBuch/ScrollableCanvasView.swift @@ -0,0 +1,126 @@ +// +// ScrollableCanvasView.swift +// WorterBuch +// +// Created by Oliver Hnát on 06.12.2025. +// + +import SwiftUI +import PencilKit + +struct ScrollableCanvasView: UIViewRepresentable { + @Binding var drawing: PKDrawing + var onDrawingChanged: ((PKDrawing) -> Void)? + var isEditable: Bool = true + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.isScrollEnabled = true + scrollView.bounces = true + scrollView.backgroundColor = .clear + + let canvasView = PKCanvasView() + canvasView.drawing = drawing + canvasView.delegate = context.coordinator + canvasView.drawingPolicy = .pencilOnly + canvasView.isOpaque = false + canvasView.backgroundColor = .clear + canvasView.isUserInteractionEnabled = isEditable + + // Allow finger touches to pass through for scrolling + canvasView.allowsFingerDrawing = false + + // Set initial canvas size - will be updated in updateUIView with proper bounds + let canvasWidth: CGFloat = 800 // Placeholder width + let canvasHeight: CGFloat = 3000 // Start with a large canvas + canvasView.frame = CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight) + + scrollView.addSubview(canvasView) + scrollView.contentSize = canvasView.frame.size + + // Allow scrolling beyond content + scrollView.alwaysBounceVertical = true + + // Store references in coordinator + context.coordinator.canvasView = canvasView + context.coordinator.scrollView = scrollView + + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + guard let canvasView = context.coordinator.canvasView else { return } + + if canvasView.drawing != drawing { + canvasView.drawing = drawing + } + 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 + let drawingBounds = drawing.bounds + let currentHeight = canvasView.frame.height + let requiredHeight: CGFloat + + if drawingBounds.isEmpty { + requiredHeight = 3000 + } else { + requiredHeight = max(drawingBounds.maxY + 500, 3000) // Add padding below + } + + // Update canvas size if needed + if requiredHeight > currentHeight || abs(canvasView.frame.width - canvasWidth) > 1 { + canvasView.frame = CGRect(x: 0, y: 0, width: canvasWidth, height: requiredHeight) + scrollView.contentSize = canvasView.frame.size + } + + // Set up tool picker when window becomes available + if isEditable && scrollView.window != nil && !context.coordinator.toolPickerSetup { + context.coordinator.setupToolPicker() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(drawing: $drawing, onDrawingChanged: onDrawingChanged) + } + + class Coordinator: NSObject, PKCanvasViewDelegate { + var drawing: Binding + var onDrawingChanged: ((PKDrawing) -> Void)? + var canvasView: PKCanvasView? + var scrollView: UIScrollView? + var toolPicker: PKToolPicker? + var toolPickerSetup = false + + init(drawing: Binding, onDrawingChanged: ((PKDrawing) -> Void)?) { + self.drawing = drawing + self.onDrawingChanged = onDrawingChanged + super.init() + // Create a dedicated tool picker instance + self.toolPicker = PKToolPicker() + } + + func setupToolPicker() { + guard let canvasView = canvasView, + let toolPicker = toolPicker, + canvasView.window != nil, + !toolPickerSetup else { + return + } + + toolPickerSetup = true + + // Set visible and add observer immediately + toolPicker.setVisible(true, forFirstResponder: canvasView) + toolPicker.addObserver(canvasView) + canvasView.becomeFirstResponder() + } + + func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { + drawing.wrappedValue = canvasView.drawing + onDrawingChanged?(canvasView.drawing) + } + } +} diff --git a/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents b/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents index 51796ed..8f6cc6d 100644 --- a/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents +++ b/WorterBuch/WorterBuch.xcdatamodeld/WorterBuch.xcdatamodel/contents @@ -10,7 +10,14 @@ + + + + + + + \ No newline at end of file