import SwiftUI /// The comment composer and the inline comment edit, one transactional sheet — presented from /// `CardDetailScreen`'s comments section, `CardEditScreen`'s model in miniature: drafts commit on /// the trailing button or not at all, Cancel guards a dirty draft with a discard confirmation, a /// failed write keeps the sheet and its draft and raises an alert, and dismissal happens only on /// the perform's own `.success`. No scenePhase or onDisappear commit anywhere — backgrounding the /// app leaves the draft waiting. /// /// ### Compose posts through the card's single draft /// /// `Post` is `saveCommentDraft` then `postComment` in **one `session.perform` bracket** — the /// Mac composer's own two primitives, back to back. That makes the on-disk draft load-bearing /// rather than optional: the composer seeds from it when one exists (it syncs — a comment started /// on the Mac is offered here to finish, and its attachments ride into the post untouched), /// because a post that ignored it would silently clobber that other device's words with these. /// Cancel touches nothing on disk: the phone's transactional model, so the typed text is the /// sheet's and the draft folder stays exactly as the last device left it. /// /// ### Edit seeds from the row that opened it /// /// The one screen in this stack that carries a value rather than IDs alone — deliberately: a /// comment lives outside the board snapshot, so there is nothing to re-read from on each body /// evaluation, and the Mac's inline edit session seeds from open-time bytes the same way. The /// save targets the comment's *id* on disk regardless, so a body that changed elsewhere mid-edit /// is overwritten by an explicit Save — the same last-writer-wins any two editors of one file /// have — and a comment deleted elsewhere refuses the write and alerts rather than recreating it. struct CommentEditorScreen: View { enum Mode { /// A new comment through the card's single draft. case compose /// One posted comment's body. case edit(Comment) } let boardRoot: URL let laneID: ItemID let cardID: ItemID let mode: Mode @Environment(BoardIndexStore.self) private var index @Environment(\.dismiss) private var dismiss @State private var bodyDraft = "" @State private var seededBody = "" @State private var draftAttachments: [String] = [] @State private var isConfirmingDiscard = false @State private var isWriting = false @State private var writeError: BoardSessionError? @FocusState private var isBodyFocused: Bool private var session: BoardSession { index.session(forBoardAt: boardRoot) } private var isEditing: Bool { if case .edit = mode { return true } return false } private var isDirty: Bool { bodyDraft != seededBody } /// Post validation, the emptied-draft rule read forwards: there is something to post exactly /// when there would be something to keep — text, or the draft's synced attachments. Edit has /// no such gate; an unchanged Save writes nothing (the writer's identical-bytes rule) and a /// deliberately emptied body is the user's to write. private var canCommit: Bool { switch mode { case .compose: !bodyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !draftAttachments.isEmpty case .edit: true } } var body: some View { NavigationStack { TextEditor(text: $bodyDraft) .focused($isBodyFocused) .padding(.horizontal, 12) .padding(.top, 8) .navigationTitle(isEditing ? "Edit Comment" : "New Comment") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { requestDismiss() } } ToolbarItem(placement: .confirmationAction) { Button(isEditing ? "Save" : "Post") { commit() } .disabled(!canCommit || isWriting) } } .task { await seed() } .confirmationDialog( "Discard Comment?", isPresented: $isConfirmingDiscard, titleVisibility: .visible ) { Button("Discard", role: .destructive) { dismiss() } Button("Cancel", role: .cancel) {} } .alert( isEditing ? "Couldn't Save" : "Couldn't Post", isPresented: Binding(get: { writeError != nil }, set: { if !$0 { writeError = nil } }), presenting: writeError ) { _ in Button("OK") { writeError = nil } } message: { error in Text(error.description) } } // A sheet — unlike the edit screen's fullScreenCover — has an interactive swipe-dismiss, // which would be a silent Cancel past the dirty guard. Disabling it while dirty routes // every exit through the two buttons; a clean sheet still swipes away freely. .interactiveDismissDisabled(isDirty) } /// Seeds once, at presentation. Edit seeds synchronously from the row's value; compose reads /// the card's single draft off the main actor and applies it only if the user has not already /// started typing into the empty editor — the seed must never eat keystrokes. private func seed() async { switch mode { case let .edit(comment): bodyDraft = comment.body seededBody = comment.body case .compose: guard let draft = await session.loadCommentDraft(laneID: laneID, cardID: cardID) else { break } draftAttachments = draft.attachments if bodyDraft.isEmpty { bodyDraft = draft.body seededBody = draft.body } } isBodyFocused = true } private func requestDismiss() { if isDirty { isConfirmingDiscard = true } else { dismiss() } } /// One `session.perform` bracket either way; `perform` awaits its own reload, which bumps /// `snapshotGeneration`, which is what makes the thread under the dismissing sheet already /// show the new comment the instant the animation ends. private func commit() { let body = bodyDraft let laneID = self.laneID let cardID = self.cardID let cardTitle = session.snapshot?.lanes .first { $0.id == laneID }?.cards .first { $0.id == cardID }?.title.value isWriting = true Task { let outcome: Result switch mode { case .compose: outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in let folder = BoardSession.cardFolder(laneID: laneID, cardID: cardID, inRoot: root) try BoardWriter.saveCommentDraft(inCard: folder, body: body, cardTitle: cardTitle) _ = try BoardWriter.postComment(inCard: folder, cardTitle: cardTitle) } case let .edit(comment): let commentID = comment.id outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in let folder = BoardSession.cardFolder(laneID: laneID, cardID: cardID, inRoot: root) try BoardWriter.editComment( at: CommentThread.commentFolder(commentID, inCard: folder), body: body, cardTitle: cardTitle ) } } isWriting = false switch outcome { case .success: dismiss() case let .failure(error): writeError = error } } } }