gitbay/Views/Issues/MilestoneListView.swift

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

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}