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