gitbay/Views/Releases/ReleaseView.swift
161 lines · 6404 bytes
1import SwiftUI
2
3struct ReleaseView: View {
4
5 @State private var model: ReleaseDetailViewModel
6 @State private var editing = false
7 @State private var draftTitle = ""
8 @State private var draftNotes = ""
9 @State private var confirmingDelete = false
10 @State private var removingAsset: Release.Asset?
11 @Environment(\.dismiss) private var dismiss
12
13 init(client: GitbayClient, repo: String, tag: String) {
14 _model = State(initialValue: ReleaseDetailViewModel(
15 client: client, repoPath: repo, tag: tag
16 ))
17 }
18
19 var body: some View {
20 List {
21 if let release = model.state.value {
22 if let error = model.actionError {
23 Section {
24 GBNotice(error, .gbWarn)
25 }
26 }
27 Section {
28 VStack(alignment: .leading, spacing: 6) {
29 Text(release.title.isEmpty ? release.tag : release.title)
30 .font(.gbSans(.headline))
31 HStack(spacing: 6) {
32 Text(release.tag)
33 .font(.gbMono(.caption))
34 if let author = release.author {
35 Text("by \(author)")
36 }
37 Text(release.createdAt, format: .relative(presentation: .named))
38 .foregroundStyle(.tertiary)
39 }
40 .font(.gbSans(.caption))
41 .foregroundStyle(.secondary)
42 }
43 .padding(.vertical, 2)
44 }
45
46 if let notes = release.notes, !notes.isEmpty {
47 Section {
48 MarkdownView(markdown: notes)
49 .padding(.vertical, 4)
50 }
51 }
52
53 if let assets = release.assets, !assets.isEmpty {
54 Section("Assets") {
55 ForEach(assets) { asset in
56 Link(destination: model.downloadURL(for: asset)) {
57 HStack {
58 VStack(alignment: .leading, spacing: 2) {
59 Text(asset.name)
60 .font(.gbMono(.caption))
61 .foregroundStyle(.primary)
62 .lineLimit(1)
63 Text(String(asset.sha256.prefix(16)))
64 .font(.gbMono(.caption2))
65 .foregroundStyle(.tertiary)
66 }
67 Spacer()
68 Text(asset.size.formatted(.byteCount(style: .file)))
69 .font(.gbSans(.caption))
70 .foregroundStyle(.secondary)
71 Image(systemName: "arrow.down.circle")
72 .foregroundStyle(.secondary)
73 }
74 }
75 .swipeActions {
76 Button("Remove", role: .destructive) {
77 removingAsset = asset
78 }
79 .disabled(model.working)
80 }
81 }
82 }
83 }
84 }
85 }
86 .overlay { LoadStateOverlay(state: model.state) }
87 .navigationTitle(model.tag)
88 .navigationBarTitleDisplayMode(.inline)
89 .toolbar {
90 ToolbarItem(placement: .topBarTrailing) {
91 if let release = model.state.value {
92 Button("Edit") {
93 draftTitle = release.title
94 draftNotes = release.notes ?? ""
95 editing = true
96 }
97 .disabled(model.working)
98 .accessibilityIdentifier("release-edit-button")
99 }
100 }
101 ToolbarItem(placement: .topBarTrailing) {
102 if model.state.value != nil {
103 Button("Delete", role: .destructive) {
104 confirmingDelete = true
105 }
106 .disabled(model.working)
107 .accessibilityIdentifier("release-delete-button")
108 }
109 }
110 }
111 .confirmationDialog(
112 "Delete \(model.tag)?",
113 isPresented: $confirmingDelete,
114 titleVisibility: .visible
115 ) {
116 Button("Delete", role: .destructive) {
117 Task {
118 if await model.delete() {
119 dismiss()
120 }
121 }
122 }
123 Button("Cancel", role: .cancel) {}
124 } message: {
125 Text("This deletes the release's assets along with it.")
126 }
127 .confirmationDialog(
128 "Remove \(removingAsset?.name ?? "") from \(model.tag)?",
129 isPresented: Binding(
130 get: { removingAsset != nil },
131 set: { if !$0 { removingAsset = nil } }
132 ),
133 titleVisibility: .visible
134 ) {
135 Button("Remove", role: .destructive) {
136 if let asset = removingAsset {
137 Task { await model.removeAsset(named: asset.name) }
138 }
139 removingAsset = nil
140 }
141 Button("Cancel", role: .cancel) {}
142 }
143 .sheet(isPresented: $editing) {
144 ComposeSheet(
145 heading: "Edit \(model.tag)",
146 submitLabel: "Save",
147 working: model.working,
148 errorMessage: model.actionError,
149 title: $draftTitle,
150 bodyText: $draftNotes
151 ) {
152 Task {
153 await model.edit(title: draftTitle, notes: draftNotes)
154 if model.actionError == nil { editing = false }
155 }
156 }
157 }
158 .task { await model.load() }
159 .refreshable { await model.load() }
160 }
161}