gitbay/Views/Releases/ReleaseListView.swift
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}