gitbay/Views/Issues/IssueListView.swift
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}