gitbay/Views/Releases/ReleaseListView.swift

v1.8.0
gitbay-ios/gitbay/Views/Releases/ReleaseListView.swift history · blame · raw

156 lines · 5893 bytes

  1import SwiftUI
  2
  3struct ReleaseListView: View {
  4
  5    @State private var model: ReleaseListViewModel
  6    @State private var composing = false
  7    @State private var draftTag = ""
  8    @State private var draftTitle = ""
  9    @State private var draftNotes = ""
 10    @State private var hasLoadedOnce = false
 11
 12    init(client: GitbayClient, repo: String) {
 13        _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo))
 14    }
 15
 16    var body: some View {
 17        List {
 18            ForEach(model.state.value ?? []) { release in
 19                NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) {
 20                    VStack(alignment: .leading, spacing: 3) {
 21                        Text(release.title.isEmpty ? release.tag : release.title)
 22                            .font(.gbSans(.subheadline).weight(.medium))
 23                            .lineLimit(2)
 24                        HStack(spacing: 6) {
 25                            Text(release.tag)
 26                                .font(.gbMono(.caption))
 27                                .foregroundStyle(.secondary)
 28                            if let author = release.author {
 29                                Text("by \(author)")
 30                                    .font(.gbSans(.caption))
 31                                    .foregroundStyle(.secondary)
 32                            }
 33                            Spacer()
 34                            Text(release.createdAt, format: .relative(presentation: .named))
 35                                .font(.gbSans(.caption))
 36                                .foregroundStyle(.tertiary)
 37                        }
 38                    }
 39                    .padding(.vertical, 2)
 40                }
 41            }
 42        }
 43        .overlay { LoadStateOverlay(state: model.state) }
 44        .navigationTitle("Releases")
 45        .navigationBarTitleDisplayMode(.inline)
 46        .toolbar {
 47            ToolbarItem(placement: .topBarTrailing) {
 48                Button {
 49                    composing = true
 50                } label: {
 51                    Image(systemName: "plus")
 52                }
 53                .accessibilityLabel("New Release")
 54                .accessibilityIdentifier("release-create-button")
 55            }
 56        }
 57        .sheet(isPresented: $composing) {
 58            ReleaseCreateSheet(
 59                model: model,
 60                tag: $draftTag, title: $draftTitle, notes: $draftNotes
 61            ) {
 62                draftTag = ""
 63                draftTitle = ""
 64                draftNotes = ""
 65                composing = false
 66            }
 67        }
 68        .task {
 69            await model.load()
 70            hasLoadedOnce = true
 71        }
 72        // A deleted release pops back to this list; `.task` does not
 73        // re-run on that pop, so a manual refresh here is what drops the
 74        // now-gone row. Guarded so the initial `.task` load isn't repeated.
 75        .onAppear {
 76            guard hasLoadedOnce else { return }
 77            Task { await model.load() }
 78        }
 79        .refreshable { await model.load() }
 80    }
 81}
 82
 83/// The tag is typed and must already exist on the server — the refusal
 84/// ("push the tag first") is the explanation.
 85private struct ReleaseCreateSheet: View {
 86
 87    let model: ReleaseListViewModel
 88    @Binding var tag: String
 89    @Binding var title: String
 90    @Binding var notes: String
 91    let onCreated: () -> Void
 92
 93    @Environment(\.dismiss) private var dismiss
 94
 95    var body: some View {
 96        NavigationStack {
 97            Form {
 98                Section("Tag") {
 99                    TextField("v1.0.0", text: $tag)
100                        .autocorrectionDisabled()
101                        .textInputAutocapitalization(.never)
102                        .accessibilityIdentifier("release-tag")
103                }
104                Section("Title") {
105                    TextField("Title (optional)", text: $title)
106                        .autocorrectionDisabled()
107                }
108                Section("Notes") {
109                    TextEditor(text: $notes)
110                        .frame(minHeight: 140)
111                        .autocorrectionDisabled()
112                    // release create sends no --format, and release notes
113                    // render as markdown.
114                    MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
115                }
116                if let error = model.createError {
117                    Section {
118                        GBNotice(error)
119                    }
120                }
121            }
122            .scrollDismissesKeyboard(.interactively)
123            .navigationTitle("New Release")
124            .navigationBarTitleDisplayMode(.inline)
125            .toolbar {
126                ToolbarItem(placement: .cancellationAction) {
127                    Button("Cancel") { dismiss() }
128                }
129                ToolbarItem(placement: .confirmationAction) {
130                    if model.working {
131                        ProgressView()
132                    } else {
133                        Button("Create") {
134                            Task {
135                                if await model.create(
136                                    tag: tag.trimmingCharacters(in: .whitespaces),
137                                    title: title, notes: notes
138                                ) {
139                                    onCreated()
140                                }
141                            }
142                        }
143                        .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty)
144                        .accessibilityIdentifier("release-submit")
145                    }
146                }
147            }
148            .interactiveDismissDisabled(model.working)
149        }
150    }
151}
152
153nonisolated enum ReleaseRoute: Hashable {
154    case list(repo: String)
155    case release(repo: String, tag: String)
156}