gitbay/Views/Issues/IssueView.swift

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

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}