krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2: Hutch/Views/Home/HomeView.swift · raw

  1import SwiftUI
  2
  3struct HomeView: View {
  4    @Environment(AppState.self) private var appState
  5    @State private var viewModel: HomeViewModel?
  6    private let previewLimit = 4
  7    private let projectPreviewLimit = 3
  8
  9    var body: some View {
 10        Group {
 11            if let viewModel {
 12                content(viewModel)
 13            } else {
 14                SRHTLoadingStateView(message: "Loading Home…")
 15            }
 16        }
 17        .navigationTitle("Home")
 18        .toolbar {
 19            ToolbarItem(placement: .topBarTrailing) {
 20                NavigationLink {
 21                    InboxView()
 22                } label: {
 23                    HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true)
 24                }
 25            }
 26        }
 27        .task {
 28            if viewModel == nil, let currentUser = appState.currentUser {
 29                let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
 30                viewModel = vm
 31                await vm.loadDashboard()
 32            }
 33        }
 34    }
 35
 36    @ViewBuilder
 37    private func content(_ viewModel: HomeViewModel) -> some View {
 38        List {
 39            projectsSection(viewModel)
 40            assignedTicketsSection(viewModel)
 41            recentBuildsSection(viewModel)
 42        }
 43        .listStyle(.insetGrouped)
 44        .overlay {
 45            if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
 46                viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
 47                SRHTLoadingStateView(message: "Loading Home…")
 48            } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
 49                        viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
 50                        viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
 51                ContentUnavailableView(
 52                    "All Clear",
 53                    systemImage: "checkmark.circle",
 54                    description: Text("There are no assigned tickets or recent builds right now.")
 55                )
 56            }
 57        }
 58        .refreshable {
 59            await viewModel.loadDashboard()
 60        }
 61    }
 62
 63    @ViewBuilder
 64    private func projectsSection(_ viewModel: HomeViewModel) -> some View {
 65        if !viewModel.projects.isEmpty {
 66            Section {
 67                ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
 68                    NavigationLink {
 69                        ProjectDetailView(project: project)
 70                    } label: {
 71                        HomeProjectRow(project: project)
 72                    }
 73                }
 74            } header: {
 75                HomeSectionHeader("Projects") {
 76                    HomeProjectsListView(viewModel: viewModel)
 77                }
 78            }
 79        }
 80    }
 81
 82    @ViewBuilder
 83    private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
 84        Section {
 85            if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
 86                HomeSectionLoadingRow(label: "Loading assigned tickets")
 87            } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
 88                HomeSectionMessageRow(
 89                    text: "Couldn’t load assigned tickets.",
 90                    systemImage: "exclamationmark.triangle",
 91                    emphasized: true,
 92                    accessibilityHint: error
 93                )
 94            } else if viewModel.assignedTickets.isEmpty {
 95                HomeSectionMessageRow(
 96                    text: "No open tickets assigned to you.",
 97                    systemImage: "person.crop.circle.badge.checkmark"
 98                )
 99            } else {
100                ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
101                    NavigationLink {
102                        TicketDetailView(
103                            ownerUsername: ticket.ownerUsername,
104                            trackerName: ticket.trackerName,
105                            trackerId: ticket.trackerId,
106                            trackerRid: ticket.trackerRid,
107                            ticketId: ticket.ticket.id
108                        )
109                    } label: {
110                        HomeAssignedTicketRow(ticket: ticket)
111                    }
112                }
113            }
114        } header: {
115            HomeSectionHeader("Assigned Tickets") {
116                HomeAssignedTicketsListView(viewModel: viewModel)
117            }
118        }
119    }
120
121    @ViewBuilder
122    private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
123        Section {
124            if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
125                HomeSectionLoadingRow(label: "Loading recent builds")
126            } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
127                HomeSectionMessageRow(
128                    text: "Couldn’t load recent builds.",
129                    systemImage: "exclamationmark.triangle",
130                    emphasized: true,
131                    accessibilityHint: error
132                )
133            } else if viewModel.recentBuilds.isEmpty {
134                HomeSectionMessageRow(
135                    text: "No recent builds.",
136                    systemImage: "clock"
137                )
138            } else {
139                ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
140                    NavigationLink {
141                        BuildDetailView(jobId: build.job.id)
142                    } label: {
143                        HomeBuildRow(build: build)
144                    }
145                }
146            }
147        } header: {
148            HomeSectionActionHeader("Recent Builds") {
149                appState.selectedTab = .builds
150            }
151        }
152    }
153
154}
155
156private struct HomeInboxToolbarIcon: View {
157    let hasUnreadThreads: Bool
158
159    var body: some View {
160        ZStack(alignment: .topTrailing) {
161            Image(systemName: hasUnreadThreads ? "tray.fill" : "tray")
162
163            if hasUnreadThreads {
164                Circle()
165                    .fill(.blue)
166                    .frame(width: 9, height: 9)
167                    .offset(x: 4, y: -2)
168            }
169        }
170        .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox")
171    }
172}
173
174private struct HomeProjectRow: View {
175    let project: Project
176
177    var body: some View {
178        VStack(alignment: .leading, spacing: 4) {
179            Text(project.name)
180                .font(.subheadline.weight(.medium))
181                .lineLimit(1)
182
183            if let description = project.description, !description.isEmpty {
184                Text(description)
185                    .font(.caption)
186                    .foregroundStyle(.secondary)
187                    .lineLimit(1)
188            }
189
190            if let summary = project.resourceSummary {
191                Text(summary)
192                    .font(.caption)
193                    .foregroundStyle(.tertiary)
194                    .lineLimit(1)
195            }
196        }
197        .padding(.vertical, 2)
198    }
199}
200
201private struct HomeProjectsListView: View {
202    let viewModel: HomeViewModel
203
204    var body: some View {
205        List {
206            ForEach(viewModel.projects) { project in
207                NavigationLink {
208                    ProjectDetailView(project: project)
209                } label: {
210                    HomeProjectRow(project: project)
211                }
212            }
213        }
214        .navigationTitle("Projects")
215        .navigationBarTitleDisplayMode(.inline)
216        .refreshable {
217            await viewModel.loadDashboard()
218        }
219        .overlay {
220            if viewModel.isLoadingProjects && viewModel.projects.isEmpty {
221                SRHTLoadingStateView(message: "Loading projects…")
222            }
223        }
224    }
225}
226
227private struct HomeBuildRow: View {
228    let build: HomeBuildItem
229
230    var body: some View {
231        HStack(spacing: 12) {
232            JobStatusIcon(status: build.job.status)
233                .frame(width: 20)
234
235            VStack(alignment: .leading, spacing: 4) {
236                Text(primaryTitle)
237                    .font(.subheadline.weight(.medium))
238                    .lineLimit(1)
239
240                HStack(spacing: 8) {
241                    Text("Job #\(build.job.id)")
242                        .font(.caption)
243                        .foregroundStyle(.secondary)
244
245                    Text("")
246                        .font(.caption)
247                        .foregroundStyle(.tertiary)
248
249                    Text(build.job.status.rawValue.capitalized)
250                        .font(.caption)
251                        .foregroundStyle(.secondary)
252
253                    Text("")
254                        .font(.caption)
255                        .foregroundStyle(.tertiary)
256
257                    Text(build.job.created.relativeDescription)
258                        .font(.caption)
259                        .foregroundStyle(.tertiary)
260
261                    Spacer()
262                }
263            }
264        }
265        .padding(.vertical, 2)
266    }
267
268    private var primaryTitle: String {
269        if let repositoryDisplayName = build.repositoryDisplayName {
270            return repositoryDisplayName
271        }
272        return build.job.displayLabel
273    }
274}
275
276private struct HomeAssignedTicketRow: View {
277    let ticket: HomeAssignedTicket
278
279    var body: some View {
280        HStack(alignment: .top, spacing: 12) {
281            TicketStatusIcon(status: ticket.ticket.status)
282                .frame(width: 20)
283
284            VStack(alignment: .leading, spacing: 4) {
285                Text(ticket.ticket.title)
286                    .font(.subheadline.weight(.medium))
287                    .lineLimit(2)
288
289                Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id)\(ticket.ticket.created.relativeDescription)")
290                    .font(.caption)
291                    .foregroundStyle(.secondary)
292                    .lineLimit(1)
293                    .truncationMode(.tail)
294            }
295
296            Spacer(minLength: 8)
297
298            Text(ticket.ticket.status.displayName)
299                .font(.caption2.weight(.medium))
300                .foregroundStyle(.secondary)
301                .lineLimit(1)
302                .fixedSize()
303        }
304        .padding(.vertical, 2)
305    }
306}
307
308private struct HomeSectionLoadingRow: View {
309    let label: String
310
311    var body: some View {
312        HStack(spacing: 10) {
313            ProgressView()
314                .controlSize(.small)
315            Text(label)
316                .foregroundStyle(.secondary)
317        }
318        .frame(maxWidth: .infinity, alignment: .leading)
319    }
320}
321
322private struct HomeSectionHeader<Destination: View>: View {
323    let title: String
324    let destination: Destination
325
326    init(_ title: String, @ViewBuilder destination: () -> Destination) {
327        self.title = title
328        self.destination = destination()
329    }
330
331    var body: some View {
332        HStack {
333            Text(title)
334            Spacer()
335            NavigationLink {
336                destination
337            } label: {
338                Text("See All")
339                    .font(.caption.weight(.medium))
340            }
341            .buttonStyle(.plain)
342        }
343        .textCase(nil)
344    }
345}
346
347private struct HomeSectionActionHeader: View {
348    let title: String
349    let action: () -> Void
350
351    init(_ title: String, action: @escaping () -> Void) {
352        self.title = title
353        self.action = action
354    }
355
356    var body: some View {
357        HStack {
358            Text(title)
359            Spacer()
360            Button("See All", action: action)
361                .font(.caption.weight(.medium))
362                .buttonStyle(.plain)
363        }
364        .textCase(nil)
365    }
366}
367
368private struct HomeAssignedTicketsListView: View {
369    let viewModel: HomeViewModel
370
371    var body: some View {
372        List {
373            ForEach(viewModel.assignedTickets) { ticket in
374                NavigationLink {
375                    TicketDetailView(
376                        ownerUsername: ticket.ownerUsername,
377                        trackerName: ticket.trackerName,
378                        trackerId: ticket.trackerId,
379                        trackerRid: ticket.trackerRid,
380                        ticketId: ticket.ticket.id
381                    )
382                } label: {
383                    HomeAssignedTicketRow(ticket: ticket)
384                }
385            }
386
387            if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
388                HomeSectionMessageRow(
389                    text: "No open tickets assigned to you.",
390                    systemImage: "person.crop.circle.badge.checkmark"
391                )
392            }
393        }
394        .navigationTitle("Assigned Tickets")
395        .navigationBarTitleDisplayMode(.inline)
396        .refreshable {
397            await viewModel.loadDashboard()
398        }
399        .overlay {
400            if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
401                SRHTLoadingStateView(message: "Loading assigned tickets…")
402            }
403        }
404    }
405}
406
407private struct HomeSectionMessageRow: View {
408    let text: String
409    let systemImage: String
410    var emphasized = false
411    var accessibilityHint: String? = nil
412
413    var body: some View {
414        Label(text, systemImage: systemImage)
415            .font(.subheadline)
416            .foregroundStyle(emphasized ? .secondary : .tertiary)
417            .accessibilityHint(accessibilityHint ?? "")
418    }
419}