feat(notes): add notes feature

This commit is contained in:
2025-12-06 15:02:45 +01:00
parent 74e814c4b8
commit eb5a5ddd6e
7 changed files with 743 additions and 1 deletions

View File

@@ -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")
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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<Note>
@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<PKDrawing> {
Binding(
get: { note.pkDrawing },
set: { newValue in
note.pkDrawing = newValue
saveContext()
}
)
}
private func bindingForText() -> Binding<String> {
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)
}
}

View File

@@ -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 {

View File

@@ -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<PKDrawing>
var onDrawingChanged: ((PKDrawing) -> Void)?
var canvasView: PKCanvasView?
var scrollView: UIScrollView?
var toolPicker: PKToolPicker?
var toolPickerSetup = false
init(drawing: Binding<PKDrawing>, 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)
}
}
}

View File

@@ -10,7 +10,14 @@
<attribute name="englishTranslationDrawing" optional="YES" attributeType="Binary"/>
<attribute name="englishTranslationText" optional="YES" attributeType="String"/>
</entity>
<entity name="Note" representedClassName="Note" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="drawing" optional="YES" attributeType="Binary"/>
<attribute name="text" optional="YES" attributeType="String"/>
</entity>
<elements>
<element name="VocabularyEntry" positionX="-63" positionY="-18" width="128" height="178"/>
<element name="Note" positionX="-63" positionY="207" width="128" height="89"/>
</elements>
</model>