gitbay/Views/Issues/MilestoneListView.swift

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

177 lines · 6433 bytes

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