gitbay/Views/Releases/ReleaseListView.swift

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

175 lines · 6898 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 {
109                    TextEditor(text: $notes)
110                        .frame(minHeight: 140)
111                        .autocorrectionDisabled()
112                    Button("Summarize changes") {
113                        Task {
114                            // Appended to the notes as they are when the lines
115                            // arrive, so typing meanwhile is kept.
116                            if let lines = await model.summary(tag: tag.trimmingCharacters(in: .whitespaces)) {
117                                notes = ReleaseListViewModel.appending(lines, to: notes)
118                            }
119                        }
120                    }
121                    .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
122                    .accessibilityIdentifier("release-summarize")
123                    // release create sends no --format, and release notes
124                    // render as markdown.
125                    MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
126                } header: {
127                    Text("Notes")
128                } footer: {
129                    if let note = model.summaryNote {
130                        Text(note)
131                    } else {
132                        Text("Summarize changes adds a line for each merge request since the previous release.")
133                    }
134                }
135                if let error = model.createError {
136                    Section {
137                        GBNotice(error)
138                    }
139                }
140            }
141            .scrollDismissesKeyboard(.interactively)
142            .navigationTitle("New Release")
143            .navigationBarTitleDisplayMode(.inline)
144            .toolbar {
145                ToolbarItem(placement: .cancellationAction) {
146                    Button("Cancel") { dismiss() }
147                }
148                ToolbarItem(placement: .confirmationAction) {
149                    if model.working {
150                        ProgressView()
151                    } else {
152                        Button("Create") {
153                            Task {
154                                if await model.create(
155                                    tag: tag.trimmingCharacters(in: .whitespaces),
156                                    title: title, notes: notes
157                                ) {
158                                    onCreated()
159                                }
160                            }
161                        }
162                        .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty)
163                        .accessibilityIdentifier("release-submit")
164                    }
165                }
166            }
167            .interactiveDismissDisabled(model.working)
168        }
169    }
170}
171
172nonisolated enum ReleaseRoute: Hashable {
173    case list(repo: String)
174    case release(repo: String, tag: String)
175}