gitbay/Views/Issues/IssueListView.swift
149 lines · 5596 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 templates: createModel.templates
90 ) {
91 createModel.format = draftFormat
92 Task {
93 if await createModel.create(title: draftTitle, body: draftBody) != nil {
94 draftTitle = ""
95 draftBody = ""
96 draftFormat = "md"
97 composing = false
98 await model.load()
99 }
100 }
101 }
102 }
103 .task { await model.loadIfNeeded() }
104 .task { await createModel.loadTemplates() }
105 .refreshable { await model.load() }
106 }
107}
108
109private struct IssueRow: View {
110 let issue: Issue
111 let colors: LabelColors
112
113 var body: some View {
114 VStack(alignment: .leading, spacing: 4) {
115 HStack(alignment: .firstTextBaseline, spacing: 6) {
116 Text("#\(issue.number)")
117 .font(.gbMono(.caption))
118 .foregroundStyle(.secondary)
119 Text(issue.title)
120 .font(.gbSans(.subheadline).weight(.medium))
121 .lineLimit(2)
122 }
123 HStack(spacing: 6) {
124 Image(systemName: issue.isOpen ? "circle" : "checkmark.circle.fill")
125 .font(.gbSans(.caption2))
126 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
127 ForEach(issue.labels ?? [], id: \.self) { label in
128 GBChip(label, colors.color(label))
129 }
130 Spacer()
131 if let assignees = issue.assignees, !assignees.isEmpty {
132 Text(assignees.joined(separator: ", "))
133 .font(.gbSans(.caption))
134 .foregroundStyle(.secondary)
135 .lineLimit(1)
136 }
137 Text(issue.createdAt, format: .relative(presentation: .named))
138 .font(.gbSans(.caption))
139 .foregroundStyle(.secondary)
140 }
141 }
142 .padding(.vertical, 2)
143 }
144}
145
146nonisolated enum IssueRoute: Hashable {
147 case list(repo: String)
148 case issue(repo: String, number: Int64)
149}