Hutch/Views/Tickets/TrackerListView.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/Views/Tickets/TrackerListView.swift history · blame · raw

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}