summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 19:44:44 -0500
committerChristian Cleberg <[email protected]>2026-04-13 19:44:44 -0500
commitfe1ccc69661603d419a642a450e4ac9e1258adb0 (patch)
tree386455b6b5468abdd9dfa46dd4802b10da8b77a2 /Hutch/Views
parent6ec9754ce34f331641b000eddef3d44bca2631a5 (diff)
downloadhutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.tar.gz
hutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.tar.bz2
hutch-fe1ccc69661603d419a642a450e4ac9e1258adb0.zip
fix: centralize URLs, tighten models, and polish SwiftUI bindingsv3.1.3
- Add shared HutchDeepLinkURL constants for app, widgets, and tests. - Bump marketing version to 3.1.2 and build to 70 (app + widget extension). - Point Hutch stats default base URL at HutchStatsAPI; add SRHTWebURL status incident feed and reuse it from SystemStatusService. - Group Project into Metadata and Resources; split ContributionStatsResponse into StatsWindow and StatsTotals with updated decoding and tests. - Replace @Bindable usage with explicit Bindings in Profile, Projects list, and repository ACL flows; simplify Home pinned-item helper; add no-op bodies on cancel alert buttons where the compiler requires a statement. - Move repository row build-status indicator next to the relative-updated caption and reserve a fixed 8×8 slot so the row does not jump when status loads. - Use NSString.lastPathComponent for build artifact filenames; collapse duplicate ACL error branches; minor HutchStats HTTP and XMLParserDelegate cleanups. - Point widgets at HutchDeepLinkURL helpers; align tests with the new response and URL types.
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Home/HomeView.swift4
-rw-r--r--Hutch/Views/More/AccountSwitcherView.swift4
-rw-r--r--Hutch/Views/More/ProfileView.swift47
-rw-r--r--Hutch/Views/Projects/ProjectsListView.swift14
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift6
-rw-r--r--Hutch/Views/Repositories/RepositoryACLView.swift13
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift19
-rw-r--r--Hutch/Views/Settings/SettingsView.swift4
8 files changed, 71 insertions, 40 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index b10926a..b4b809d 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -171,7 +171,7 @@ struct HomeView: View {
) {
ForEach(items) { item in
Button {
- openPinnedItem(item, viewModel: viewModel)
+ openPinnedItem(item)
} label: {
HomePinnedCard(item: item)
}
@@ -313,7 +313,7 @@ struct HomeView: View {
}
}
- private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) {
+ private func openPinnedItem(_ item: HomePinnedItem) {
switch item.pin.kind {
case .project:
guard let project = item.project else { return }
diff --git a/Hutch/Views/More/AccountSwitcherView.swift b/Hutch/Views/More/AccountSwitcherView.swift
index ce69279..83871a8 100644
--- a/Hutch/Views/More/AccountSwitcherView.swift
+++ b/Hutch/Views/More/AccountSwitcherView.swift
@@ -98,7 +98,9 @@ struct AccountSwitcherView: View {
}
)
) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; removal uses the destructive button. */
+ }
Button("Remove", role: .destructive) {
guard let pendingRemoval else { return }
Task { await appState.removeAccount(id: pendingRemoval.id) }
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
index c53a89d..8f353ee 100644
--- a/Hutch/Views/More/ProfileView.swift
+++ b/Hutch/Views/More/ProfileView.swift
@@ -61,8 +61,6 @@ struct ProfileView: View {
@ViewBuilder
private func profileContent(_ viewModel: SettingsViewModel) -> some View {
- @Bindable var vm = viewModel
-
Form {
if let profile = viewModel.profile {
profileSection(profile, viewModel: viewModel)
@@ -100,7 +98,10 @@ struct ProfileView: View {
)
}
}
- .sheet(isPresented: $vm.isEditingProfile) {
+ .sheet(isPresented: Binding(
+ get: { viewModel.isEditingProfile },
+ set: { viewModel.isEditingProfile = $0 }
+ )) {
if let profile = viewModel.profile {
EditProfileSheet(profile: profile, viewModel: viewModel)
}
@@ -130,7 +131,9 @@ struct ProfileView: View {
}
)
) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; destructive action is separate. */
+ }
Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
guard let action = pendingDestructiveAction else { return }
pendingDestructiveAction = nil
@@ -158,12 +161,11 @@ struct ProfileView: View {
Section("Profile") {
HStack(spacing: 12) {
AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
- switch phase {
- case .success(let image):
+ if case .success(let image) = phase {
image
.resizable()
.scaledToFill()
- default:
+ } else {
Image(systemName: "person.crop.circle.fill")
.resizable()
.foregroundStyle(.secondary)
@@ -238,8 +240,6 @@ struct ProfileView: View {
@ViewBuilder
private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
- @Bindable var vm = viewModel
-
Section {
ForEach(viewModel.sshKeys) { key in
VStack(alignment: .leading, spacing: 2) {
@@ -275,7 +275,14 @@ struct ProfileView: View {
.themedRow()
if viewModel.isAddingSSHKey {
- TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
+ TextField(
+ "Paste SSH public key",
+ text: Binding(
+ get: { viewModel.newSSHKey },
+ set: { viewModel.newSSHKey = $0 }
+ ),
+ axis: .vertical
+ )
.font(.caption.monospaced())
.lineLimit(3...6)
.themedRow()
@@ -310,8 +317,6 @@ struct ProfileView: View {
@ViewBuilder
private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
- @Bindable var vm = viewModel
-
Section {
ForEach(viewModel.pgpKeys) { key in
VStack(alignment: .leading, spacing: 2) {
@@ -333,7 +338,14 @@ struct ProfileView: View {
.themedRow()
if viewModel.isAddingPGPKey {
- TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
+ TextField(
+ "Paste PGP public key",
+ text: Binding(
+ get: { viewModel.newPGPKey },
+ set: { viewModel.newPGPKey = $0 }
+ ),
+ axis: .vertical
+ )
.font(.caption.monospaced())
.lineLimit(3...6)
.themedRow()
@@ -482,12 +494,11 @@ private struct EditProfileSheet: View {
.scaledToFill()
} else {
AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
- switch phase {
- case .success(let image):
+ if case .success(let image) = phase {
image
.resizable()
.scaledToFill()
- default:
+ } else {
Image(systemName: "person.crop.circle.fill")
.resizable()
.foregroundStyle(.secondary)
@@ -592,7 +603,9 @@ private struct EditProfileSheet: View {
}
}
.alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; removal uses the destructive button. */
+ }
Button("Remove Avatar", role: .destructive) {
Task {
await viewModel.removeAvatar()
diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift
index 12a5149..f6d5766 100644
--- a/Hutch/Views/Projects/ProjectsListView.swift
+++ b/Hutch/Views/Projects/ProjectsListView.swift
@@ -67,8 +67,6 @@ struct ProjectsListView: View {
@ViewBuilder
private func content(_ viewModel: ProjectsListViewModel) -> some View {
- @Bindable var vm = viewModel
-
List {
ForEach(viewModel.filteredProjects) { project in
NavigationLink {
@@ -84,7 +82,10 @@ struct ProjectsListView: View {
.themedList()
.listStyle(.plain)
.searchable(
- text: $vm.searchText,
+ text: Binding(
+ get: { viewModel.searchText },
+ set: { viewModel.searchText = $0 }
+ ),
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search projects"
)
@@ -107,7 +108,12 @@ struct ProjectsListView: View {
)
}
}
- .srhtErrorBanner(error: $vm.error)
+ .srhtErrorBanner(
+ error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ )
+ )
.refreshable {
await viewModel.loadProjects()
}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
index 7b8d7f7..6df40db 100644
--- a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
@@ -261,11 +261,7 @@ final class HgRepositorySettingsViewModel {
}
newACLEntity = ""
} catch {
- if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) {
- self.error = error.userFacingMessage
- } else {
- self.error = error.userFacingMessage
- }
+ self.error = error.userFacingMessage
}
}
diff --git a/Hutch/Views/Repositories/RepositoryACLView.swift b/Hutch/Views/Repositories/RepositoryACLView.swift
index 29ded84..ede770c 100644
--- a/Hutch/Views/Repositories/RepositoryACLView.swift
+++ b/Hutch/Views/Repositories/RepositoryACLView.swift
@@ -51,8 +51,6 @@ struct RepositoryACLView: View {
@ViewBuilder
private func content(_ viewModel: RepositoryACLViewModel) -> some View {
- @Bindable var vm = viewModel
-
Group {
if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
SRHTLoadingStateView(message: "Loading access…")
@@ -98,7 +96,12 @@ struct RepositoryACLView: View {
}
}
}
- .srhtErrorBanner(error: $vm.error)
+ .srhtErrorBanner(
+ error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ )
+ )
.alert("Remove Access?", isPresented: Binding(
get: { pendingDeletion != nil },
set: { isPresented in
@@ -107,7 +110,9 @@ struct RepositoryACLView: View {
}
}
)) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; removal is confirmed separately. */
+ }
Button("Remove Access", role: .destructive) {
guard let entry = pendingDeletion else { return }
Task {
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index afbd740..a2d120b 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -17,9 +17,6 @@ struct RepositoryRowView: View {
RepositoryForgeBadge(service: repository.service)
- if buildStatus != .none {
- RepositoryBuildStatusIndicator(status: buildStatus)
- }
VisibilityBadge(visibility: repository.visibility)
}
@@ -44,9 +41,19 @@ struct RepositoryRowView: View {
Spacer()
- Text(repository.updated.relativeDescription)
- .font(.caption)
- .foregroundStyle(.tertiary)
+ HStack(spacing: 6) {
+ ZStack {
+ if buildStatus != .none {
+ RepositoryBuildStatusIndicator(status: buildStatus)
+ }
+ }
+ .frame(width: 8, height: 8)
+ .accessibilityHidden(buildStatus == .none)
+
+ Text(repository.updated.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
}
}
.padding(.vertical, 2)
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 4ed9216..577de8a 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -32,7 +32,9 @@ struct SettingsView: View {
}
)
) {
- Button("Cancel", role: .cancel) {}
+ Button("Cancel", role: .cancel) {
+ /* Dismiss only; destructive action is separate. */
+ }
Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
guard let action = pendingDestructiveAction else { return }
pendingDestructiveAction = nil