Extract the exercise info view and ready the figure palette and app icon for macOS
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user