Forji/Forji/Forji/Views/IssueDetailView.swift
2026-03-11 20:27:38 +01:00

302 lines
12 KiB
Swift

import ForgejoKit
import SwiftUI
struct IssueDetailView: View {
let repository: Repository
let issueNumber: Int
@State private var authService: AuthenticationService
@State private var issue: Issue?
@State private var comments: [IssueComment] = []
@State private var isLoading = true
@State private var errorMessage: String?
@State private var showError = false
@State private var showEditSheet = false
@State private var isTogglingState = false
@State private var showCommentSheet = false
@State private var showActionsExpanded = false
private let issueService: IssueService?
init(repository: Repository, issueNumber: Int, authService: AuthenticationService) {
self.repository = repository
self.issueNumber = issueNumber
self.authService = authService
issueService = authService.client.map { IssueService(client: $0) }
}
private var owner: String {
repository.owner
}
private var repo: String {
repository.repoName
}
private var hasPushPermission: Bool {
repository.permissions?.push == true || repository.permissions?.admin == true
}
private var isAuthor: Bool {
guard let currentUser = authService.currentUser?.login else { return false }
return issue?.user.login == currentUser
}
private var canEditOrClose: Bool {
hasPushPermission || isAuthor
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading, issue == nil {
ProgressView()
} else if let issue {
List {
// Header
Section {
VStack(alignment: .leading, spacing: 10) {
Text(issue.title)
.font(.title3)
.fontWeight(.bold)
.accessibilityIdentifier("issue-detail-title")
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(
systemName: issue.stateValue == .open
? "circle.circle" : "checkmark.circle.fill",
)
Text(issue.stateValue == .open ? "Open" : "Closed")
}
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.foregroundStyle(.white)
.glassEffect(.regular.tint(issue.stateValue == .open ? .green : .purple))
Text("#\(issue.number)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text("\(issue.user.login) opened \(formatRelativeDate(issue.createdAt))")
.font(.subheadline)
.foregroundStyle(.secondary)
if !issue.labels.isEmpty {
FlowLayout(spacing: 4) {
ForEach(issue.labels) { label in
IssueLabelView(label: label)
}
}
}
}
.padding(.vertical, 4)
}
.listRowBackground(
(issue.stateValue == .open ? Color.green : Color.purple).opacity(0.08),
)
if let milestone = issue.milestone {
MilestoneDisplaySection(milestone: milestone)
}
if let assignees = issue.assignees, !assignees.isEmpty {
AssigneesDisplaySection(assignees: assignees)
}
// Body
if let body = issue.body, !body.isEmpty {
Section("Description") {
MarkdownPreview(text: body)
.padding(.vertical, 4)
}
}
// Comments
if !comments.isEmpty {
Section("Comments (\(comments.count))") {
ForEach(comments) { comment in
CommentView(
comment: comment,
currentUsername: authService.currentUser?.login,
) { newBody in
await editComment(commentId: comment.id, body: newBody)
}
}
}
}
// Spacer so content isn't hidden behind floating bar
Section {} footer: {
Spacer().frame(height: 60)
}
}
.listStyle(.insetGrouped)
}
}
// Floating glass action cluster
if let currentIssue = issue {
ExpandableActionMenu(isExpanded: $showActionsExpanded) {
Button { showCommentSheet = true } label: {
Label("Comment", systemImage: "text.bubble")
}
.buttonStyle(.glassProminent)
.transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityIdentifier("issue-comment-button")
if canEditOrClose {
Button { showEditSheet = true } label: {
Label("Edit", systemImage: "pencil")
}
.buttonStyle(.glassProminent)
.transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityIdentifier("issue-edit-button")
Button { Task { await toggleIssueState() } } label: {
if isTogglingState {
ProgressView()
.controlSize(.small)
} else {
Label(
currentIssue.stateValue == .open ? "Close" : "Reopen",
systemImage: currentIssue.stateValue == .open
? "xmark.circle" : "arrow.uturn.left.circle",
)
}
}
.buttonStyle(.glassProminent)
.tint(currentIssue.stateValue == .open ? .purple : .green)
.disabled(isTogglingState)
.transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityIdentifier("issue-toggle-state")
}
}
}
}
.navigationTitle("#\(issueNumber)")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showEditSheet) {
if let issue {
IssueEditView(
repository: repository,
issue: issue,
authService: authService,
) { updatedIssue in
self.issue = updatedIssue
}
}
}
.sheet(isPresented: $showCommentSheet) {
CommentSheet(users: []) { body in
guard let issueService else { throw URLError(.userAuthenticationRequired) }
let comment = try await issueService.createComment(
owner: owner,
repo: repo,
index: issueNumber,
body: body,
)
comments.append(comment)
}
}
.task {
await loadData()
}
.errorAlert(message: $errorMessage, isPresented: $showError)
}
private func loadData() async {
guard let issueService else { return }
isLoading = true
do {
async let fetchedIssue = issueService.fetchIssue(owner: owner, repo: repo, index: issueNumber)
async let fetchedComments = issueService.fetchComments(owner: owner, repo: repo, index: issueNumber)
let loadedIssue = try await fetchedIssue
let loadedComments = try await fetchedComments
try Task.checkCancellation()
issue = loadedIssue
comments = loadedComments
} catch is CancellationError {
// Ignore cancellation
} catch {
errorMessage = error.localizedDescription
showError = true
}
isLoading = false
}
private func toggleIssueState() async {
guard let issueService, let issue else { return }
isTogglingState = true
do {
let newState = issue.stateValue == .open ? IssueState.closed.rawValue : IssueState.open.rawValue
let updated = try await issueService.editIssue(
owner: owner,
repo: repo,
index: issueNumber,
title: nil,
body: nil,
state: newState,
)
self.issue = updated
NotificationCenter.default.post(name: .issuesDidChange, object: nil)
} catch {
errorMessage = error.localizedDescription
showError = true
}
isTogglingState = false
}
private func editComment(commentId: Int, body: String) async -> Bool {
guard let issueService else { return false }
do {
let updated = try await issueService.editComment(
owner: owner,
repo: repo,
commentId: commentId,
body: body,
)
if let index = comments.firstIndex(where: { $0.id == commentId }) {
comments[index] = updated
}
return true
} catch {
errorMessage = error.localizedDescription
showError = true
return false
}
}
#if DEBUG
init(preview _: Void, repository: Repository, issueNumber: Int, authService: AuthenticationService,
issue: Issue, comments: [IssueComment] = [])
{
self.repository = repository
self.issueNumber = issueNumber
self.authService = authService
issueService = nil
_issue = State(initialValue: issue)
_comments = State(initialValue: comments)
_isLoading = State(initialValue: false)
}
#endif
}
#if DEBUG
#Preview {
NavigationStack {
IssueDetailView(
preview: (),
repository: .preview,
issueNumber: 1,
authService: .previewDefault,
issue: .preview,
comments: [.preview],
)
}
}
#endif