| @@ -0,0 +1,102 @@ |
| |
1 | import SwiftUI |
| |
2 | |
| |
3 | /// A repository's labels: their colour, how many issues carry each, and |
| |
4 | /// the controls to create, recolour and remove them. |
| |
5 | struct 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 | |
| |
76 | private 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 | Menu { |
| |
91 | ForEach(LabelPalette.palette, id: \.self) { hex in |
| |
92 | Button(hex) { recolour(String(hex.dropFirst())) } |
| |
93 | } |
| |
94 | Button("Clear colour") { recolour("") } |
| |
95 | Button("Remove", role: .destructive) { remove() } |
| |
96 | } label: { |
| |
97 | Image(systemName: "ellipsis.circle") |
| |
98 | } |
| |
99 | .disabled(working) |
| |
100 | } |
| |
101 | } |
| |
102 | } |