Build iOS App / Build .ipa and publish source (push) Successful in 22s
- Empty state ('No open PRs') was a bare ContentUnavailableView with no
pull-to-refresh, so you couldn't reload when the list was empty. Wrap it in
a refreshable ScrollView so pulling down reloads even with no PRs.
- Switch the landing title from .large to .inline so 'Pull Requests' sits on
the same row as the settings gear (better space use). Bump to 2.8.
121 lines
4.5 KiB
Swift
121 lines
4.5 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()
|
|
}
|
|
.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())
|
|
}
|
|
}
|