diff --git a/bookstax/Views/QuickNote/QuickNoteView.swift b/bookstax/Views/QuickNote/QuickNoteView.swift new file mode 100644 index 0000000..e299ca5 --- /dev/null +++ b/bookstax/Views/QuickNote/QuickNoteView.swift @@ -0,0 +1,262 @@ +import SwiftUI +import SwiftData + +struct QuickNoteView: View { + @Environment(\.modelContext) private var modelContext + @Environment(ConnectivityMonitor.self) private var connectivity + + // Form fields + @State private var title = "" + @State private var content = "" + @State private var tagsRaw = "" + + // Location selection + @State private var shelves: [ShelfDTO] = [] + @State private var books: [BookDTO] = [] + @State private var selectedShelf: ShelfDTO? = nil + @State private var selectedBook: BookDTO? = nil + @State private var isLoadingShelves = false + @State private var isLoadingBooks = false + + // Save state + @State private var isSaving = false + @State private var savedMessage: String? = nil + @State private var error: String? = nil + + // Pending notes + @Query(sort: \PendingNote.createdAt) private var pendingNotes: [PendingNote] + @State private var isUploadingPending = false + + var body: some View { + NavigationStack { + Form { + // Note content + Section(L("quicknote.field.title")) { + TextField(L("quicknote.field.title"), text: $title) + } + + Section(L("quicknote.field.content")) { + TextEditor(text: $content) + .frame(minHeight: 120) + .font(.system(.body, design: .monospaced)) + } + + Section(L("quicknote.field.tags")) { + TextField(L("quicknote.field.tags"), text: $tagsRaw) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + // Location: shelf → book + Section(L("quicknote.section.location")) { + if isLoadingShelves { + HStack { + ProgressView().controlSize(.small) + Text(L("quicknote.shelf.loading")) + .foregroundStyle(.secondary) + .padding(.leading, 8) + } + } else { + Picker(L("create.shelf.title"), selection: $selectedShelf) { + Text(L("quicknote.shelf.none")).tag(ShelfDTO?.none) + ForEach(shelves) { shelf in + Text(shelf.name).tag(ShelfDTO?.some(shelf)) + } + } + .onChange(of: selectedShelf) { _, shelf in + selectedBook = nil + if let shelf { + Task { await loadBooks(for: shelf) } + } else { + Task { await loadAllBooks() } + } + } + } + + if isLoadingBooks { + HStack { + ProgressView().controlSize(.small) + Text(L("quicknote.book.loading")) + .foregroundStyle(.secondary) + .padding(.leading, 8) + } + } else { + Picker(L("create.book.title"), selection: $selectedBook) { + Text(L("quicknote.book.none")).tag(BookDTO?.none) + ForEach(books) { book in + Text(book.name).tag(BookDTO?.some(book)) + } + } + } + } + + // Feedback + if let msg = savedMessage { + Section { + HStack { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Text(msg).foregroundStyle(.secondary).font(.footnote) + } + } + } + if let err = error { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red) + Text(err).foregroundStyle(.red).font(.footnote) + } + } + } + + // Pending notes section + if !pendingNotes.isEmpty { + Section { + ForEach(pendingNotes) { note in + VStack(alignment: .leading, spacing: 2) { + Text(note.title).font(.body) + Text(note.bookName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onDelete(perform: deletePending) + + Button { + Task { await uploadPending() } + } label: { + if isUploadingPending { + HStack { + ProgressView().controlSize(.small) + Text(L("quicknote.pending.uploading")) + .foregroundStyle(.secondary) + .padding(.leading, 6) + } + } else { + Label(L("quicknote.pending.upload"), systemImage: "arrow.up.circle") + } + } + .disabled(isUploadingPending || !connectivity.isConnected) + } header: { + Text(L("quicknote.pending.title")) + } + } + } + .navigationTitle(L("quicknote.title")) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + if isSaving { + ProgressView().controlSize(.small) + } else { + Button(L("quicknote.save")) { + Task { await save() } + } + .disabled(title.isEmpty || selectedBook == nil) + } + } + } + .task { + await loadShelves() + } + } + } + + // MARK: - Load shelves / books + + private func loadShelves() async { + isLoadingShelves = true + shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? [] + isLoadingShelves = false + await loadAllBooks() + } + + private func loadAllBooks() async { + isLoadingBooks = true + books = (try? await BookStackAPI.shared.fetchBooks()) ?? [] + isLoadingBooks = false + } + + private func loadBooks(for shelf: ShelfDTO) async { + isLoadingBooks = true + books = (try? await BookStackAPI.shared.fetchShelf(id: shelf.id))?.books ?? [] + isLoadingBooks = false + } + + // MARK: - Save + + private func save() async { + guard let book = selectedBook else { + error = L("quicknote.error.nobook") + return + } + error = nil + savedMessage = nil + isSaving = true + + let tagDTOs = parsedTags() + + if connectivity.isConnected { + do { + let page = try await BookStackAPI.shared.createPage( + bookId: book.id, + name: title, + markdown: content, + tags: tagDTOs + ) + AppLog(.info, "Quick note '\(title)' created as page \(page.id)", category: "QuickNote") + savedMessage = L("quicknote.saved.online") + resetForm() + } catch { + self.error = error.localizedDescription + } + } else { + let tagsString = tagDTOs.map { "\($0.name):\($0.value)" }.joined(separator: ",") + let pending = PendingNote( + title: title, + markdown: content, + tags: tagsString, + bookId: book.id, + bookName: book.name + ) + modelContext.insert(pending) + try? modelContext.save() + savedMessage = L("quicknote.saved.offline") + resetForm() + } + isSaving = false + } + + // MARK: - Pending + + private func uploadPending() async { + isUploadingPending = true + await SyncService.shared.flushPendingNotes(context: modelContext) + isUploadingPending = false + } + + private func deletePending(at offsets: IndexSet) { + for i in offsets { + modelContext.delete(pendingNotes[i]) + } + try? modelContext.save() + } + + // MARK: - Helpers + + private func parsedTags() -> [TagDTO] { + tagsRaw.split(separator: ",").compactMap { token -> TagDTO? in + let parts = token.trimmingCharacters(in: .whitespaces) + .split(separator: ":", maxSplits: 1) + .map(String.init) + guard parts.count == 2 else { return nil } + return TagDTO(name: parts[0].trimmingCharacters(in: .whitespaces), + value: parts[1].trimmingCharacters(in: .whitespaces), + order: 0) + } + } + + private func resetForm() { + title = "" + content = "" + tagsRaw = "" + } +}