gitbay/Views/Issues/MilestoneListView.swift

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

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