gitbay/Views/Issues/IssueView.swift

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

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