gitbay/Views/Issues/IssueListView.swift

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

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