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