gitbay/Views/Issues/IssueView.swift

v1.13.0
gitbay-ios/gitbay/Views/Issues/IssueView.swift history · blame · raw

249 lines · 10277 bytes

  1import SwiftUI
  2
  3struct IssueView: View {
  4
  5    @State private var model: IssueDetailViewModel
  6    @State private var commentText = ""
  7    @State private var commentFormat = "md"
  8    @State private var editingLabel = ""
  9    @State private var editingAssignee = ""
 10    @State private var editing = false
 11    @State private var draftTitle = ""
 12    @State private var draftBody = ""
 13    @State private var draftFormat = "md"
 14
 15    init(client: GitbayClient, repo: String, number: Int64) {
 16        _model = State(initialValue: IssueDetailViewModel(
 17            client: client, repoPath: repo, number: number
 18        ))
 19    }
 20
 21    var body: some View {
 22        List {
 23            if let issue = model.state.value {
 24                header(issue)
 25
 26                if let error = model.actionError {
 27                    Section {
 28                        GBNotice(error, .gbWarn)
 29                    }
 30                }
 31
 32                Section {
 33                    if let body = issue.body, !body.isEmpty {
 34                        FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath)
 35                            .padding(.vertical, 4)
 36                    }
 37                    ReactionBar(reactions: issue.reactions ?? [], disabled: model.working,
 38                                idPrefix: "issue-reaction") { name in
 39                        Task { await model.react(name, comment: nil) }
 40                    }
 41                }
 42
 43                triageSection(issue)
 44                commentsSection(issue.comments ?? [])
 45            }
 46        }
 47        .overlay { LoadStateOverlay(state: model.state) }
 48        .referenceNavigation()
 49        .scrollDismissesKeyboard(.interactively)
 50        .navigationTitle("#\(model.number)")
 51        .navigationBarTitleDisplayMode(.inline)
 52        .toolbar { toolbar }
 53        .sheet(isPresented: $editing) {
 54            ComposeSheet(
 55                heading: "Edit #\(model.number)",
 56                submitLabel: "Save",
 57                working: model.working,
 58                errorMessage: model.actionError,
 59                title: $draftTitle,
 60                bodyText: $draftBody,
 61                format: $draftFormat
 62            ) {
 63                Task {
 64                    await model.edit(title: draftTitle, body: draftBody, format: draftFormat)
 65                    if model.actionError == nil { editing = false }
 66                }
 67            }
 68        }
 69        .task { await model.load() }
 70        .refreshable { await model.load() }
 71    }
 72
 73    @ViewBuilder
 74    private func header(_ issue: IssueDetail) -> some View {
 75        Section {
 76            VStack(alignment: .leading, spacing: 6) {
 77                Text(issue.title)
 78                    .font(.gbSans(.headline))
 79                HStack(spacing: 6) {
 80                    Label(stateLabel(issue.state), systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
 81                        .font(.gbSans(.caption).weight(.medium))
 82                        .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
 83                    Text("by \(issue.author)")
 84                    Text(issue.createdAt, format: .relative(presentation: .named))
 85                        .foregroundStyle(.tertiary)
 86                    if let milestone = issue.milestone {
 87                        Label(milestone, systemImage: "flag")
 88                    }
 89                }
 90                .font(.gbSans(.caption))
 91                .foregroundStyle(.secondary)
 92            }
 93            .padding(.vertical, 2)
 94        }
 95    }
 96
 97    @ViewBuilder
 98    private func triageSection(_ issue: IssueDetail) -> some View {
 99        Section("Labels") {
100            RemovableChips(items: issue.labels ?? [], idPrefix: "issue-label",
101                           disabled: model.working, color: { model.colors.color($0) },
102                           remove: { label in Task { await model.removeLabel(label) } })
103            HStack {
104                TextField("Add label", text: $editingLabel)
105                    .autocorrectionDisabled()
106                    .textInputAutocapitalization(.never)
107                Button {
108                    let label = editingLabel.trimmingCharacters(in: .whitespaces)
109                    editingLabel = ""
110                    Task { await model.addLabel(label) }
111                } label: {
112                    Image(systemName: "plus.circle.fill")
113                }
114                .accessibilityLabel("Add label")
115                .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
116            }
117        }
118        Section("Assignees") {
119            RemovableChips(items: issue.assignees ?? [], idPrefix: "issue-assignee",
120                           disabled: model.working,
121                           remove: { user in Task { await model.removeAssignee(user) } })
122            HStack {
123                TextField("Assign user", text: $editingAssignee)
124                    .autocorrectionDisabled()
125                    .textInputAutocapitalization(.never)
126                Button {
127                    let user = editingAssignee.trimmingCharacters(in: .whitespaces)
128                    editingAssignee = ""
129                    Task { await model.addAssignee(user) }
130                } label: {
131                    Image(systemName: "plus.circle.fill")
132                }
133                .accessibilityLabel("Add assignee")
134                .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
135            }
136        }
137        Section("Milestone") {
138            Menu {
139                Button("None") {
140                    Task { await model.setMilestone(nil) }
141                }
142                ForEach(model.availableMilestones ?? []) { milestone in
143                    Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
144                        Task { await model.setMilestone(milestone.title) }
145                    }
146                }
147            } label: {
148                HStack {
149                    Label(issue.milestone ?? "None", systemImage: "flag")
150                        .font(.gbSans(.subheadline))
151                    Spacer()
152                    Image(systemName: "chevron.up.chevron.down")
153                        .font(.gbSans(.caption2))
154                        .foregroundStyle(.secondary)
155                }
156            }
157            .disabled(model.working)
158            .task { await model.loadMilestones() }
159            .accessibilityIdentifier("milestone-menu")
160        }
161    }
162
163    private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View {
164        Section("Comments") {
165            ForEach(comments) { comment in
166                VStack(alignment: .leading, spacing: 4) {
167                    HStack {
168                        Text(comment.author)
169                            .font(.gbSans(.caption).weight(.semibold))
170                        Text(comment.createdAt, format: .relative(presentation: .named))
171                            .font(.gbSans(.caption))
172                            .foregroundStyle(.tertiary)
173                    }
174                    FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
175                        .font(.gbSans(.subheadline))
176                    if let id = comment.commentID, !comment.isSystem {
177                        ReactionBar(reactions: comment.reactions ?? [], disabled: model.working,
178                                    idPrefix: "issue-comment-\(id)-reaction") { name in
179                            Task { await model.react(name, comment: id) }
180                        }
181                    }
182                }
183                .padding(.vertical, 2)
184            }
185
186            HStack {
187                TextField("Comment", text: $commentText, axis: .vertical)
188                    .lineLimit(1...5)
189                    .accessibilityIdentifier("issue-comment-field")
190                Picker("Format", selection: $commentFormat) {
191                    Text("Markdown").tag("md")
192                    Text("Org").tag("org")
193                }
194                .pickerStyle(.segmented)
195                .labelsHidden()
196                .fixedSize()
197                .accessibilityIdentifier("issue-comment-format")
198                Button {
199                    let text = commentText
200                    commentText = ""
201                    Task { await model.comment(text, format: commentFormat) }
202                } label: {
203                    Image(systemName: "arrow.up.circle.fill")
204                        .font(.title)
205                        .frame(minWidth: 44, minHeight: 44)
206                        .contentShape(Rectangle())
207                }
208                .accessibilityLabel("Send comment")
209                .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
210                    || model.working)
211            }
212            if !commentText.isEmpty {
213                MarkupPreview(text: commentText, format: commentFormat,
214                              identifier: "issue-comment-preview")
215                    .font(.gbSans(.subheadline))
216            }
217        }
218    }
219
220    @ToolbarContentBuilder
221    private var toolbar: some ToolbarContent {
222        ToolbarItem(placement: .topBarTrailing) {
223            if let issue = model.state.value {
224                Menu {
225                    Button {
226                        draftTitle = issue.title
227                        draftBody = issue.body ?? ""
228                        draftFormat = issue.bodyFormat ?? "md"
229                        editing = true
230                    } label: {
231                        Label("Edit", systemImage: "pencil")
232                    }
233                    Button(issue.isOpen ? "Close" : "Reopen") {
234                        Task { issue.isOpen ? await model.close() : await model.reopen() }
235                    }
236                } label: {
237                    if model.working {
238                        ProgressView()
239                    } else {
240                        Image(systemName: "ellipsis.circle")
241                    }
242                }
243                .accessibilityLabel("Actions")
244                .disabled(model.working)
245                .accessibilityIdentifier("issue-actions-menu")
246            }
247        }
248    }
249}