Redesign Health view with card grid and expandable sections

Replaced the long flat list with a cleaner layout:
- Compact header bar: version, update banner, pass/warn/error counts
- Status/Diagnostics tab switcher (segmented control)
- 2-column card grid: each section is a uniform card showing icon,
  title, and colored status dot counts (green/orange/red)
- Cards have a colored border accent based on worst status
- Click to expand: reveals individual check rows inline
- Only one section expanded at a time for clean scanning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-03-31 12:39:42 -04:00
parent c6f45ac22e
commit 3477fa733f
@@ -2,89 +2,165 @@ import SwiftUI
struct HealthView: View { struct HealthView: View {
@State private var viewModel = HealthViewModel() @State private var viewModel = HealthViewModel()
@State private var expandedSection: UUID?
@State private var selectedTab = 0
var body: some View { var body: some View {
VStack(spacing: 0) {
headerBar
Divider()
Picker("", selection: $selectedTab) {
Text("Status").tag(0)
Text("Diagnostics").tag(1)
}
.pickerStyle(.segmented)
.frame(maxWidth: 300)
.padding(.vertical, 8)
Divider()
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
headerSection
if !viewModel.statusSections.isEmpty {
sectionGroup("Status", sections: viewModel.statusSections)
}
if !viewModel.doctorSections.isEmpty {
sectionGroup("Diagnostics", sections: viewModel.doctorSections)
}
}
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .topLeading) }
} }
.navigationTitle("Health") .navigationTitle("Health")
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
} }
private var headerSection: some View { // MARK: - Header
VStack(alignment: .leading, spacing: 12) {
private var headerBar: some View {
HStack(spacing: 16) { HStack(spacing: 16) {
if !viewModel.version.isEmpty { if !viewModel.version.isEmpty {
Text(viewModel.version) Text(viewModel.version)
.font(.system(.body, design: .monospaced)) .font(.system(.caption, design: .monospaced))
} .foregroundStyle(.secondary)
Spacer()
Button("Refresh") { viewModel.load() }
.controlSize(.small)
} }
if viewModel.hasUpdate { if viewModel.hasUpdate {
HStack(spacing: 8) { HStack(spacing: 4) {
Image(systemName: "arrow.triangle.2.circlepath") Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.orange) .font(.caption2)
Text(viewModel.updateInfo) Text(viewModel.updateInfo)
.font(.caption) .font(.caption)
Text("Run `hermes update` in terminal")
.font(.caption)
.foregroundStyle(.secondary)
} }
.padding(8) .foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
} }
HStack(spacing: 16) { Spacer()
CountBadge(count: viewModel.okCount, label: "Passing", color: .green, icon: "checkmark.circle.fill")
CountBadge(count: viewModel.warningCount, label: "Warnings", color: .orange, icon: "exclamationmark.triangle.fill") HStack(spacing: 12) {
CountBadge(count: viewModel.issueCount, label: "Issues", color: .red, icon: "xmark.circle.fill") MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
} MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
} MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
} }
private func sectionGroup(_ title: String, sections: [HealthSection]) -> some View { Button("Refresh") { viewModel.load() }
VStack(alignment: .leading, spacing: 16) { .controlSize(.small)
Text(title) }
.font(.title3.bold()) .padding(.horizontal)
.padding(.vertical, 8)
}
// MARK: - Grid
private func sectionGrid(_ sections: [HealthSection]) -> some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
ForEach(sections) { section in ForEach(sections) { section in
VStack(alignment: .leading, spacing: 6) { SectionCard(
HStack(spacing: 6) { section: section,
Image(systemName: section.icon) isExpanded: expandedSection == section.id,
.foregroundStyle(.secondary) onTap: {
.frame(width: 16) withAnimation(.easeInOut(duration: 0.2)) {
Text(section.title) expandedSection = expandedSection == section.id ? nil : section.id
.font(.headline)
}
VStack(alignment: .leading, spacing: 2) {
ForEach(section.checks) { check in
CheckRow(check: check)
} }
} }
.padding(.leading, 22) )
}
.padding(12)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
} }
} }
} }
} }
// MARK: - Section Card
struct SectionCard: View {
let section: HealthSection
let isExpanded: Bool
let onTap: () -> Void
private var okCount: Int { section.checks.filter { $0.status == .ok }.count }
private var warnCount: Int { section.checks.filter { $0.status == .warning }.count }
private var errorCount: Int { section.checks.filter { $0.status == .error }.count }
private var accentColor: Color {
if errorCount > 0 { return .red }
if warnCount > 0 { return .orange }
return .green
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
HStack(spacing: 10) {
Image(systemName: section.icon)
.font(.title3)
.foregroundStyle(accentColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 8) {
if okCount > 0 {
HStack(spacing: 2) {
Circle().fill(.green).frame(width: 5, height: 5)
Text("\(okCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if warnCount > 0 {
HStack(spacing: 2) {
Circle().fill(.orange).frame(width: 5, height: 5)
Text("\(warnCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if errorCount > 0 {
HStack(spacing: 2) {
Circle().fill(.red).frame(width: 5, height: 5)
Text("\(errorCount)").font(.caption2).foregroundStyle(.secondary)
}
}
}
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(12)
}
.buttonStyle(.plain)
if isExpanded {
Divider()
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 3) {
ForEach(section.checks) { check in
CheckRow(check: check)
}
}
.padding(12)
}
}
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(accentColor.opacity(0.3), lineWidth: 1)
)
}
}
// MARK: - Check Row
struct CheckRow: View { struct CheckRow: View {
let check: HealthCheck let check: HealthCheck
@@ -92,9 +168,10 @@ struct CheckRow: View {
HStack(alignment: .top, spacing: 6) { HStack(alignment: .top, spacing: 6) {
Image(systemName: statusIcon) Image(systemName: statusIcon)
.foregroundStyle(statusColor) .foregroundStyle(statusColor)
.font(.caption) .font(.system(size: 9))
.frame(width: 14) .frame(width: 12, alignment: .center)
VStack(alignment: .leading, spacing: 1) { .padding(.top, 2)
VStack(alignment: .leading, spacing: 0) {
Text(check.label) Text(check.label)
.font(.caption) .font(.caption)
if let detail = check.detail { if let detail = check.detail {
@@ -104,7 +181,6 @@ struct CheckRow: View {
} }
} }
} }
.padding(.vertical, 1)
} }
private var statusIcon: String { private var statusIcon: String {
@@ -124,25 +200,20 @@ struct CheckRow: View {
} }
} }
struct CountBadge: View { // MARK: - Mini Count
struct MiniCount: View {
let count: Int let count: Int
let label: String
let color: Color let color: Color
let icon: String let icon: String
var body: some View { var body: some View {
HStack(spacing: 6) { HStack(spacing: 3) {
Image(systemName: icon) Image(systemName: icon)
.foregroundStyle(color) .foregroundStyle(color)
.font(.caption2)
Text("\(count)") Text("\(count)")
.font(.system(.title3, design: .monospaced, weight: .semibold)) .font(.caption.monospaced().bold())
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
} }
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
} }
} }