gitbay/Views/Issues/MilestoneListView.swift
177 lines · 6433 bytes
1import SwiftUI
2
3/// A repository's or an org's milestones and their progress.
4struct MilestoneListView: View {
5
6 @State private var model: MilestoneListViewModel
7 @State private var creating = false
8
9 init(client: GitbayClient, repo: String) {
10 self.init(client: client, owner: .repo(repo))
11 }
12
13 init(client: GitbayClient, owner: LabelOwner) {
14 _model = State(initialValue: MilestoneListViewModel(client: client, owner: owner))
15 }
16
17 var body: some View {
18 List {
19 Section {
20 Picker("State", selection: Bindable(model).filter) {
21 ForEach(MilestoneListViewModel.StateFilter.allCases) { filter in
22 Text(filter.rawValue.capitalized).tag(filter)
23 }
24 }
25 .pickerStyle(.segmented)
26 .listRowBackground(Color.clear)
27 .listRowInsets(EdgeInsets())
28 }
29
30 if let error = model.actionError {
31 Section {
32 GBNotice(error, .gbWarn)
33 }
34 }
35
36 ForEach(model.state.value ?? []) { milestone in
37 MilestoneRow(milestone: milestone)
38 .swipeActions {
39 // An org milestone is closed and reopened with
40 // `org milestone`; the repository's commands
41 // refuse it. The org's own screen can.
42 if milestone.org && !model.owner.isOrg {
43 EmptyView()
44 } else if milestone.state == "open" {
45 Button("Close") {
46 Task { await model.close(milestone.title) }
47 }
48 } else {
49 Button("Reopen") {
50 Task { await model.reopen(milestone.title) }
51 }
52 }
53 }
54 }
55 }
56 .overlay { LoadStateOverlay(state: model.state) }
57 .navigationTitle("Milestones")
58 .navigationBarTitleDisplayMode(.inline)
59 .toolbar {
60 ToolbarItem(placement: .topBarTrailing) {
61 Button {
62 creating = true
63 } label: {
64 Image(systemName: "plus")
65 }
66 .accessibilityLabel("New Milestone")
67 .disabled(model.working)
68 .accessibilityIdentifier("milestone-create-button")
69 }
70 }
71 .sheet(isPresented: $creating) {
72 MilestoneCreateSheet(model: model) { creating = false }
73 }
74 .task { await model.load() }
75 .refreshable { await model.load() }
76 }
77}
78
79/// `milestone create` — title, optional description, optional due date
80/// typed the way the command takes it.
81private struct MilestoneCreateSheet: View {
82 let model: MilestoneListViewModel
83 let onCreated: () -> Void
84
85 @State private var title = ""
86 @State private var description = ""
87 @State private var due = ""
88
89 var body: some View {
90 NavigationStack {
91 Form {
92 TextField("Title", text: $title)
93 .autocorrectionDisabled()
94 .accessibilityIdentifier("milestone-title")
95 TextField("Description", text: $description, axis: .vertical)
96 .lineLimit(1...4)
97 .autocorrectionDisabled()
98 TextField("Due (YYYY-MM-DD)", text: $due)
99 .keyboardType(.numbersAndPunctuation)
100 .autocorrectionDisabled()
101 if let error = model.actionError {
102 Section {
103 GBNotice(error, .gbWarn)
104 }
105 }
106 }
107 .navigationTitle("New Milestone")
108 .navigationBarTitleDisplayMode(.inline)
109 .toolbar {
110 ToolbarItem(placement: .cancellationAction) {
111 Button("Cancel") { onCreated() }
112 .disabled(model.working)
113 }
114 ToolbarItem(placement: .confirmationAction) {
115 if model.working {
116 ProgressView()
117 } else {
118 Button("Create") {
119 Task {
120 if await model.create(title: title, description: description, due: due) {
121 onCreated()
122 }
123 }
124 }
125 .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
126 .accessibilityIdentifier("milestone-submit")
127 }
128 }
129 }
130 }
131 .interactiveDismissDisabled(model.working)
132 }
133}
134
135private struct MilestoneRow: View {
136 let milestone: Milestone
137
138 private var total: Int { milestone.open + milestone.closed }
139 private var fraction: Double {
140 total == 0 ? 0 : Double(milestone.closed) / Double(total)
141 }
142
143 var body: some View {
144 VStack(alignment: .leading, spacing: 5) {
145 HStack(spacing: 6) {
146 Text(milestone.title)
147 .font(.gbSans(.subheadline).weight(.medium))
148 if milestone.state != "open" {
149 GBChip(milestone.state, .gbDone)
150 }
151 if milestone.org {
152 GBChip("org", .secondary)
153 }
154 Spacer()
155 if let due = milestone.due, !due.isEmpty {
156 Text(due)
157 .font(.gbSans(.caption))
158 .foregroundStyle(.tertiary)
159 }
160 }
161 if let description = milestone.description, !description.isEmpty {
162 Text(description)
163 .font(.gbSans(.caption))
164 .foregroundStyle(.secondary)
165 .lineLimit(2)
166 }
167 if total > 0 {
168 ProgressView(value: fraction)
169 .tint(.gbAccent)
170 Text("\(milestone.closed) of \(total) closed")
171 .font(.gbSans(.caption2))
172 .foregroundStyle(.secondary)
173 }
174 }
175 .padding(.vertical, 2)
176 }
177}