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