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