diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json b/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json index 67a024d..3e2536f 100644 --- a/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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": { diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-128.png b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-128.png new file mode 100644 index 0000000..b829c34 Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-128.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-16.png b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-16.png new file mode 100644 index 0000000..709dffe Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-16.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-256.png b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-256.png new file mode 100644 index 0000000..1ccb13a Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-256.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-32.png b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-32.png new file mode 100644 index 0000000..80a9138 Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-32.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-512.png b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-512.png new file mode 100644 index 0000000..3a0ba6b Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-512.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-64.png b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-64.png new file mode 100644 index 0000000..06387fe Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/mac-icon-64.png differ diff --git a/Workouts/ExerciseFigure/ExerciseFigureView.swift b/Workouts/ExerciseFigure/ExerciseFigureView.swift index 4269b5c..52cd11e 100644 --- a/Workouts/ExerciseFigure/ExerciseFigureView.swift +++ b/Workouts/ExerciseFigure/ExerciseFigureView.swift @@ -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 diff --git a/Workouts/Views/Exercises/ExerciseInfoContent.swift b/Workouts/Views/Exercises/ExerciseInfoContent.swift new file mode 100644 index 0000000..a2a4fe7 --- /dev/null +++ b/Workouts/Views/Exercises/ExerciseInfoContent.swift @@ -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 + } +} diff --git a/Workouts/Views/Exercises/ExerciseLibraryView.swift b/Workouts/Views/Exercises/ExerciseLibraryView.swift index 3a3f5d2..08379e0 100644 --- a/Workouts/Views/Exercises/ExerciseLibraryView.swift +++ b/Workouts/Views/Exercises/ExerciseLibraryView.swift @@ -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 - } -}