gitbay/Views/Issues/IssueView.swift

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

230 lines · 9160 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            RemovableChips(items: issue.labels ?? [], idPrefix: "issue-label",
 95                           disabled: model.working, color: { model.colors.color($0) },
 96                           remove: { label in Task { await model.removeLabel(label) } })
 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            RemovableChips(items: issue.assignees ?? [], idPrefix: "issue-assignee",
113                           disabled: model.working,
114                           remove: { user in Task { await model.removeAssignee(user) } })
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    private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View {
156        Section("Comments") {
157            ForEach(comments) { comment in
158                VStack(alignment: .leading, spacing: 4) {
159                    HStack {
160                        Text(comment.author)
161                            .font(.gbSans(.caption).weight(.semibold))
162                        Text(comment.createdAt, format: .relative(presentation: .named))
163                            .font(.gbSans(.caption))
164                            .foregroundStyle(.tertiary)
165                    }
166                    FormattedBodyView(text: comment.body, format: comment.bodyFormat)
167                        .font(.gbSans(.subheadline))
168                }
169                .padding(.vertical, 2)
170            }
171
172            HStack {
173                TextField("Comment", text: $commentText, axis: .vertical)
174                    .lineLimit(1...5)
175                    .accessibilityIdentifier("issue-comment-field")
176                Picker("Format", selection: $commentFormat) {
177                    Text("Markdown").tag("md")
178                    Text("Org").tag("org")
179                }
180                .pickerStyle(.segmented)
181                .labelsHidden()
182                .fixedSize()
183                .accessibilityIdentifier("issue-comment-format")
184                Button {
185                    let text = commentText
186                    commentText = ""
187                    Task { await model.comment(text, format: commentFormat) }
188                } label: {
189                    Image(systemName: "arrow.up.circle.fill")
190                }
191                .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
192                    || model.working)
193            }
194            if !commentText.isEmpty {
195                MarkupPreview(text: commentText, format: commentFormat,
196                              identifier: "issue-comment-preview")
197                    .font(.gbSans(.subheadline))
198            }
199        }
200    }
201
202    @ToolbarContentBuilder
203    private var toolbar: some ToolbarContent {
204        ToolbarItem(placement: .topBarTrailing) {
205            if let issue = model.state.value {
206                Menu {
207                    Button {
208                        draftTitle = issue.title
209                        draftBody = issue.body ?? ""
210                        draftFormat = issue.bodyFormat ?? "md"
211                        editing = true
212                    } label: {
213                        Label("Edit", systemImage: "pencil")
214                    }
215                    Button(issue.isOpen ? "Close" : "Reopen") {
216                        Task { issue.isOpen ? await model.close() : await model.reopen() }
217                    }
218                } label: {
219                    if model.working {
220                        ProgressView()
221                    } else {
222                        Image(systemName: "ellipsis.circle")
223                    }
224                }
225                .disabled(model.working)
226                .accessibilityIdentifier("issue-actions-menu")
227            }
228        }
229    }
230}