gitbay/Views/Issues/IssueView.swift

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

224 lines · 8852 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                Picker("Format", selection: $commentFormat) {
176                    Text("Markdown").tag("md")
177                    Text("Org").tag("org")
178                }
179                .pickerStyle(.segmented)
180                .labelsHidden()
181                .fixedSize()
182                .accessibilityIdentifier("issue-comment-format")
183                Button {
184                    let text = commentText
185                    commentText = ""
186                    Task { await model.comment(text, format: commentFormat) }
187                } label: {
188                    Image(systemName: "arrow.up.circle.fill")
189                }
190                .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
191                    || model.working)
192            }
193        }
194    }
195
196    @ToolbarContentBuilder
197    private var toolbar: some ToolbarContent {
198        ToolbarItem(placement: .topBarTrailing) {
199            if let issue = model.state.value {
200                Menu {
201                    Button {
202                        draftTitle = issue.title
203                        draftBody = issue.body ?? ""
204                        draftFormat = issue.bodyFormat ?? "md"
205                        editing = true
206                    } label: {
207                        Label("Edit", systemImage: "pencil")
208                    }
209                    Button(issue.isOpen ? "Close" : "Reopen") {
210                        Task { issue.isOpen ? await model.close() : await model.reopen() }
211                    }
212                } label: {
213                    if model.working {
214                        ProgressView()
215                    } else {
216                        Image(systemName: "ellipsis.circle")
217                    }
218                }
219                .disabled(model.working)
220                .accessibilityIdentifier("issue-actions-menu")
221            }
222        }
223    }
224}