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