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