Files
WorterBuch/WorterBuch/NotesListView.swift

435 lines
14 KiB
Swift

//
// 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?
@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 {
NavigationView {
// Left sidebar - list of notes
List(selection: $selectedNote) {
ForEach(filteredNotes, 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")
}
}
}
.searchable(text: $searchText, prompt: "Search notes")
// 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) {
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
}
// Small delay to ensure UI updates before deletion
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
withAnimation {
// 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(1)
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
@State private var showTranscription = false
@State private var forceTranscriptionTrigger = 0
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: {
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) {
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(),
showTranscription: $showTranscription,
forceTranscriptionTrigger: forceTranscriptionTrigger
)
}
.background(Color(.systemGroupedBackground))
}
private func bindingForDrawing() -> Binding<PKDrawing> {
Binding(
get: { note.pkDrawing },
set: { newValue in
// Guard against modifying deleted objects
guard note.managedObjectContext != nil else { return }
note.pkDrawing = newValue
saveContext()
}
)
}
private func bindingForText() -> Binding<String> {
Binding(
get: { note.text ?? "" },
set: { newValue in
// Guard against modifying deleted objects
guard note.managedObjectContext != nil else { return }
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
@Binding var showTranscription: Bool
let forceTranscriptionTrigger: Int
@State private var isRecognizing = 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))
}
}
.onChange(of: forceTranscriptionTrigger) { _ in
forceRecognizeHandwriting()
}
}
private func forceRecognizeHandwriting() {
guard !drawing.bounds.isEmpty else {
return
}
// Cancel any pending recognition
recognitionWorkItem?.cancel()
// Perform recognition immediately without debounce
Task { @MainActor in
isRecognizing = true
showTranscription = true // Auto-show transcription when forcing
if let recognizedText = await HandwritingRecognizer.recognizeTextAsync(from: drawing) {
text = recognizedText
isRecognizing = false
} else {
isRecognizing = false
}
}
}
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
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
}
}