gitbay/Views/Releases/ReleaseListView.swift

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

151 lines · 5573 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                .accessibilityIdentifier("release-create-button")
 54            }
 55        }
 56        .sheet(isPresented: $composing) {
 57            ReleaseCreateSheet(
 58                model: model,
 59                tag: $draftTag, title: $draftTitle, notes: $draftNotes
 60            ) {
 61                draftTag = ""
 62                draftTitle = ""
 63                draftNotes = ""
 64                composing = false
 65            }
 66        }
 67        .task {
 68            await model.load()
 69            hasLoadedOnce = true
 70        }
 71        // A deleted release pops back to this list; `.task` does not
 72        // re-run on that pop, so a manual refresh here is what drops the
 73        // now-gone row. Guarded so the initial `.task` load isn't repeated.
 74        .onAppear {
 75            guard hasLoadedOnce else { return }
 76            Task { await model.load() }
 77        }
 78        .refreshable { await model.load() }
 79    }
 80}
 81
 82/// The tag is typed and must already exist on the server — the refusal
 83/// ("push the tag first") is the explanation.
 84private struct ReleaseCreateSheet: View {
 85
 86    let model: ReleaseListViewModel
 87    @Binding var tag: String
 88    @Binding var title: String
 89    @Binding var notes: String
 90    let onCreated: () -> Void
 91
 92    @Environment(\.dismiss) private var dismiss
 93
 94    var body: some View {
 95        NavigationStack {
 96            Form {
 97                Section("Tag") {
 98                    TextField("v1.0.0", text: $tag)
 99                        .autocorrectionDisabled()
100                        .textInputAutocapitalization(.never)
101                        .accessibilityIdentifier("release-tag")
102                }
103                Section("Title") {
104                    TextField("Title (optional)", text: $title)
105                        .autocorrectionDisabled()
106                }
107                Section("Notes") {
108                    TextEditor(text: $notes)
109                        .frame(minHeight: 140)
110                        .autocorrectionDisabled()
111                }
112                if let error = model.createError {
113                    Section {
114                        GBNotice(error)
115                    }
116                }
117            }
118            .navigationTitle("New Release")
119            .navigationBarTitleDisplayMode(.inline)
120            .toolbar {
121                ToolbarItem(placement: .cancellationAction) {
122                    Button("Cancel") { dismiss() }
123                }
124                ToolbarItem(placement: .confirmationAction) {
125                    if model.working {
126                        ProgressView()
127                    } else {
128                        Button("Create") {
129                            Task {
130                                if await model.create(
131                                    tag: tag.trimmingCharacters(in: .whitespaces),
132                                    title: title, notes: notes
133                                ) {
134                                    onCreated()
135                                }
136                            }
137                        }
138                        .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty)
139                        .accessibilityIdentifier("release-submit")
140                    }
141                }
142            }
143            .interactiveDismissDisabled(model.working)
144        }
145    }
146}
147
148nonisolated enum ReleaseRoute: Hashable {
149    case list(repo: String)
150    case release(repo: String, tag: String)
151}