gitbay/Views/Issues/IssueView.swift

5411753023a5e2958f17b523903abce380941107
gitbay-ios/gitbay/Views/Issues/IssueView.swift history · blame · raw

240 lines · 9070 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                        Label(error, systemImage: "hand.raised")
 27                            .foregroundStyle(.orange)
 28                            .font(.subheadline)
 29                    }
 30                }
 31
 32                if let body = issue.body, !body.isEmpty {
 33                    Section {
 34                        MarkdownView(markdown: body)
 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            ) {
 56                Task {
 57                    await model.edit(title: draftTitle, body: draftBody)
 58                    if model.actionError == nil { editing = false }
 59                }
 60            }
 61        }
 62        .task { await model.load() }
 63        .refreshable { await model.load() }
 64    }
 65
 66    @ViewBuilder
 67    private func header(_ issue: IssueDetail) -> some View {
 68        Section {
 69            VStack(alignment: .leading, spacing: 6) {
 70                Text(issue.title)
 71                    .font(.headline)
 72                HStack(spacing: 6) {
 73                    Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
 74                        .font(.caption.weight(.medium))
 75                        .foregroundStyle(issue.isOpen ? .green : .purple)
 76                    Text("by \(issue.author)")
 77                    Text(issue.createdAt, format: .relative(presentation: .named))
 78                        .foregroundStyle(.tertiary)
 79                    if let milestone = issue.milestone {
 80                        Label(milestone, systemImage: "flag")
 81                    }
 82                }
 83                .font(.caption)
 84                .foregroundStyle(.secondary)
 85            }
 86            .padding(.vertical, 2)
 87        }
 88    }
 89
 90    @ViewBuilder
 91    private func triageSection(_ issue: IssueDetail) -> some View {
 92        Section("Labels") {
 93            labelFlow(issue.labels ?? [], remove: { label in
 94                Task { await model.removeLabel(label) }
 95            })
 96            HStack {
 97                TextField("Add label", text: $editingLabel)
 98                    .autocorrectionDisabled()
 99                    .textInputAutocapitalization(.never)
100                Button {
101                    let label = editingLabel.trimmingCharacters(in: .whitespaces)
102                    editingLabel = ""
103                    Task { await model.addLabel(label) }
104                } label: {
105                    Image(systemName: "plus.circle.fill")
106                }
107                .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
108            }
109        }
110        Section("Assignees") {
111            labelFlow(issue.assignees ?? [], remove: { user in
112                Task { await model.removeAssignee(user) }
113            })
114            HStack {
115                TextField("Assign user", text: $editingAssignee)
116                    .autocorrectionDisabled()
117                    .textInputAutocapitalization(.never)
118                Button {
119                    let user = editingAssignee.trimmingCharacters(in: .whitespaces)
120                    editingAssignee = ""
121                    Task { await model.addAssignee(user) }
122                } label: {
123                    Image(systemName: "plus.circle.fill")
124                }
125                .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
126            }
127        }
128        Section("Milestone") {
129            Menu {
130                Button("None") {
131                    Task { await model.setMilestone(nil) }
132                }
133                ForEach(model.availableMilestones ?? []) { milestone in
134                    Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") {
135                        Task { await model.setMilestone(milestone.title) }
136                    }
137                }
138            } label: {
139                HStack {
140                    Label(issue.milestone ?? "None", systemImage: "flag")
141                        .font(.subheadline)
142                    Spacer()
143                    Image(systemName: "chevron.up.chevron.down")
144                        .font(.caption2)
145                        .foregroundStyle(.secondary)
146                }
147            }
148            .disabled(model.working)
149            .task { await model.loadMilestones() }
150            .accessibilityIdentifier("milestone-menu")
151        }
152    }
153
154    @ViewBuilder
155    private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View {
156        if !items.isEmpty {
157            ScrollView(.horizontal, showsIndicators: false) {
158                HStack(spacing: 6) {
159                    ForEach(items, id: \.self) { item in
160                        HStack(spacing: 3) {
161                            Text(item)
162                            Button {
163                                remove(item)
164                            } label: {
165                                Image(systemName: "xmark.circle.fill")
166                                    .foregroundStyle(.tertiary)
167                            }
168                            .disabled(model.working)
169                        }
170                        .font(.caption)
171                        .padding(.horizontal, 8)
172                        .padding(.vertical, 3)
173                        .background(.quaternary, in: Capsule())
174                    }
175                }
176            }
177        }
178    }
179
180    private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View {
181        Section("Comments") {
182            ForEach(comments) { comment in
183                VStack(alignment: .leading, spacing: 4) {
184                    HStack {
185                        Text(comment.author)
186                            .font(.caption.weight(.semibold))
187                        Text(comment.createdAt, format: .relative(presentation: .named))
188                            .font(.caption)
189                            .foregroundStyle(.tertiary)
190                    }
191                    MarkdownView(markdown: comment.body)
192                        .font(.subheadline)
193                }
194                .padding(.vertical, 2)
195            }
196
197            HStack {
198                TextField("Comment", text: $commentText, axis: .vertical)
199                    .lineLimit(1...5)
200                Button {
201                    let text = commentText
202                    commentText = ""
203                    Task { await model.comment(text) }
204                } label: {
205                    Image(systemName: "arrow.up.circle.fill")
206                }
207                .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
208                    || model.working)
209            }
210        }
211    }
212
213    @ToolbarContentBuilder
214    private var toolbar: some ToolbarContent {
215        ToolbarItem(placement: .topBarTrailing) {
216            if let issue = model.state.value {
217                Menu {
218                    Button {
219                        draftTitle = issue.title
220                        draftBody = issue.body ?? ""
221                        editing = true
222                    } label: {
223                        Label("Edit", systemImage: "pencil")
224                    }
225                    Button(issue.isOpen ? "Close" : "Reopen") {
226                        Task { issue.isOpen ? await model.close() : await model.reopen() }
227                    }
228                } label: {
229                    if model.working {
230                        ProgressView()
231                    } else {
232                        Image(systemName: "ellipsis.circle")
233                    }
234                }
235                .disabled(model.working)
236                .accessibilityIdentifier("issue-actions-menu")
237            }
238        }
239    }
240}