Files
workouts/Workouts/Views/Exercises/ExerciseInfoContent.swift

143 lines
4.9 KiB
Swift

//
// ExerciseInfoContent.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// Renders a parsed `ExerciseInfo`: summary, target chips, then each authored
/// section with its numbered steps or bullets. Internal so the debug figure-review
/// screen can reuse it.
struct ExerciseInfoContent: View {
let info: ExerciseInfo
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if !info.summary.isEmpty {
Text(info.summary)
.font(.callout)
.foregroundStyle(.secondary)
}
if !info.targets.isEmpty {
TargetChips(targets: info.targets)
}
ForEach(info.sections, id: \.title) { section in
VStack(alignment: .leading, spacing: 6) {
Text(section.title)
.font(.headline)
ForEach(Array(section.items.enumerated()), id: \.offset) { index, item in
itemRow(item, ordinal: ordinal(for: item, at: index, in: section))
}
}
}
}
}
/// Steps number themselves by their position among the section's steps, so an
/// interleaved paragraph doesn't break the count.
private func ordinal(for item: ExerciseInfo.Item, at index: Int, in section: ExerciseInfo.Section) -> Int? {
guard case .step = item else { return nil }
return section.items[...index].reduce(0) { count, it in
if case .step = it { count + 1 } else { count }
}
}
@ViewBuilder
private func itemRow(_ item: ExerciseInfo.Item, ordinal: Int?) -> some View {
switch item {
case .step(let text):
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("\(ordinal ?? 0).")
.foregroundStyle(.secondary)
.monospacedDigit()
Text(text)
}
.font(.callout)
case .bullet(let text):
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•")
.foregroundStyle(.secondary)
Text(text)
}
.font(.callout)
case .paragraph(let text):
Text(text)
.font(.callout)
}
}
}
/// The `Targets:` muscles as wrapping capsule chips.
private struct TargetChips: View {
let targets: [String]
var body: some View {
FlowLayout(spacing: 6) {
ForEach(targets, id: \.self) { target in
Text(target)
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.accentColor.opacity(0.12), in: Capsule())
.foregroundStyle(Color.accentColor)
}
}
}
}
/// Minimal leading-aligned wrapping layout for the target chips.
private struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = layoutRows(maxWidth: proposal.width ?? .infinity, subviews: subviews)
let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(0, rows.count - 1))
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var y = bounds.minY
var index = 0
for row in layoutRows(maxWidth: bounds.width, subviews: subviews) {
var x = bounds.minX
for size in row.sizes {
subviews[index].place(
at: CGPoint(x: x, y: y),
proposal: ProposedViewSize(size)
)
x += size.width + spacing
index += 1
}
y += row.height + spacing
}
}
private struct Row {
var sizes: [CGSize] = []
var width: CGFloat = 0
var height: CGFloat = 0
}
private func layoutRows(maxWidth: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = []
var current = Row()
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let next = current.sizes.isEmpty ? size.width : current.width + spacing + size.width
if next > maxWidth, !current.sizes.isEmpty {
rows.append(current)
current = Row()
}
current.sizes.append(size)
current.width = current.sizes.isEmpty ? 0 : current.width + (current.sizes.count == 1 ? size.width : spacing + size.width)
current.height = max(current.height, size.height)
}
if !current.sizes.isEmpty { rows.append(current) }
return rows
}
}