import SwiftUI /// A repository's or an org's milestones and their progress. struct MilestoneListView: View { @State private var model: MilestoneListViewModel @State private var creating = false init(client: GitbayClient, repo: String) { self.init(client: client, owner: .repo(repo)) } init(client: GitbayClient, owner: LabelOwner) { _model = State(initialValue: MilestoneListViewModel(client: client, owner: owner)) } var body: some View { List { Picker("State", selection: Bindable(model).filter) { ForEach(MilestoneListViewModel.StateFilter.allCases) { filter in Text(filter.rawValue.capitalized).tag(filter) } } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } ForEach(model.state.value ?? []) { milestone in MilestoneRow(milestone: milestone) .swipeActions { // An org milestone is closed and reopened with // `org milestone`; the repository's commands // refuse it. The org's own screen can. if milestone.org && !model.owner.isOrg { EmptyView() } else if milestone.state == "open" { Button("Close") { Task { await model.close(milestone.title) } } } else { Button("Reopen") { Task { await model.reopen(milestone.title) } } } } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Milestones") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { creating = true } label: { Image(systemName: "plus") } .disabled(model.working) .accessibilityIdentifier("milestone-create-button") } } .sheet(isPresented: $creating) { MilestoneCreateSheet(model: model) { creating = false } } .task { await model.load() } .refreshable { await model.load() } } } /// `milestone create` — title, optional description, optional due date /// typed the way the command takes it. private struct MilestoneCreateSheet: View { let model: MilestoneListViewModel let onCreated: () -> Void @State private var title = "" @State private var description = "" @State private var due = "" var body: some View { NavigationStack { Form { TextField("Title", text: $title) .autocorrectionDisabled() .accessibilityIdentifier("milestone-title") TextField("Description", text: $description, axis: .vertical) .lineLimit(1...4) .autocorrectionDisabled() TextField("Due (YYYY-MM-DD)", text: $due) .keyboardType(.numbersAndPunctuation) .autocorrectionDisabled() if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } } .navigationTitle("New Milestone") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { onCreated() } .disabled(model.working) } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Create") { Task { if await model.create(title: title, description: description, due: due) { onCreated() } } } .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("milestone-submit") } } } } .interactiveDismissDisabled(model.working) } } private struct MilestoneRow: View { let milestone: Milestone private var total: Int { milestone.open + milestone.closed } private var fraction: Double { total == 0 ? 0 : Double(milestone.closed) / Double(total) } var body: some View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Text(milestone.title) .font(.gbSans(.subheadline).weight(.medium)) if milestone.state != "open" { GBChip(milestone.state, .gbDone) } if milestone.org { GBChip("org", .secondary) } Spacer() if let due = milestone.due, !due.isEmpty { Text(due) .font(.gbSans(.caption)) .foregroundStyle(.tertiary) } } if let description = milestone.description, !description.isEmpty { Text(description) .font(.gbSans(.caption)) .foregroundStyle(.secondary) .lineLimit(2) } if total > 0 { ProgressView(value: fraction) .tint(.gbAccent) Text("\(milestone.closed) of \(total) closed") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } .padding(.vertical, 2) } }