gitbay/Views/Issues/IssueListView.swift

v1.0.6
gitbay-ios/gitbay/Views/Issues/IssueListView.swift history · blame · raw

147 lines · 5486 bytes

  1import SwiftUI
  2
  3struct IssueListView: View {
  4
  5    @State private var model: IssueListViewModel
  6    @State private var createModel: IssueCreateViewModel
  7    @State private var composing = false
  8    @State private var filtering = false
  9    @State private var draftTitle = ""
 10    @State private var draftBody = ""
 11    @State private var draftFormat = "md"
 12
 13    init(client: GitbayClient, repo: String) {
 14        _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo))
 15        _createModel = State(initialValue: IssueCreateViewModel(client: client, repoPath: repo))
 16    }
 17
 18    var body: some View {
 19        List {
 20            Picker("State", selection: Bindable(model).filter.state) {
 21                ForEach(IssueFilter.State.allCases) { filter in
 22                    Text(filter.label).tag(filter)
 23                }
 24            }
 25            .pickerStyle(.segmented)
 26            .listRowBackground(Color.clear)
 27            .listRowInsets(EdgeInsets())
 28
 29            ForEach(model.state.value ?? []) { issue in
 30                NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) {
 31                    IssueRow(issue: issue, colors: model.colors)
 32                }
 33            }
 34            PageFooter(list: model.list)
 35        }
 36        .overlay { LoadStateOverlay(state: model.state) }
 37        .searchable(text: Bindable(model).filter.search, prompt: "Search titles and bodies")
 38        .navigationTitle("Issues")
 39        .navigationBarTitleDisplayMode(.inline)
 40        .toolbar {
 41            ToolbarItem(placement: .topBarTrailing) {
 42                Button {
 43                    filtering = true
 44                } label: {
 45                    Image(systemName: model.filter.isNarrowed
 46                        ? "line.3.horizontal.decrease.circle.fill"
 47                        : "line.3.horizontal.decrease.circle")
 48                }
 49                .accessibilityIdentifier("issue-filter-button")
 50            }
 51            ToolbarItem(placement: .topBarTrailing) {
 52                Button {
 53                    composing = true
 54                } label: {
 55                    Image(systemName: "plus")
 56                }
 57                .accessibilityIdentifier("issue-create-button")
 58            }
 59        }
 60        .sheet(isPresented: $filtering) {
 61            FilterSheet(
 62                title: "Filter Issues",
 63                author: Bindable(model).filter.author,
 64                milestone: Bindable(model).filter.milestone,
 65                milestones: model.milestones,
 66                onClear: {
 67                    let state = model.filter.state
 68                    model.filter = IssueFilter(state: state)
 69                }
 70            ) {
 71                IssueFilterExtra(
 72                    label: Bindable(model).filter.label,
 73                    assignee: Bindable(model).filter.assignee,
 74                    labels: model.labels,
 75                    colors: model.colors
 76                )
 77            }
 78            .task { await model.loadMilestones() }
 79        }
 80        .sheet(isPresented: $composing) {
 81            ComposeSheet(
 82                heading: "New Issue",
 83                submitLabel: "Create",
 84                working: createModel.working,
 85                errorMessage: createModel.errorMessage,
 86                title: $draftTitle,
 87                bodyText: $draftBody,
 88                format: $draftFormat
 89            ) {
 90                createModel.format = draftFormat
 91                Task {
 92                    if await createModel.create(title: draftTitle, body: draftBody) != nil {
 93                        draftTitle = ""
 94                        draftBody = ""
 95                        draftFormat = "md"
 96                        composing = false
 97                        await model.load()
 98                    }
 99                }
100            }
101        }
102        .task { await model.load() }
103        .refreshable { await model.load() }
104    }
105}
106
107private struct IssueRow: View {
108    let issue: Issue
109    let colors: LabelColors
110
111    var body: some View {
112        VStack(alignment: .leading, spacing: 4) {
113            HStack(alignment: .firstTextBaseline, spacing: 6) {
114                Text("#\(issue.number)")
115                    .font(.gbMono(.caption))
116                    .foregroundStyle(.secondary)
117                Text(issue.title)
118                    .font(.gbSans(.subheadline).weight(.medium))
119                    .lineLimit(2)
120            }
121            HStack(spacing: 6) {
122                Image(systemName: issue.isOpen ? "circle" : "checkmark.circle.fill")
123                    .font(.gbSans(.caption2))
124                    .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
125                ForEach(issue.labels ?? [], id: \.self) { label in
126                    GBChip(label, colors.color(label))
127                }
128                Spacer()
129                if let assignees = issue.assignees, !assignees.isEmpty {
130                    Text(assignees.joined(separator: ", "))
131                        .font(.gbSans(.caption))
132                        .foregroundStyle(.secondary)
133                        .lineLimit(1)
134                }
135                Text(issue.createdAt, format: .relative(presentation: .named))
136                    .font(.gbSans(.caption))
137                    .foregroundStyle(.secondary)
138            }
139        }
140        .padding(.vertical, 2)
141    }
142}
143
144nonisolated enum IssueRoute: Hashable {
145    case list(repo: String)
146    case issue(repo: String, number: Int64)
147}