gitbay/Views/Issues/IssueView.swift

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

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