Build iOS App / Build .ipa and publish source (push) Successful in 22s
.task fires on every appearance, including popping back from a PR detail, so the landing page re-ran the full network refresh on each back navigation. Guard the initial load with didInitialLoad so the list loads once and then only refreshes when the user pulls down. Bump to 2.2.
121 lines
4.4 KiB
Swift
121 lines
4.4 KiB
Swift
import SwiftUI
|
|
|
|
struct InboxView: View {
|
|
@StateObject private var vm = InboxViewModel()
|
|
@State private var showSettings = false
|
|
// Load the inbox only on first appearance. `.task` fires on every
|
|
// appearance (including popping back from a PR detail), so without this
|
|
// guard the landing page re-runs the full network refresh on each back
|
|
// navigation. After the first load, refresh only via pull-to-refresh.
|
|
@State private var didInitialLoad = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if !vm.isConfigured {
|
|
ContentUnavailableView(
|
|
"Not configured",
|
|
systemImage: "key",
|
|
description: Text("Add your Gitea instance and token in Settings."))
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) { settingsButton }
|
|
}
|
|
} else if vm.displayedRows.isEmpty && !vm.isLoading {
|
|
ContentUnavailableView(
|
|
"No open PRs",
|
|
systemImage: "tray",
|
|
description: Text("Nothing to review."))
|
|
} else {
|
|
List {
|
|
ForEach(vm.displayedRows) { row in
|
|
NavigationLink(value: row) {
|
|
PRRowView(row: row)
|
|
}
|
|
}
|
|
}
|
|
.refreshable { await vm.load() }
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
.navigationTitle("Pull Requests")
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) { settingsButton }
|
|
}
|
|
.navigationDestination(for: PRRow.self) { row in
|
|
PRDetailView(repo: row.repo, prNumber: row.pr.number)
|
|
}
|
|
.task {
|
|
// Load only on first appearance; subsequent appearances (e.g.
|
|
// popping back from a PR) reuse the cached list and only
|
|
// refresh when the user pulls down.
|
|
guard !didInitialLoad else { return }
|
|
didInitialLoad = true
|
|
await vm.load()
|
|
}
|
|
.overlay {
|
|
// Only show a spinner when there's nothing cached to display
|
|
// yet; with a cache the list renders instantly and refreshes
|
|
// in the background.
|
|
if vm.isLoading && vm.displayedRows.isEmpty {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.alert("Error", isPresented: .init(
|
|
get: { vm.errorMessage != nil },
|
|
set: { if !$0 { vm.errorMessage = nil } })) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(vm.errorMessage ?? "")
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var settingsButton: some View {
|
|
Button { showSettings = true } label: {
|
|
Image(systemName: "gearshape")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PRRowView: View {
|
|
let row: PRRow
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Text("#\(row.pr.number)")
|
|
.font(.subheadline.weight(.bold))
|
|
.foregroundColor(.secondary)
|
|
if row.pr.draft == true {
|
|
Text("Draft")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
Text(row.pr.title)
|
|
.font(.body.weight(.medium))
|
|
.lineLimit(2)
|
|
Text(row.fullName)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
if let updated = row.pr.updatedAt {
|
|
Text(relative(updated))
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
private func relative(_ date: Date) -> String {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .abbreviated
|
|
return f.localizedString(for: date, relativeTo: Date())
|
|
}
|
|
}
|