435 lines
14 KiB
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)
|
|
}
|
|
}
|