krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.3.0: Hutch/Views/Tickets/TrackerListView.swift · raw
1import SwiftUI
2
3struct TrackerListView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: TrackerListViewModel?
6 @State private var showCreateTrackerSheet = false
7 @State private var createdTracker: TrackerSummary?
8
9 var body: some View {
10 Group {
11 if let viewModel {
12 listContent(viewModel)
13 } else {
14 SRHTLoadingStateView(message: "Loading trackers…")
15 }
16 }
17 .navigationTitle("Trackers")
18 .toolbar {
19 if viewModel != nil {
20 ToolbarItem(placement: .topBarTrailing) {
21 Button {
22 showCreateTrackerSheet = true
23 } label: {
24 Image(systemName: "plus")
25 }
26 }
27 }
28 }
29 .sheet(isPresented: $showCreateTrackerSheet) {
30 if let viewModel {
31 CreateTrackerSheet(viewModel: viewModel) { tracker in
32 showCreateTrackerSheet = false
33 createdTracker = tracker
34 }
35 }
36 }
37 .navigationDestination(isPresented: Binding(
38 get: { createdTracker != nil },
39 set: { isPresented in
40 if !isPresented {
41 createdTracker = nil
42 }
43 }
44 )) {
45 if let createdTracker {
46 TicketListView(
47 ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()),
48 trackerName: createdTracker.name,
49 trackerId: createdTracker.id,
50 trackerRid: createdTracker.rid
51 )
52 }
53 }
54 .task {
55 if viewModel == nil {
56 let vm = TrackerListViewModel(client: appState.client)
57 viewModel = vm
58 await vm.loadTrackers()
59 }
60 }
61 }
62
63 @ViewBuilder
64 private func listContent(_ viewModel: TrackerListViewModel) -> some View {
65 @Bindable var vm = viewModel
66
67 List {
68 ForEach(viewModel.filteredTrackers) { tracker in
69 NavigationLink(value: tracker) {
70 TrackerRowView(tracker: tracker)
71 }
72 .task {
73 await viewModel.loadMoreIfNeeded(currentItem: tracker)
74 }
75 }
76
77 if viewModel.isLoadingMore {
78 HStack {
79 Spacer()
80 ProgressView()
81 Spacer()
82 }
83 .listRowSeparator(.hidden)
84 }
85 }
86 .listStyle(.plain)
87 .searchable(
88 text: $vm.searchText,
89 placement: .navigationBarDrawer(displayMode: .always),
90 prompt: "Search trackers"
91 )
92 .overlay {
93 if viewModel.isLoading, viewModel.trackers.isEmpty {
94 SRHTLoadingStateView(message: "Loading trackers…")
95 } else if let error = viewModel.error, viewModel.trackers.isEmpty {
96 SRHTErrorStateView(
97 title: "Couldn't Load Trackers",
98 message: error,
99 retryAction: { await viewModel.loadTrackers() }
100 )
101 } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty {
102 ContentUnavailableView.search(text: viewModel.searchText)
103 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
104 ContentUnavailableView(
105 "No Trackers",
106 systemImage: "checklist",
107 description: Text("Your bug trackers will appear here.")
108 )
109 }
110 }
111 .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
112 await viewModel.loadTrackers()
113 }
114 .srhtErrorBanner(error: $vm.error)
115 .refreshable {
116 await viewModel.loadTrackers()
117 }
118 .navigationDestination(for: TrackerSummary.self) { tracker in
119 TicketListView(
120 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
121 trackerName: tracker.name,
122 trackerId: tracker.id,
123 trackerRid: tracker.rid
124 )
125 }
126 }
127}
128
129private struct CreateTrackerSheet: View {
130 let viewModel: TrackerListViewModel
131 let onCreated: (TrackerSummary) -> Void
132
133 @Environment(\.dismiss) private var dismiss
134 @Bindable var viewModelBindable: TrackerListViewModel
135 @State private var name = ""
136 @State private var description = ""
137 @State private var visibility: Visibility = .public
138
139 init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) {
140 self.viewModel = viewModel
141 self._viewModelBindable = Bindable(viewModel)
142 self.onCreated = onCreated
143 }
144
145 var body: some View {
146 NavigationStack {
147 Form {
148 Section("Tracker Details") {
149 TextField("Tracker name", text: $name)
150 .textInputAutocapitalization(.never)
151 .autocorrectionDisabled()
152 TextField("Short description (optional)", text: $description, axis: .vertical)
153 .lineLimit(2...4)
154 Picker("Visibility", selection: $visibility) {
155 Text("Public").tag(Visibility.public)
156 Text("Unlisted").tag(Visibility.unlisted)
157 Text("Private").tag(Visibility.private)
158 }
159 }
160
161 if let error = viewModel.error {
162 Section {
163 Label {
164 Text(error)
165 } icon: {
166 Image(systemName: "exclamationmark.triangle.fill")
167 .foregroundStyle(.red)
168 }
169 .foregroundStyle(.red)
170 }
171 }
172 }
173 .navigationTitle("New Tracker")
174 .navigationBarTitleDisplayMode(.inline)
175 .onDisappear {
176 viewModelBindable.error = nil
177 }
178 .toolbar {
179 ToolbarItem(placement: .cancellationAction) {
180 Button("Cancel") {
181 viewModelBindable.error = nil
182 dismiss()
183 }
184 }
185 ToolbarItem(placement: .confirmationAction) {
186 Button {
187 Task {
188 if let tracker = await viewModel.createTracker(
189 name: name,
190 description: description,
191 visibility: visibility
192 ) {
193 onCreated(tracker)
194 }
195 }
196 } label: {
197 if viewModel.isCreatingTracker {
198 ProgressView()
199 .controlSize(.small)
200 } else {
201 Text("Create Tracker")
202 }
203 }
204 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
205 }
206 }
207 }
208 }
209}
210
211// MARK: - Tracker Row
212
213private struct TrackerRowView: View {
214 let tracker: TrackerSummary
215
216 var body: some View {
217 VStack(alignment: .leading, spacing: 4) {
218 HStack {
219 Text(tracker.name)
220 .font(.subheadline.weight(.medium))
221
222 Spacer()
223
224 VisibilityBadge(visibility: tracker.visibility)
225 }
226
227 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
228 Text("~\(owner)")
229 .font(.caption)
230 .foregroundStyle(.secondary)
231 }
232
233 if let description = tracker.description, !description.isEmpty {
234 Text(description)
235 .font(.caption)
236 .foregroundStyle(.secondary)
237 .lineLimit(2)
238 }
239
240 Text(tracker.updated.relativeDescription)
241 .font(.caption2)
242 .foregroundStyle(.tertiary)
243 }
244 .padding(.vertical, 2)
245 }
246}