Hutch/Views/Tickets/TrackerListView.swift
214 lines · 7364 bytes
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 @State private var name = ""
128 @State private var description = ""
129 @State private var visibility: Visibility = .public
130
131 var body: some View {
132 NavigationStack {
133 Form {
134 Section("Tracker Details") {
135 TextField("Tracker name", text: $name)
136 .textInputAutocapitalization(.never)
137 .autocorrectionDisabled()
138 TextField("Short description (optional)", text: $description, axis: .vertical)
139 .lineLimit(2...4)
140 Picker("Visibility", selection: $visibility) {
141 Text("Public").tag(Visibility.public)
142 Text("Unlisted").tag(Visibility.unlisted)
143 Text("Private").tag(Visibility.private)
144 }
145 }
146 }
147 .navigationTitle("New Tracker")
148 .navigationBarTitleDisplayMode(.inline)
149 .toolbar {
150 ToolbarItem(placement: .cancellationAction) {
151 Button("Cancel") { dismiss() }
152 }
153 ToolbarItem(placement: .confirmationAction) {
154 Button {
155 Task {
156 if let tracker = await viewModel.createTracker(
157 name: name,
158 description: description,
159 visibility: visibility
160 ) {
161 onCreated(tracker)
162 }
163 }
164 } label: {
165 if viewModel.isCreatingTracker {
166 ProgressView()
167 .controlSize(.small)
168 } else {
169 Text("Create Tracker")
170 }
171 }
172 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
173 }
174 }
175 }
176 }
177}
178
179// MARK: - Tracker Row
180
181private struct TrackerRowView: View {
182 let tracker: TrackerSummary
183
184 var body: some View {
185 VStack(alignment: .leading, spacing: 4) {
186 HStack {
187 Text(tracker.name)
188 .font(.subheadline.weight(.medium))
189
190 Spacer()
191
192 VisibilityBadge(visibility: tracker.visibility)
193 }
194
195 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
196 Text("~\(owner)")
197 .font(.caption)
198 .foregroundStyle(.secondary)
199 }
200
201 if let description = tracker.description, !description.isEmpty {
202 Text(description)
203 .font(.caption)
204 .foregroundStyle(.secondary)
205 .lineLimit(2)
206 }
207
208 Text(tracker.updated.relativeDescription)
209 .font(.caption2)
210 .foregroundStyle(.tertiary)
211 }
212 .padding(.vertical, 2)
213 }
214}