gitbay/Views/Releases/ReleaseListView.swift
140 lines · 5136 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
11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo))
13 }
14
15 var body: some View {
16 List {
17 ForEach(model.state.value ?? []) { release in
18 NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) {
19 VStack(alignment: .leading, spacing: 3) {
20 Text(release.title.isEmpty ? release.tag : release.title)
21 .font(.gbSans(.subheadline).weight(.medium))
22 .lineLimit(2)
23 HStack(spacing: 6) {
24 Text(release.tag)
25 .font(.gbMono(.caption))
26 .foregroundStyle(.secondary)
27 if let author = release.author {
28 Text("by \(author)")
29 .font(.gbSans(.caption))
30 .foregroundStyle(.secondary)
31 }
32 Spacer()
33 Text(release.createdAt, format: .relative(presentation: .named))
34 .font(.gbSans(.caption))
35 .foregroundStyle(.tertiary)
36 }
37 }
38 .padding(.vertical, 2)
39 }
40 }
41 }
42 .overlay { LoadStateOverlay(state: model.state) }
43 .navigationTitle("Releases")
44 .navigationBarTitleDisplayMode(.inline)
45 .toolbar {
46 ToolbarItem(placement: .topBarTrailing) {
47 Button {
48 composing = true
49 } label: {
50 Image(systemName: "plus")
51 }
52 .accessibilityIdentifier("release-create-button")
53 }
54 }
55 .sheet(isPresented: $composing) {
56 ReleaseCreateSheet(
57 model: model,
58 tag: $draftTag, title: $draftTitle, notes: $draftNotes
59 ) {
60 draftTag = ""
61 draftTitle = ""
62 draftNotes = ""
63 composing = false
64 }
65 }
66 .task { await model.load() }
67 .refreshable { await model.load() }
68 }
69}
70
71/// The tag is typed and must already exist on the server — the refusal
72/// ("push the tag first") is the explanation.
73private struct ReleaseCreateSheet: View {
74
75 let model: ReleaseListViewModel
76 @Binding var tag: String
77 @Binding var title: String
78 @Binding var notes: String
79 let onCreated: () -> Void
80
81 @Environment(\.dismiss) private var dismiss
82
83 var body: some View {
84 NavigationStack {
85 Form {
86 Section("Tag") {
87 TextField("v1.0.0", text: $tag)
88 .autocorrectionDisabled()
89 .textInputAutocapitalization(.never)
90 .accessibilityIdentifier("release-tag")
91 }
92 Section("Title") {
93 TextField("Title (optional)", text: $title)
94 .autocorrectionDisabled()
95 }
96 Section("Notes") {
97 TextEditor(text: $notes)
98 .frame(minHeight: 140)
99 .autocorrectionDisabled()
100 }
101 if let error = model.createError {
102 Section {
103 GBNotice(error)
104 }
105 }
106 }
107 .navigationTitle("New Release")
108 .navigationBarTitleDisplayMode(.inline)
109 .toolbar {
110 ToolbarItem(placement: .cancellationAction) {
111 Button("Cancel") { dismiss() }
112 }
113 ToolbarItem(placement: .confirmationAction) {
114 if model.working {
115 ProgressView()
116 } else {
117 Button("Create") {
118 Task {
119 if await model.create(
120 tag: tag.trimmingCharacters(in: .whitespaces),
121 title: title, notes: notes
122 ) {
123 onCreated()
124 }
125 }
126 }
127 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty)
128 .accessibilityIdentifier("release-submit")
129 }
130 }
131 }
132 .interactiveDismissDisabled(model.working)
133 }
134 }
135}
136
137nonisolated enum ReleaseRoute: Hashable {
138 case list(repo: String)
139 case release(repo: String, tag: String)
140}