a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Views/Issues/IssueView.swift

239 lines · 9159 bytes

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