import SwiftUI struct ReleaseListView: View { @State private var model: ReleaseListViewModel @State private var composing = false @State private var draftTag = "" @State private var draftTitle = "" @State private var draftNotes = "" init(client: GitbayClient, repo: String) { _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo)) } var body: some View { List { ForEach(model.state.value ?? []) { release in NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) { VStack(alignment: .leading, spacing: 3) { Text(release.title.isEmpty ? release.tag : release.title) .font(.gbSans(.subheadline).weight(.medium)) .lineLimit(2) HStack(spacing: 6) { Text(release.tag) .font(.gbMono(.caption)) .foregroundStyle(.secondary) if let author = release.author { Text("by \(author)") .font(.gbSans(.caption)) .foregroundStyle(.secondary) } Spacer() Text(release.createdAt, format: .relative(presentation: .named)) .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } } .padding(.vertical, 2) } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Releases") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { composing = true } label: { Image(systemName: "plus") } .accessibilityIdentifier("release-create-button") } } .sheet(isPresented: $composing) { ReleaseCreateSheet( model: model, tag: $draftTag, title: $draftTitle, notes: $draftNotes ) { draftTag = "" draftTitle = "" draftNotes = "" composing = false } } .task { await model.load() } .refreshable { await model.load() } } } /// The tag is typed and must already exist on the server — the refusal /// ("push the tag first") is the explanation. private struct ReleaseCreateSheet: View { let model: ReleaseListViewModel @Binding var tag: String @Binding var title: String @Binding var notes: String let onCreated: () -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Form { Section("Tag") { TextField("v1.0.0", text: $tag) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("release-tag") } Section("Title") { TextField("Title (optional)", text: $title) .autocorrectionDisabled() } Section("Notes") { TextEditor(text: $notes) .frame(minHeight: 140) .autocorrectionDisabled() } if let error = model.createError { Section { GBNotice(error) } } } .navigationTitle("New Release") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Create") { Task { if await model.create( tag: tag.trimmingCharacters(in: .whitespaces), title: title, notes: notes ) { onCreated() } } } .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("release-submit") } } } .interactiveDismissDisabled(model.working) } } } nonisolated enum ReleaseRoute: Hashable { case list(repo: String) case release(repo: String, tag: String) }