gitbay/Views/Issues/LabelListView.swift
108 lines · 3956 bytes
1import SwiftUI
2
3/// A repository's labels: their colour, how many issues carry each, and
4/// the controls to create, recolour and remove them.
5struct LabelListView: View {
6
7 @State private var model: LabelListViewModel
8 @State private var newLabel = ""
9 @State private var pendingRemoval: IssueLabel?
10
11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: LabelListViewModel(client: client, repoPath: repo))
13 }
14
15 private var colors: LabelColors { LabelColors(model.state.value ?? []) }
16
17 var body: some View {
18 List {
19 if let actionError = model.actionError {
20 Section { GBNotice(actionError) }
21 }
22
23 Section("New label") {
24 HStack {
25 TextField("Label", text: $newLabel)
26 .autocorrectionDisabled()
27 .textInputAutocapitalization(.never)
28 Button {
29 let name = newLabel.trimmingCharacters(in: .whitespaces)
30 newLabel = ""
31 Task { await model.setLabel(name, color: nil) }
32 } label: {
33 Image(systemName: "plus.circle.fill")
34 }
35 .disabled(
36 newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working
37 )
38 }
39 }
40
41 ForEach(model.state.value ?? []) { label in
42 LabelRow(label: label, color: colors.color(label.name), working: model.working) {
43 hex in
44 Task { await model.setLabel(label.name, color: hex) }
45 } remove: {
46 pendingRemoval = label
47 }
48 }
49 }
50 .overlay { LoadStateOverlay(state: model.state) }
51 .navigationTitle("Labels")
52 .navigationBarTitleDisplayMode(.inline)
53 .task { await model.load() }
54 .refreshable { await model.load() }
55 .confirmationDialog(
56 "Remove \(pendingRemoval?.name ?? "")?",
57 isPresented: .init(
58 get: { pendingRemoval != nil },
59 set: { if !$0 { pendingRemoval = nil } }
60 ),
61 titleVisibility: .visible
62 ) {
63 Button("Remove", role: .destructive) {
64 guard let label = pendingRemoval else { return }
65 pendingRemoval = nil
66 Task { await model.remove(label.name) }
67 }
68 Button("Cancel", role: .cancel) { pendingRemoval = nil }
69 } message: {
70 // The command takes the label off every issue, not just the set.
71 Text("This takes the label off every issue that carries it.")
72 }
73 }
74}
75
76private struct LabelRow: View {
77 let label: IssueLabel
78 let color: Color
79 let working: Bool
80 let recolour: (String) -> Void
81 let remove: () -> Void
82
83 var body: some View {
84 HStack(spacing: 8) {
85 GBChip(label.name, color)
86 Text(label.issues == 1 ? "1 issue" : "\(label.issues) issues")
87 .font(.gbSans(.caption))
88 .foregroundStyle(.secondary)
89 Spacer()
90 // An org label is managed with `org label`; the repository's
91 // own commands refuse it, so it gets the mark and no menu.
92 if label.org {
93 GBChip("org", .secondary)
94 } else {
95 Menu {
96 ForEach(LabelPalette.palette, id: \.self) { hex in
97 Button(hex) { recolour(String(hex.dropFirst())) }
98 }
99 Button("Clear colour") { recolour("") }
100 Button("Remove", role: .destructive) { remove() }
101 } label: {
102 Image(systemName: "ellipsis.circle")
103 }
104 .disabled(working)
105 }
106 }
107 }
108}