krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.1: 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.trackers) { 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 .overlay {
88 if viewModel.isLoading, viewModel.trackers.isEmpty {
89 SRHTLoadingStateView(message: "Loading trackers…")
90 } else if let error = viewModel.error, viewModel.trackers.isEmpty {
91 SRHTErrorStateView(
92 title: "Couldn't Load Trackers",
93 message: error,
94 retryAction: { await viewModel.loadTrackers() }
95 )
96 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
97 ContentUnavailableView(
98 "No Trackers",
99 systemImage: "checklist",
100 description: Text("Your bug trackers will appear here.")
101 )
102 }
103 }
104 .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
105 await viewModel.loadTrackers()
106 }
107 .srhtErrorBanner(error: $vm.error)
108 .refreshable {
109 await viewModel.loadTrackers()
110 }
111 .navigationDestination(for: TrackerSummary.self) { tracker in
112 TicketListView(
113 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
114 trackerName: tracker.name,
115 trackerId: tracker.id,
116 trackerRid: tracker.rid
117 )
118 }
119 }
120}
121
122private struct CreateTrackerSheet: View {
123 let viewModel: TrackerListViewModel
124 let onCreated: (TrackerSummary) -> Void
125
126 @Environment(\.dismiss) private var dismiss
127 @Bindable var viewModelBindable: TrackerListViewModel
128 @State private var name = ""
129 @State private var description = ""
130 @State private var visibility: Visibility = .public
131
132 init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) {
133 self.viewModel = viewModel
134 self._viewModelBindable = Bindable(viewModel)
135 self.onCreated = onCreated
136 }
137
138 var body: some View {
139 NavigationStack {
140 Form {
141 Section("Tracker Details") {
142 TextField("Tracker name", text: $name)
143 .textInputAutocapitalization(.never)
144 .autocorrectionDisabled()
145 TextField("Short description (optional)", text: $description, axis: .vertical)
146 .lineLimit(2...4)
147 Picker("Visibility", selection: $visibility) {
148 Text("Public").tag(Visibility.public)
149 Text("Unlisted").tag(Visibility.unlisted)
150 Text("Private").tag(Visibility.private)
151 }
152 }
153
154 if let error = viewModel.error {
155 Section {
156 Label {
157 Text(error)
158 } icon: {
159 Image(systemName: "exclamationmark.triangle.fill")
160 .foregroundStyle(.red)
161 }
162 .foregroundStyle(.red)
163 }
164 }
165 }
166 .navigationTitle("New Tracker")
167 .navigationBarTitleDisplayMode(.inline)
168 .onDisappear {
169 viewModelBindable.error = nil
170 }
171 .toolbar {
172 ToolbarItem(placement: .cancellationAction) {
173 Button("Cancel") {
174 viewModelBindable.error = nil
175 dismiss()
176 }
177 }
178 ToolbarItem(placement: .confirmationAction) {
179 Button {
180 Task {
181 if let tracker = await viewModel.createTracker(
182 name: name,
183 description: description,
184 visibility: visibility
185 ) {
186 onCreated(tracker)
187 }
188 }
189 } label: {
190 if viewModel.isCreatingTracker {
191 ProgressView()
192 .controlSize(.small)
193 } else {
194 Text("Create Tracker")
195 }
196 }
197 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
198 }
199 }
200 }
201 }
202}
203
204// MARK: - Tracker Row
205
206private struct TrackerRowView: View {
207 let tracker: TrackerSummary
208
209 var body: some View {
210 VStack(alignment: .leading, spacing: 4) {
211 HStack {
212 Text(tracker.name)
213 .font(.subheadline.weight(.medium))
214
215 Spacer()
216
217 VisibilityBadge(visibility: tracker.visibility)
218 }
219
220 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
221 Text("~\(owner)")
222 .font(.caption)
223 .foregroundStyle(.secondary)
224 }
225
226 if let description = tracker.description, !description.isEmpty {
227 Text(description)
228 .font(.caption)
229 .foregroundStyle(.secondary)
230 .lineLimit(2)
231 }
232
233 Text(tracker.updated.relativeDescription)
234 .font(.caption2)
235 .foregroundStyle(.tertiary)
236 }
237 .padding(.vertical, 2)
238 }
239}