gitbay/Views/Shared/FilterSheet.swift

v1.1.1
gitbay-ios/gitbay/Views/Shared/FilterSheet.swift history · blame · raw

115 lines · 4021 bytes

  1import SwiftUI
  2
  3/// The issue and MR lists' filter sheet. A `Form` with the fields both
  4/// lists share — author and milestone — plus an `extra` slot for the
  5/// rows only issues have, since `mr list` takes neither `--label` nor
  6/// `--assignee`.
  7struct FilterSheet<Extra: View>: View {
  8
  9    let title: String
 10    @Binding var author: String?
 11    @Binding var milestone: String?
 12    let milestones: [Milestone]
 13    let onClear: () -> Void
 14    @ViewBuilder var extra: Extra
 15
 16    @Environment(\.dismiss) private var dismiss
 17
 18    var body: some View {
 19        NavigationStack {
 20            Form {
 21                extra
 22                Section("Author") {
 23                    TextField("Username", text: $author.orEmpty)
 24                        .autocorrectionDisabled()
 25                        .textInputAutocapitalization(.never)
 26                        .accessibilityIdentifier("filter-author")
 27                }
 28                Section("Milestone") {
 29                    Menu {
 30                        Button("Any milestone") { milestone = nil }
 31                        Button("No milestone") { milestone = "none" }
 32                        ForEach(milestones) { entry in
 33                            Button(entry.title) { milestone = entry.title }
 34                        }
 35                    } label: {
 36                        HStack {
 37                            Text(milestone ?? "Any milestone")
 38                            Spacer()
 39                            Image(systemName: "chevron.up.chevron.down")
 40                                .font(.gbSans(.caption2))
 41                                .foregroundStyle(.secondary)
 42                        }
 43                    }
 44                    .accessibilityIdentifier("filter-milestone-menu")
 45                }
 46                Section {
 47                    Button("Clear filters", role: .destructive, action: onClear)
 48                }
 49            }
 50            .navigationTitle(title)
 51            .navigationBarTitleDisplayMode(.inline)
 52            .toolbar {
 53                ToolbarItem(placement: .confirmationAction) {
 54                    Button("Done") { dismiss() }
 55                }
 56            }
 57        }
 58    }
 59}
 60
 61/// The label and assignee rows the issue list's sheet adds through
 62/// `FilterSheet`'s `extra` slot.
 63struct IssueFilterExtra: View {
 64
 65    @Binding var label: String?
 66    @Binding var assignee: String?
 67    let labels: [IssueLabel]
 68    let colors: LabelColors
 69
 70    var body: some View {
 71        Section("Label") {
 72            Menu {
 73                Button("Any label") { label = nil }
 74                ForEach(labels) { entry in
 75                    Button {
 76                        label = entry.name
 77                    } label: {
 78                        Text(entry.name)
 79                    }
 80                }
 81            } label: {
 82                HStack {
 83                    if let label {
 84                        GBChip(label, colors.color(label))
 85                    } else {
 86                        Text("Any label")
 87                    }
 88                    Spacer()
 89                    Image(systemName: "chevron.up.chevron.down")
 90                        .font(.gbSans(.caption2))
 91                        .foregroundStyle(.secondary)
 92                }
 93            }
 94            .accessibilityIdentifier("filter-label-menu")
 95        }
 96        Section("Assignee") {
 97            TextField("Username", text: $assignee.orEmpty)
 98                .autocorrectionDisabled()
 99                .textInputAutocapitalization(.never)
100                .accessibilityIdentifier("filter-assignee")
101        }
102    }
103}
104
105extension Binding where Value == String? {
106    /// Bridges an optional filter field to a plain `TextField`: empty
107    /// text writes back `nil` rather than `""`, since the two are
108    /// distinct filter values (no flag sent versus `--flag ""`).
109    var orEmpty: Binding<String> {
110        Binding<String>(
111            get: { self.wrappedValue ?? "" },
112            set: { self.wrappedValue = $0.isEmpty ? nil : $0 }
113        )
114    }
115}