Schnellnotiz und Editorfenster.
This commit is contained in:
@@ -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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user