Files
ios-gitea-prs/GiteaPR/Detail/PRDetailView.swift
T
Hermes 5e9ee9e5cf
Build iOS App / Build .ipa and publish source (push) Successful in 22s
Prevent duplicate approve reviews + redesign bottom bar; bump to 2.9
- Approve: rapid taps fired multiple submitReview calls that each appended a
  review, stacking duplicate entries. Add a busy guard (skip while one is in
  flight) and dedupe reviews per user so the latest replaces earlier ones;
  disable the review menu while submitting.
- Bottom bar: replace the full-width bar with a floating frosted-glass pill
  inset from the edges (like SideStore), with a highlighted capsule behind
  the active tab. Bump to 2.9.
2026-08-13 16:00:32 +00:00

223 lines
8.0 KiB
Swift

import SwiftUI
struct PRDetailView: View {
@StateObject private var vm: PRDetailViewModel
@State private var selectedTab: DetailTab = .overview
@State private var showCommentSheet = false
@State private var showMergeSheet = false
@State private var showCloseConfirm = false
@State private var showRerunConfirm: ActionRun?
@State private var requestChangesMode = false
enum DetailTab: String, CaseIterable, Identifiable {
case overview = "Overview"
case conversation = "Conversation"
case files = "Files"
case checks = "Checks"
var id: String { rawValue }
var icon: String {
switch self {
case .overview: return "doc.text"
case .conversation: return "bubble.left.and.bubble.right"
case .files: return "doc.plaintext"
case .checks: return "checkmark.circle"
}
}
}
init(repo: Repository, prNumber: Int) {
_vm = StateObject(wrappedValue: PRDetailViewModel(repo: repo,
prNumber: prNumber))
}
var body: some View {
Group {
if let pr = vm.pr {
content(pr)
} else if vm.isLoading {
ProgressView("Loading…")
} else if let err = vm.errorMessage {
ContentUnavailableView("Couldn't load PR",
systemImage: "exclamationmark.triangle",
description: Text(err))
.toolbar { ToolbarItem(placement: .primaryAction) { reloadButton } }
} else {
ProgressView("Loading…")
}
}
.navigationTitle("#\(vm.prNumber)")
.navigationBarTitleDisplayMode(.inline)
.task { await vm.load() }
.alert("Error", isPresented: .init(
get: { vm.errorMessage != nil },
set: { if !$0 { vm.errorMessage = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(vm.errorMessage ?? "") }
.overlay { if vm.busyMessage != nil { busyOverlay } }
.confirmationDialog("Close this PR?", isPresented: $showCloseConfirm, titleVisibility: .visible) {
Button("Close", role: .destructive) { Task { await vm.setState("closed") } }
Button("Cancel", role: .cancel) {}
} message: { Text("This will close the pull request.") }
.sheet(isPresented: $showCommentSheet) {
CommentSheet(vm: vm,
mode: requestChangesMode ? .requestChanges : .comment)
.onDisappear { requestChangesMode = false }
}
.sheet(isPresented: $showMergeSheet) { MergeSheet(vm: vm) }
.confirmationDialog("Re-run workflow?",
isPresented: .init(
get: { showRerunConfirm != nil },
set: { if !$0 { showRerunConfirm = nil } }),
titleVisibility: .visible) {
Button("Re-run") {
if let run = showRerunConfirm {
Task { await vm.rerun(runID: run.id) }
}
showRerunConfirm = nil
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Re-run \"\(showRerunConfirm?.name ?? "workflow")\"?")
}
}
@ViewBuilder
private func content(_ pr: PullRequest) -> some View {
VStack(spacing: 0) {
header(pr)
ScrollView {
switch selectedTab {
case .overview: OverviewTab(pr: pr)
case .conversation: ConversationTab(vm: vm)
case .files: FilesTab(vm: vm)
case .checks: ChecksTab(vm: vm, onRerun: { showRerunConfirm = $0 })
}
}
bottomTabBar
}
.toolbar {
if prIsOpen {
ToolbarItem(placement: .primaryAction) { mergeButton }
ToolbarItem(placement: .primaryAction) {
// Disable while a review submission is in flight so rapid
// taps can't stack duplicate reviews.
reviewMenu.disabled(vm.busyMessage != nil)
}
}
}
}
@ViewBuilder
private func header(_ pr: PullRequest) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(pr.title)
.font(.title3.weight(.bold))
HStack(spacing: 6) {
Text("\(vm.repo.fullName)#\(pr.number)")
.font(.caption)
.foregroundColor(.secondary)
CICircle(status: vm.ciStatus)
if vm.reviewState != .unknown {
ReviewBadge(state: vm.reviewState)
}
if pr.draft == true {
Text("Draft").font(.caption2).foregroundColor(.secondary)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground))
}
/// Bottom tab bar: a floating, frosted-glass pill inset from the screen
/// edges (like the SideStore tab bar), with a highlighted capsule behind
/// the active tab. Icon + text per tab.
private var bottomTabBar: some View {
HStack(spacing: 4) {
ForEach(DetailTab.allCases) { tab in
Button {
selectedTab = tab
} label: {
VStack(spacing: 2) {
Image(systemName: tab.icon)
.font(.system(size: 20, weight: .medium))
Text(tab.rawValue)
.font(.caption2.weight(.medium))
}
.foregroundColor(selectedTab == tab ? .accentColor : .secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(
selectedTab == tab
? AnyShapeStyle(.ultraThinMaterial)
: AnyShapeStyle(.clear)
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 6)
.padding(.vertical, 6)
.background(
Capsule()
.fill(.regularMaterial)
.shadow(color: .black.opacity(0.15), radius: 10, y: 4)
)
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
private var reviewMenu: some View {
Menu {
Button("Comment") { requestChangesMode = false; showCommentSheet = true }
Button("Approve") { Task { await vm.submitReview(state: "approved", comment: nil) } }
Button("Request changes") { requestChangesMode = true; showCommentSheet = true }
Divider()
if prIsOpen {
Button("Close", role: .destructive) { showCloseConfirm = true }
} else {
Button("Reopen") { Task { await vm.setState("open") } }
}
} label: {
Image(systemName: "checkmark.bubble")
}
}
private var mergeButton: some View {
Button {
showMergeSheet = true
} label: {
Image(systemName: "arrow.triangle.merge")
}
.disabled(vm.ciStatus == .failure &&
SettingsStore.shared.mergeGateMode == .block)
}
private var prIsOpen: Bool {
(vm.pr?.state.lowercased() ?? "") == "open"
}
private var reloadButton: some View {
Button { Task { await vm.load() } } label: {
Image(systemName: "arrow.clockwise")
}
}
private var busyOverlay: some View {
ZStack {
Color.black.opacity(0.15)
ProgressView(vm.busyMessage ?? "")
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.ignoresSafeArea()
}
}