Extract the exercise info view and ready the figure palette and app icon for macOS

This commit is contained in:
2026-07-16 19:54:56 -04:00
parent 9741b7d834
commit fe4a7a3ca1
10 changed files with 213 additions and 133 deletions
@@ -107,6 +107,66 @@
"idiom": "ios-marketing",
"size": "1024x1024",
"scale": "1x"
},
{
"filename": "mac-icon-16.png",
"idiom": "mac",
"size": "16x16",
"scale": "1x"
},
{
"filename": "mac-icon-32.png",
"idiom": "mac",
"size": "16x16",
"scale": "2x"
},
{
"filename": "mac-icon-32.png",
"idiom": "mac",
"size": "32x32",
"scale": "1x"
},
{
"filename": "mac-icon-64.png",
"idiom": "mac",
"size": "32x32",
"scale": "2x"
},
{
"filename": "mac-icon-128.png",
"idiom": "mac",
"size": "128x128",
"scale": "1x"
},
{
"filename": "mac-icon-256.png",
"idiom": "mac",
"size": "128x128",
"scale": "2x"
},
{
"filename": "mac-icon-256.png",
"idiom": "mac",
"size": "256x256",
"scale": "1x"
},
{
"filename": "mac-icon-512.png",
"idiom": "mac",
"size": "256x256",
"scale": "2x"
},
{
"filename": "mac-icon-512.png",
"idiom": "mac",
"size": "512x512",
"scale": "1x"
},
{
"filename": "icon-1024.png",
"idiom": "mac",
"size": "512x512",
"scale": "2x"
}
],
"info": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -6,6 +6,9 @@
//
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
/// The looping animated stick-figure for the run screen's bottom half, rendered with
/// `Canvas` + `TimelineView(.animation)` from the bundled anatomical rig data (see
@@ -255,6 +258,12 @@ private extension Color {
static func figure(light: (Double, Double, Double), dark: (Double, Double, Double)) -> Color {
#if os(watchOS)
Color(red: dark.0, green: dark.1, blue: dark.2)
#elseif os(macOS)
Color(nsColor: NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
? NSColor(red: dark.0, green: dark.1, blue: dark.2, alpha: 1)
: NSColor(red: light.0, green: light.1, blue: light.2, alpha: 1)
})
#else
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
@@ -301,6 +310,8 @@ private extension Color {
/// Opaque head fill the screen background, so limbs pass behind the face.
#if os(watchOS)
static let figureHeadFill = Color.black
#elseif os(macOS)
static let figureHeadFill = Color(nsColor: .windowBackgroundColor)
#else
static let figureHeadFill = Color(.systemBackground)
#endif
@@ -0,0 +1,142 @@
//
// 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
}
}
@@ -283,136 +283,3 @@ private struct MachineSettingsCard: View {
}
}
/// 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
}
}