Files
Hermes a045a36125
Build iOS App / Build .ipa and publish source (push) Successful in 34s
Reload inbox after merge + pull-to-refresh on PR content; bump to 3.4
- After a merge, the app returns to the landing page but the inbox kept stale
  PRs. Add an inboxNeedsRefresh flag set on merge success; InboxView reloads
  on next appearance when it's set.
- The PR content (incl. the Checks/workflow tab) is now pull-to-refreshable
  via .refreshable on the content ScrollView, so workflow status can be
  updated by pulling down. Bump to 3.4.
2026-08-17 13:23:10 +00:00

129 lines
4.9 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 {
// Empty state as a refreshable ScrollView so pulling down
// reloads even when there are no PRs (a ContentUnavailableView
// alone has no pull-to-refresh).
ScrollView {
ContentUnavailableView(
"No open PRs",
systemImage: "tray",
description: Text("Pull down to refresh."))
.frame(maxWidth: .infinity, minHeight: 400)
}
.refreshable { await vm.load() }
} else {
List {
ForEach(vm.displayedRows) { row in
NavigationLink(value: row) {
PRRowView(row: row)
}
}
}
.refreshable { await vm.load() }
.listStyle(.plain)
}
}
.navigationTitle("Pull Requests")
.navigationBarTitleDisplayMode(.inline)
.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()
}
.onAppear {
// After a merge, the inbox must reload so the just-merged PR
// doesn't linger as stale in the list.
if SettingsStore.shared.inboxNeedsRefresh {
SettingsStore.shared.inboxNeedsRefresh = false
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 ?? "")
}
.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())
}
}