summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 18:53:13 -0500
committerChristian Cleberg <[email protected]>2026-04-12 18:53:13 -0500
commitb526cddd8ec421eba00feecbdffae3978cb61b34 (patch)
treef8426032d07a92d034975e90decd1ec0f85bd452 /Hutch/Views
parent92fb89cd35588ee36190c95b1af1e9595b397fae (diff)
downloadhutch-b526cddd8ec421eba00feecbdffae3978cb61b34.tar.gz
hutch-b526cddd8ec421eba00feecbdffae3978cb61b34.tar.bz2
hutch-b526cddd8ec421eba00feecbdffae3978cb61b34.zip
feat: improve Home structure and navigationv2.16.0
implements: https://todo.sr.ht/~ccleberg/hutch/14 implements: https://todo.sr.ht/~ccleberg/hutch/15 implements: https://todo.sr.ht/~ccleberg/hutch/16
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Builds/BuildRowView.swift38
-rw-r--r--Hutch/Views/Home/HomeSectionView.swift63
-rw-r--r--Hutch/Views/Home/HomeView.swift207
3 files changed, 248 insertions, 60 deletions
diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift
index e2afd27..9602e5c 100644
--- a/Hutch/Views/Builds/BuildRowView.swift
+++ b/Hutch/Views/Builds/BuildRowView.swift
@@ -36,6 +36,44 @@ struct BuildRowView: View {
}
}
+struct JobStatusBadge: View {
+ let status: JobStatus
+
+ var body: some View {
+ HStack(spacing: 4) {
+ JobStatusIcon(status: status)
+ .frame(width: 12, height: 12)
+ Text(status.displayTitle)
+ .font(.caption2.weight(.medium))
+ }
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color(.secondarySystemFill), in: Capsule())
+ }
+}
+
+extension JobStatus {
+ var displayTitle: String {
+ switch self {
+ case .pending:
+ "Pending"
+ case .queued:
+ "Queued"
+ case .running:
+ "Running"
+ case .success:
+ "Succeeded"
+ case .failed:
+ "Failed"
+ case .cancelled:
+ "Cancelled"
+ case .timeout:
+ "Timed Out"
+ }
+ }
+}
+
// MARK: - Job Status Icon
struct JobStatusIcon: View {
diff --git a/Hutch/Views/Home/HomeSectionView.swift b/Hutch/Views/Home/HomeSectionView.swift
new file mode 100644
index 0000000..5f04d58
--- /dev/null
+++ b/Hutch/Views/Home/HomeSectionView.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct HomeSectionView<Accessory: View, Content: View>: View {
+ let title: String
+ @Binding var isExpanded: Bool
+ let accessory: Accessory
+ let content: Content
+
+ init(
+ _ title: String,
+ isExpanded: Binding<Bool>,
+ @ViewBuilder accessory: () -> Accessory,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.title = title
+ self._isExpanded = isExpanded
+ self.accessory = accessory()
+ self.content = content()
+ }
+
+ var body: some View {
+ Section {
+ if isExpanded {
+ content
+ }
+ } header: {
+ HomeSectionHeader(
+ title: title,
+ isExpanded: $isExpanded,
+ accessory: accessory
+ )
+ }
+ }
+}
+
+private struct HomeSectionHeader<Accessory: View>: View {
+ let title: String
+ @Binding var isExpanded: Bool
+ let accessory: Accessory
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Button {
+ isExpanded.toggle()
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Text(title)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(isExpanded ? "Collapse \(title)" : "Expand \(title)")
+
+ Spacer(minLength: 8)
+
+ accessory
+ }
+ .textCase(nil)
+ }
+}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 3efef7a..180f0f3 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -2,6 +2,9 @@ import SwiftUI
struct HomeView: View {
@AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
+ @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true
+ @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true
+ @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true
@Environment(AppState.self) private var appState
@Environment(\.scenePhase) private var scenePhase
@State private var viewModel: HomeViewModel?
@@ -116,7 +119,7 @@ struct HomeView: View {
summary: viewModel.buildsSummaryText,
countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount),
action: {
- appState.selectedTab = .builds
+ appState.navigateToBuildsList()
}
)
}
@@ -176,7 +179,15 @@ struct HomeView: View {
@ViewBuilder
private func projectsSection(_ viewModel: HomeViewModel) -> some View {
if !viewModel.projects.isEmpty {
- Section {
+ HomeSectionView("Projects", isExpanded: $projectsExpanded) {
+ NavigationLink {
+ HomeProjectsListView(viewModel: viewModel)
+ } label: {
+ Text("See All")
+ .font(.caption.weight(.medium))
+ }
+ .buttonStyle(.plain)
+ } content: {
ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in
NavigationLink {
ProjectDetailView(project: project)
@@ -184,17 +195,21 @@ struct HomeView: View {
HomeProjectRow(project: project)
}
}
- } header: {
- HomeSectionHeader("Projects") {
- HomeProjectsListView(viewModel: viewModel)
- }
}
}
}
@ViewBuilder
private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View {
- Section {
+ HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) {
+ NavigationLink {
+ HomeAssignedTicketsListView(viewModel: viewModel)
+ } label: {
+ Text("See All")
+ .font(.caption.weight(.medium))
+ }
+ .buttonStyle(.plain)
+ } content: {
if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
HomeSectionLoadingRow(label: "Loading assigned tickets")
} else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty {
@@ -241,16 +256,18 @@ struct HomeView: View {
}
}
}
- } header: {
- HomeSectionHeader("Assigned Tickets") {
- HomeAssignedTicketsListView(viewModel: viewModel)
- }
}
}
@ViewBuilder
private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View {
- Section {
+ HomeSectionView("Builds", isExpanded: $buildsExpanded) {
+ Button("See All") {
+ appState.navigateToBuildsList()
+ }
+ .font(.caption.weight(.medium))
+ .buttonStyle(.plain)
+ } content: {
if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty {
HomeSectionLoadingRow(label: "Loading recent builds")
} else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty {
@@ -266,32 +283,67 @@ struct HomeView: View {
systemImage: "clock"
)
} else {
- ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in
- NavigationLink {
- BuildDetailView(jobId: build.job.id)
- } label: {
- HomeBuildRow(build: build)
+ ForEach(buildGroups(for: viewModel.recentBuilds)) { group in
+ if let repositoryDisplayName = group.repositoryDisplayName {
+ HomeBuildGroupHeader(
+ repositoryDisplayName: repositoryDisplayName,
+ buildCount: group.builds.count,
+ latestStatus: group.latestStatus
+ )
}
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled, build.job.status.isCancellable {
- Button {
- Task {
- await viewModel.cancelBuild(build)
+
+ ForEach(group.builds) { build in
+ NavigationLink {
+ BuildDetailView(jobId: build.job.id)
+ } label: {
+ HomeBuildRow(
+ build: build,
+ showsRepositoryLink: group.repositoryDisplayName == nil
+ )
+ }
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ if swipeActionsEnabled, build.job.status.isCancellable {
+ Button {
+ Task {
+ await viewModel.cancelBuild(build)
+ }
}
+ label: {
+ Label("Cancel", systemImage: "xmark.circle")
+ }
+ .tint(.red)
}
- label: {
- Label("Cancel", systemImage: "xmark.circle")
- }
- .tint(.red)
}
}
}
}
- } header: {
- HomeSectionActionHeader("Recent Builds") {
- appState.selectedTab = .builds
+ }
+ }
+
+ private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] {
+ let previewBuilds = Array(builds.prefix(previewLimit))
+ guard let firstBuild = previewBuilds.first else { return [] }
+
+ var groups: [HomeBuildGroup] = []
+ var currentIdentity = HomeBuildGroup.Identity(build: firstBuild)
+ var currentBuilds: [HomeBuildItem] = []
+
+ for build in previewBuilds {
+ let identity = HomeBuildGroup.Identity(build: build)
+ if identity == currentIdentity {
+ currentBuilds.append(build)
+ } else {
+ groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
+ currentIdentity = identity
+ currentBuilds = [build]
}
}
+
+ if !currentBuilds.isEmpty {
+ groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds))
+ }
+
+ return groups
}
@ViewBuilder
@@ -387,6 +439,7 @@ private struct HomeProjectsListView: View {
private struct HomeBuildRow: View {
@Environment(AppState.self) private var appState
let build: HomeBuildItem
+ var showsRepositoryLink = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -395,7 +448,7 @@ private struct HomeBuildRow: View {
.frame(width: 20)
VStack(alignment: .leading, spacing: 4) {
- Text(primaryTitle)
+ Text(build.job.displayLabel)
.font(.subheadline.weight(.medium))
.lineLimit(1)
@@ -408,7 +461,7 @@ private struct HomeBuildRow: View {
.font(.caption)
.foregroundStyle(.tertiary)
- Text(build.job.status.rawValue.capitalized)
+ Text(build.job.status.displayTitle)
.font(.caption)
.foregroundStyle(.secondary)
@@ -425,7 +478,7 @@ private struct HomeBuildRow: View {
}
}
- if let repositoryDisplayName = build.repositoryDisplayName {
+ if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName {
Button {
openRepository()
} label: {
@@ -439,13 +492,6 @@ private struct HomeBuildRow: View {
.padding(.vertical, 2)
}
- private var primaryTitle: String {
- if let repositoryDisplayName = build.repositoryDisplayName {
- return repositoryDisplayName
- }
- return build.job.displayLabel
- }
-
private func openRepository() {
guard let repositoryName = build.repositoryName,
let repositoryOwner = build.repositoryOwner else { return }
@@ -463,6 +509,68 @@ private struct HomeBuildRow: View {
}
}
+private struct HomeBuildGroup: Identifiable {
+ enum Identity: Hashable {
+ case repository(owner: String?, name: String)
+ case standalone(Int)
+
+ init(build: HomeBuildItem) {
+ if let repositoryName = build.repositoryName {
+ self = .repository(owner: build.repositoryOwner, name: repositoryName)
+ } else {
+ self = .standalone(build.id)
+ }
+ }
+ }
+
+ let identity: Identity
+ let builds: [HomeBuildItem]
+
+ var id: String {
+ switch identity {
+ case .repository(let owner, let name):
+ return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)"
+ case .standalone(let jobId):
+ return "job-\(jobId)"
+ }
+ }
+
+ var repositoryDisplayName: String? {
+ builds.first?.repositoryDisplayName
+ }
+
+ var latestStatus: JobStatus {
+ builds.first?.job.status ?? .pending
+ }
+}
+
+private struct HomeBuildGroupHeader: View {
+ let repositoryDisplayName: String
+ let buildCount: Int
+ let latestStatus: JobStatus
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Label(repositoryDisplayName, systemImage: "book.closed")
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ Spacer(minLength: 8)
+
+ Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")")
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.tertiary)
+
+ JobStatusBadge(status: latestStatus)
+ }
+ .padding(.top, 4)
+ .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20))
+ .listRowSeparator(.hidden)
+ .accessibilityElement(children: .combine)
+ }
+}
+
private struct HomeAssignedTicketRow: View {
@Environment(AppState.self) private var appState
let ticket: HomeAssignedTicket
@@ -717,27 +825,6 @@ private struct HomeAttentionLinkRow<Destination: View>: View {
}
}
-private struct HomeSectionActionHeader: View {
- let title: String
- let action: () -> Void
-
- init(_ title: String, action: @escaping () -> Void) {
- self.title = title
- self.action = action
- }
-
- var body: some View {
- HStack {
- Text(title)
- Spacer()
- Button("See All", action: action)
- .font(.caption.weight(.medium))
- .buttonStyle(.plain)
- }
- .textCase(nil)
- }
-}
-
private struct HomeAssignedTicketsListView: View {
let viewModel: HomeViewModel
@AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true