gitbay/Views/Issues/MilestoneListView.swift

v1.1.1
gitbay-ios/gitbay/Views/Issues/MilestoneListView.swift history · blame · raw

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}