Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ across_the_room: # The signature ruleset. Applies to the pla
media_readiness: # PRD §4.7 — v1 is name-only, layouts reserve media slots.
component: "WorkoutVisual — the ONLY way any screen renders a workout's visual identity."
placeholder: "Monogram tile: category-hue soft fill, category SF Symbol, concentric radius. Same tile family at every size."
slots: { gallery_card: "1:1", workout_detail: "4:3", builder_row: "1:1 small", stage: "4:3 region beside/above the countdown (landscape/portrait)" }
slots: { gallery_card: "1:1", workout_detail: "4:3", builder_row: "1:1 small", stage: "4:3 region beside/below the countdown (landscape/portrait)" }
later: "Photos/video loops drop into the same slots via mediaKey; screens must not reflow when media arrives."

haptics:
Expand Down Expand Up @@ -283,7 +283,7 @@ size stays disciplined. All numerals tabular so durations align down every list.
smart defaults (PRD §5.4).
- **Stage — work segment:** countdown as the single hero, workout name below it,
set indicator ("Set 2 of 3") and optional rep guidance in stage-dim text, the
`WorkoutVisual` region beside (landscape) or above (portrait) the countdown, and a
`WorkoutVisual` region beside (landscape) or below (portrait) the countdown, and a
full-width thin progress bar with elapsed/remaining at the stage foot. Segment hue:
Stage Work (progress ring/bar, accents — never a full-canvas flood).
- **Stage — rest segment:** layout inverts — "Next: Squats" leads at title scale with
Expand Down
10 changes: 10 additions & 0 deletions StepBack.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
3AB3FF32CFAFE0F9A6386FC3 /* WeeklyPlanDayRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A41E7831775D6BDA838142 /* WeeklyPlanDayRow.swift */; };
3D064CDAB1BEEBF526690467 /* PlayerCompletionStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56F98A2C9DB4CE0DD9481BF /* PlayerCompletionStatsView.swift */; };
3DA4AAF68931A61BC153D06D /* SystemSessionDateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B92329DBD4E12D14139C54D4 /* SystemSessionDateProvider.swift */; };
3F2866F29DBF9143116730E4 /* PlayerPortraitStageUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 560DB6187B875E1110B0D515 /* PlayerPortraitStageUITests.swift */; };
4135EAB3D4B7983DD39CDDB2 /* RoutineCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A580CD4774BF44D2990F63 /* RoutineCard.swift */; };
41A6B8C26A725FF6089E3CAF /* PlayerControlBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E8D478F209612C88CD5FB1 /* PlayerControlBar.swift */; };
427BE546700D208EF0F6580B /* InFlightSessionMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4637373527B6278F7CF4D210 /* InFlightSessionMarker.swift */; };
Expand Down Expand Up @@ -83,6 +84,7 @@
5F7F2EAAFFC2BA46D17FFCC2 /* RoutineBuilderFloatingBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175EB9F56FCBC93BF2302C37 /* RoutineBuilderFloatingBar.swift */; };
5FBBEA43681D7211F77A3AF9 /* ColorTokenContrastTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C5668721400D86CCAAB5C3 /* ColorTokenContrastTests.swift */; };
5FEAE92B1C411CF30D2A8DF7 /* PlayerCompletionStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40EBEB35653C1EDB4E93348D /* PlayerCompletionStats.swift */; };
60B5FB971CC6B4AD920D1868 /* PlayerStackedSegmentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */; };
622439D35549A8FC40785285 /* AppSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8A3978327F3430A2D8FC6E /* AppSection.swift */; };
63017C363437110DD559D449 /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F59D68FFF5527BFBFB69F5 /* PlayerPreferences.swift */; };
6514B31863DE0FB5BEF39A16 /* TabAppShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A9CDAF4C9E27EC92D58B4 /* TabAppShellView.swift */; };
Expand Down Expand Up @@ -228,6 +230,7 @@
EE819960E7839288609E78AC /* PlayerCompletionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C186CD3F3272BED3DCAFBAA /* PlayerCompletionView.swift */; };
EF2DDE5B930ACBAD0D34AEDD /* AgentBridgeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3578C8D6CD1D732AF4920D5B /* AgentBridgeProtocol.swift */; };
F0A5AC9FAD61399C0650D3EF /* PlayerPartialCompletionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF1AA0876E78612C1103F65 /* PlayerPartialCompletionView.swift */; };
F0C0ACEA9BC0A4124BA79A6F /* PlayerStackedSegmentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */; };
F12BC9CC65987D2581FB581D /* RoutineDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0807F3424D56760C38F9 /* RoutineDetailView.swift */; };
F197D90DEC3D7AFF8A3EC668 /* AppShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F12B3CC4D8E8CE7CA0D292 /* AppShellView.swift */; };
F247684BCD2C70CCFB38F1AB /* CloudAccountStatusModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC1E7F1C2BA1E4253991224 /* CloudAccountStatusModel.swift */; };
Expand Down Expand Up @@ -328,6 +331,7 @@
558E2480503D9BEB9C013327 /* AgentBridgePluginContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentBridgePluginContractTests.swift; sourceTree = "<group>"; };
55C5668721400D86CCAAB5C3 /* ColorTokenContrastTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorTokenContrastTests.swift; sourceTree = "<group>"; };
55D9400C781F7121ACBD717E /* PlayerStageRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStageRoot.swift; sourceTree = "<group>"; };
560DB6187B875E1110B0D515 /* PlayerPortraitStageUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPortraitStageUITests.swift; sourceTree = "<group>"; };
57B9BE25B096864518B7F539 /* PlayerAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerAudioSession.swift; sourceTree = "<group>"; };
58E4C63295736CEA9B596B5E /* RoutineRestRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineRestRow.swift; sourceTree = "<group>"; };
5C4EFEB501E194C261920672 /* RoutineStepFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineStepFormatting.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -397,6 +401,7 @@
D32A7C81517DC2DA3448327C /* StepBackPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepBackPerformanceTests.swift; sourceTree = "<group>"; };
D3E5DF2B9320E1A4302BDF44 /* CustomWorkoutEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWorkoutEditor.swift; sourceTree = "<group>"; };
D421CAA01BED6DBD5BDDB5BA /* ShapeRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapeRadius.swift; sourceTree = "<group>"; };
D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStackedSegmentLayout.swift; sourceTree = "<group>"; };
DE816E5057C6DF4B2DBBD9AA /* RoutinesHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutinesHomeView.swift; sourceTree = "<group>"; };
DEC57F161E6C05CC443CEF78 /* ProductionCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductionCatalogTests.swift; sourceTree = "<group>"; };
DF883D65838AAB64A21649A2 /* WorkoutVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutVisual.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -508,6 +513,7 @@
43811FFACD2386CBA0DBF601 /* StepBackUITests */ = {
isa = PBXGroup;
children = (
560DB6187B875E1110B0D515 /* PlayerPortraitStageUITests.swift */,
EB8375CD5C14F6444B3E5A8C /* StepBackAccessibilityAuditTests.swift */,
D32A7C81517DC2DA3448327C /* StepBackPerformanceTests.swift */,
44E1A1F153DD793B55B2AADA /* StepBackUITests.swift */,
Expand Down Expand Up @@ -665,6 +671,7 @@
677AEEA10EF3C0DCB0D63682 /* PlayerSegmentView.swift */,
EE42F7940F0735DFFB0AB338 /* PlayerSessionModel.swift */,
F840B9BDB99F4E49A3516A4B /* PlayerSignposting.swift */,
D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */,
E175A54019E51246C0BFDB73 /* PlayerStageColors.swift */,
55D9400C781F7121ACBD717E /* PlayerStageRoot.swift */,
8E923FAEAC372A7927806372 /* PlayerStageView.swift */,
Expand Down Expand Up @@ -974,6 +981,7 @@
744777CBE10EF7161E12C7FE /* PlayerSessionModel.swift in Sources */,
990534B86F1BA0E974197C6A /* PlayerSessionRecorder.swift in Sources */,
F2B2BB545DF6F0182557B6E4 /* PlayerSignposting.swift in Sources */,
60B5FB971CC6B4AD920D1868 /* PlayerStackedSegmentLayout.swift in Sources */,
86554C2F5C8C3E9AF225B403 /* PlayerStageColors.swift in Sources */,
9F710B6EF41986B53FDAD052 /* PlayerStageRoot.swift in Sources */,
88A64D7A0A6EC3A2DFFA803D /* PlayerStageView.swift in Sources */,
Expand Down Expand Up @@ -1086,6 +1094,7 @@
D82422433D3E5D1D8ADA5F1B /* PlayerSessionModel.swift in Sources */,
44C32D69C00959EC75C75DFC /* PlayerSessionRecorder.swift in Sources */,
1BA4EE8DFFFD59A2C57E3B93 /* PlayerSignposting.swift in Sources */,
F0C0ACEA9BC0A4124BA79A6F /* PlayerStackedSegmentLayout.swift in Sources */,
E2F68EADEBFEE76839233AF5 /* PlayerStageColors.swift in Sources */,
E4F1E65F04FD5DAA818EBF62 /* PlayerStageRoot.swift in Sources */,
2B4B238EC57B95998E1CC80D /* PlayerStageView.swift in Sources */,
Expand Down Expand Up @@ -1172,6 +1181,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
3F2866F29DBF9143116730E4 /* PlayerPortraitStageUITests.swift in Sources */,
9C404245FE092AB302498912 /* StepBackAccessibilityAuditTests.swift in Sources */,
986C702369429BE81DB97403 /* StepBackPerformanceTests.swift in Sources */,
4A52183641FFDED9C276FD50 /* StepBackUITests.swift in Sources */,
Expand Down
104 changes: 82 additions & 22 deletions StepBack/Features/Player/PlayerSegmentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,53 @@ import StepBackCore
import SwiftUI

struct PlayerSegmentView: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
let model: PlayerSessionModel
let workout: WorkoutItem?
let categoryName: String?
let isWide: Bool
let countdownSize: CGFloat
let stackedVisualHeight: CGFloat

var body: some View {
Group {
if isWide {
HStack(spacing: 40) {
segmentText
visual
.frame(maxWidth: 360)
}
} else {
VStack(spacing: 16) {
PlayerStackedSegmentLayout(visualHeight: stackedVisualHeight) {
segmentText
visual
.frame(maxHeight: 180)
.frame(
maxWidth: stackedVisualHeight * 4 / 3,
idealHeight: stackedVisualHeight,
maxHeight: stackedVisualHeight
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(model.snapshot.currentSegmentIndex)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
}

@ViewBuilder
private var segmentText: some View {
VStack(alignment: .leading, spacing: 8) {
if isWide {
segmentText(countdownSize: countdownSize)
} else {
segmentText(
countdownSize: dynamicTypeSize.isAccessibilitySize ? 44 : countdownSize
)
}
}

private func segmentText(countdownSize: CGFloat) -> some View {
VStack(alignment: isWide ? .leading : .center, spacing: 8) {
Text(kicker)
.font(.headline.bold())
.textCase(.uppercase)
Expand All @@ -40,36 +59,70 @@ struct PlayerSegmentView: View {
if leadsWithNext {
Text(headline)
.font(.largeTitle.bold())
.lineLimit(isWide ? nil : 2)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("player.next")
countdown
countdown(size: countdownSize)
supportingText
} else {
countdown
countdown(size: countdownSize)
Text(headline)
.font(.largeTitle.bold())
.lineLimit(isWide ? nil : 2)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("player.name")
supportingText
}
}
.multilineTextAlignment(isWide ? .leading : .center)
.frame(maxWidth: .infinity, alignment: isWide ? .leading : .center)
}

if let detail {
Text(detail)
.font(.title3)
.foregroundStyle(Color("StageTextDim"))
.accessibilityIdentifier("player.setIndicator")
}
@ViewBuilder
private var supportingText: some View {
if let detail {
Text(detail)
.font(.title3)
.foregroundStyle(Color("StageTextDim"))
.accessibilityIdentifier("player.setIndicator")
}
if let workNextName {
workNextUp(workNextName)
}
}

if model.showsNextDuringWork,
let next = model.currentSegment?.nextWorkoutNameSnapshot {
Text(L10n.playerNext(next))
.font(.headline)
.foregroundStyle(Color("StageTextDim"))
.accessibilityIdentifier("player.next")
@ViewBuilder
private func workNextUp(_ next: String) -> some View {
if isWide {
if showsNextLine {
nextUpText(next)
}
} else {
nextUpText(
next,
accessibilityIdentifier: showsNextLine ? "player.next" : ""
)
.opacity(showsNextLine ? 1 : 0)
.accessibilityHidden(!showsNextLine)
.animation(.easeInOut(duration: 0.2), value: showsNextLine)
}
.frame(maxWidth: .infinity, alignment: .leading)
}

private var countdown: some View {
private func nextUpText(
_ next: String,
accessibilityIdentifier: String = "player.next"
) -> some View {
Text(L10n.playerNext(next))
.font(.headline)
.lineLimit(isWide ? nil : 2)
.fixedSize(horizontal: false, vertical: true)
.foregroundStyle(Color("StageTextDim"))
.accessibilityIdentifier(accessibilityIdentifier)
}

private func countdown(size: CGFloat) -> some View {
Text(DisplayFormatters.stageDuration(displayedSeconds))
.font(.system(size: countdownSize, weight: .heavy, design: .rounded))
.font(.system(size: size, weight: .heavy, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText())
.lineLimit(1)
Expand All @@ -83,15 +136,13 @@ struct PlayerSegmentView: View {
private var visual: some View {
if let workout {
WorkoutVisual(workout: workout, categoryName: categoryName, variant: .stage)
.frame(maxWidth: 360)
} else if let step = model.currentSegment?.step {
WorkoutVisual(
workoutID: step.workoutID,
categoryID: nil,
categoryName: nil,
variant: .stage
)
.frame(maxWidth: 360)
}
}

Expand All @@ -104,6 +155,15 @@ struct PlayerSegmentView: View {
return kind == .getReady || kind == .rest || kind == .setRest
}

private var showsNextLine: Bool {
workNextName != nil && model.showsNextDuringWork
}

private var workNextName: String? {
guard !leadsWithNext else { return nil }
return model.currentSegment?.nextWorkoutNameSnapshot
}

private var kicker: String {
guard let segment = model.currentSegment else { return L10n.playerKickerWork }
switch segment.kind {
Expand Down
93 changes: 93 additions & 0 deletions StepBack/Features/Player/PlayerStackedSegmentLayout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import SwiftUI

struct PlayerStackedSegmentLayout: Layout {
let visualHeight: CGFloat
private let bandSpacing: CGFloat = 24
private let footClearance: CGFloat = 20

static var layoutProperties: LayoutProperties {
var properties = LayoutProperties()
properties.stackOrientation = .vertical
return properties
}

func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard subviews.count == 2 else {
return proposal.replacingUnspecifiedDimensions()
}

let heroSize = subviews[0].sizeThatFits(
ProposedViewSize(width: proposal.width, height: nil)
)
let visualSize = subviews[1].sizeThatFits(
visualProposal(maxWidth: proposal.width, height: visualHeight)
)
return CGSize(
width: proposal.width ?? max(heroSize.width, visualSize.width),
height: proposal.height ?? heroSize.height + visualSize.height + bandSpacing
)
}

func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard subviews.count == 2 else { return }

let usableHeight = max(0, bounds.height - footClearance)
let idealHeroSize = subviews[0].sizeThatFits(
ProposedViewSize(width: bounds.width, height: nil)
)
let availableVisualHeight = max(
0,
usableHeight - idealHeroSize.height - bandSpacing
)
let placedVisualHeight = min(visualHeight, availableVisualHeight)
let heroHeightLimit = max(
0,
usableHeight - placedVisualHeight - bandSpacing
)
let heroSize = subviews[0].sizeThatFits(
ProposedViewSize(width: bounds.width, height: heroHeightLimit)
)
let visualProposal = visualProposal(
maxWidth: bounds.width,
height: placedVisualHeight
)
let visualSize = subviews[1].sizeThatFits(visualProposal)

let remainingHeight = max(
0,
usableHeight - heroSize.height - visualSize.height - bandSpacing
)
let outerBreathingSpace = remainingHeight / 4
let bandGap = bandSpacing + remainingHeight / 2
let centerX = bounds.midX
let heroY = bounds.minY + outerBreathingSpace
let visualY = heroY + heroSize.height + bandGap

subviews[0].place(
at: CGPoint(x: centerX, y: heroY),
anchor: .top,
proposal: ProposedViewSize(width: bounds.width, height: heroSize.height)
)
subviews[1].place(
at: CGPoint(x: centerX, y: visualY),
anchor: .top,
proposal: visualProposal
)
}

private func visualProposal(maxWidth: CGFloat?, height: CGFloat) -> ProposedViewSize {
ProposedViewSize(
width: min(maxWidth ?? height * 4 / 3, height * 4 / 3),
height: height
)
}
}
Loading
Loading