summaryrefslogtreecommitdiff
path: root/Hutch/Views/Home/HomeView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-18 22:56:34 -0500
committerChristian Cleberg <[email protected]>2026-03-18 22:56:34 -0500
commitffc0ef0988b5e7d01951c23af85a1cf026b3da9f (patch)
tree868cef24e91ddaa5e7d5e39362d97ec6a94107c9 /Hutch/Views/Home/HomeView.swift
parented48e72520c51e4635cd3840dbc569bdd74c950f (diff)
downloadhutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.tar.gz
hutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.tar.bz2
hutch-ffc0ef0988b5e7d01951c23af85a1cf026b3da9f.zip
add Home dashboard and repository build status indicators
Diffstat (limited to 'Hutch/Views/Home/HomeView.swift')
-rw-r--r--Hutch/Views/Home/HomeView.swift297
1 files changed, 297 insertions, 0 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
new file mode 100644
index 0000000..a72853f
--- /dev/null
+++ b/Hutch/Views/Home/HomeView.swift
@@ -0,0 +1,297 @@
+import SwiftUI
+
+struct HomeView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: HomeViewModel?
+ private let previewLimit = 4
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading Home…")
+ }
+ }
+ .navigationTitle("Home")
+ .task {
+ if viewModel == nil, let currentUser = appState.currentUser {
+ let vm = HomeViewModel(currentUser: currentUser, client: appState.client)
+ viewModel = vm
+ await vm.loadDashboard()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: HomeViewModel) -> some View {
+ List {
+ assignedTicketsSection(viewModel)
+ recentBuildsSection(viewModel)
+ }
+ .listStyle(.insetGrouped)
+ .overlay {
+ if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
+ viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
+ SRHTLoadingStateView(message: "Loading Home…")
+ } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
+ viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
+ viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
+ ContentUnavailableView(
+ "All Clear",
+ systemImage: "checkmark.circle",
+ description: Text("There are no assigned tickets or recent builds right now.")
+ )
+ }
+ }
+ .refreshable {
+ await viewModel.loadDashboard()
+ }
+ }
+
+ @ViewBuilder
+ private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ HomeSectionLoadingRow(label: "Loading assigned tickets")
+ } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "Couldn’t load assigned tickets.",
+ systemImage: "exclamationmark.triangle",
+ emphasized: true,
+ accessibilityHint: error
+ )
+ } else if viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "No open tickets assigned to you.",
+ systemImage: "person.crop.circle.badge.checkmark"
+ )
+ } else {
+ ForEach(viewModel.assignedTickets.prefix(previewLimit)) { ticket in
+ NavigationLink {
+ TicketDetailView(
+ ownerUsername: ticket.ownerUsername,
+ trackerName: ticket.trackerName,
+ trackerId: ticket.trackerId,
+ trackerRid: ticket.trackerRid,
+ ticketId: ticket.ticket.id
+ )
+ } label: {
+ HomeAssignedTicketRow(ticket: ticket)
+ }
+ }
+ }
+ } header: {
+ HomeSectionHeader("Assigned Tickets") {
+ HomeAssignedTicketsListView(viewModel: viewModel)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
+ HomeSectionLoadingRow(label: "Loading recent builds")
+ } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
+ HomeSectionMessageRow(
+ text: "Couldn’t load recent builds.",
+ systemImage: "exclamationmark.triangle",
+ emphasized: true,
+ accessibilityHint: error
+ )
+ } else if viewModel.recentBuilds.isEmpty {
+ HomeSectionMessageRow(
+ text: "No recent builds.",
+ systemImage: "clock"
+ )
+ } else {
+ ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
+ NavigationLink {
+ BuildDetailView(jobId: build.job.id)
+ } label: {
+ HomeBuildRow(build: build)
+ }
+ }
+ }
+ } header: {
+ HomeSectionHeader("Recent Builds") {
+ BuildListView()
+ }
+ }
+ }
+
+}
+
+private struct HomeBuildRow: View {
+ let build: HomeBuildItem
+
+ var body: some View {
+ HStack(spacing: 12) {
+ JobStatusIcon(status: build.job.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(primaryTitle)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ Text("Job #\(build.job.id)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("•")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Text(build.job.status.rawValue.capitalized)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("•")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Text(build.job.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+
+ Spacer()
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ private var primaryTitle: String {
+ if let repositoryDisplayName = build.repositoryDisplayName {
+ return repositoryDisplayName
+ }
+ return build.job.displayLabel
+ }
+}
+
+private struct HomeAssignedTicketRow: View {
+ let ticket: HomeAssignedTicket
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ TicketStatusIcon(status: ticket.ticket.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(ticket.ticket.title)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+
+ Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+
+ Spacer(minLength: 8)
+
+ Text(ticket.ticket.status.displayName)
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .fixedSize()
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct HomeSectionLoadingRow: View {
+ let label: String
+
+ var body: some View {
+ HStack(spacing: 10) {
+ ProgressView()
+ .controlSize(.small)
+ Text(label)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+private struct HomeSectionHeader<Destination: View>: View {
+ let title: String
+ let destination: Destination
+
+ init(_ title: String, @ViewBuilder destination: () -> Destination) {
+ self.title = title
+ self.destination = destination()
+ }
+
+ var body: some View {
+ HStack {
+ Text(title)
+ Spacer()
+ NavigationLink {
+ destination
+ } label: {
+ Text("See All")
+ .font(.caption.weight(.medium))
+ }
+ .buttonStyle(.plain)
+ }
+ .textCase(nil)
+ }
+}
+
+private struct HomeAssignedTicketsListView: View {
+ let viewModel: HomeViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.assignedTickets) { ticket in
+ NavigationLink {
+ TicketDetailView(
+ ownerUsername: ticket.ownerUsername,
+ trackerName: ticket.trackerName,
+ trackerId: ticket.trackerId,
+ trackerRid: ticket.trackerRid,
+ ticketId: ticket.ticket.id
+ )
+ } label: {
+ HomeAssignedTicketRow(ticket: ticket)
+ }
+ }
+
+ if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ HomeSectionMessageRow(
+ text: "No open tickets assigned to you.",
+ systemImage: "person.crop.circle.badge.checkmark"
+ )
+ }
+ }
+ .navigationTitle("Assigned Tickets")
+ .navigationBarTitleDisplayMode(.inline)
+ .refreshable {
+ await viewModel.loadDashboard()
+ }
+ .overlay {
+ if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
+ SRHTLoadingStateView(message: "Loading assigned tickets…")
+ }
+ }
+ }
+}
+
+private struct HomeSectionMessageRow: View {
+ let text: String
+ let systemImage: String
+ var emphasized = false
+ var accessibilityHint: String? = nil
+
+ var body: some View {
+ Label(text, systemImage: systemImage)
+ .font(.subheadline)
+ .foregroundStyle(emphasized ? .secondary : .tertiary)
+ .accessibilityHint(accessibilityHint ?? "")
+ }
+}