diff --git a/DESIGN.md b/DESIGN.md index 9ee95ce..eddaaa7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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: @@ -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 diff --git a/StepBack.xcodeproj/project.pbxproj b/StepBack.xcodeproj/project.pbxproj index 91f99c3..3dbc0a1 100644 --- a/StepBack.xcodeproj/project.pbxproj +++ b/StepBack.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 */; }; @@ -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 */; }; @@ -328,6 +331,7 @@ 558E2480503D9BEB9C013327 /* AgentBridgePluginContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentBridgePluginContractTests.swift; sourceTree = ""; }; 55C5668721400D86CCAAB5C3 /* ColorTokenContrastTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorTokenContrastTests.swift; sourceTree = ""; }; 55D9400C781F7121ACBD717E /* PlayerStageRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStageRoot.swift; sourceTree = ""; }; + 560DB6187B875E1110B0D515 /* PlayerPortraitStageUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPortraitStageUITests.swift; sourceTree = ""; }; 57B9BE25B096864518B7F539 /* PlayerAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerAudioSession.swift; sourceTree = ""; }; 58E4C63295736CEA9B596B5E /* RoutineRestRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineRestRow.swift; sourceTree = ""; }; 5C4EFEB501E194C261920672 /* RoutineStepFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineStepFormatting.swift; sourceTree = ""; }; @@ -397,6 +401,7 @@ D32A7C81517DC2DA3448327C /* StepBackPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepBackPerformanceTests.swift; sourceTree = ""; }; D3E5DF2B9320E1A4302BDF44 /* CustomWorkoutEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWorkoutEditor.swift; sourceTree = ""; }; D421CAA01BED6DBD5BDDB5BA /* ShapeRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapeRadius.swift; sourceTree = ""; }; + D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStackedSegmentLayout.swift; sourceTree = ""; }; DE816E5057C6DF4B2DBBD9AA /* RoutinesHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutinesHomeView.swift; sourceTree = ""; }; DEC57F161E6C05CC443CEF78 /* ProductionCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductionCatalogTests.swift; sourceTree = ""; }; DF883D65838AAB64A21649A2 /* WorkoutVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutVisual.swift; sourceTree = ""; }; @@ -508,6 +513,7 @@ 43811FFACD2386CBA0DBF601 /* StepBackUITests */ = { isa = PBXGroup; children = ( + 560DB6187B875E1110B0D515 /* PlayerPortraitStageUITests.swift */, EB8375CD5C14F6444B3E5A8C /* StepBackAccessibilityAuditTests.swift */, D32A7C81517DC2DA3448327C /* StepBackPerformanceTests.swift */, 44E1A1F153DD793B55B2AADA /* StepBackUITests.swift */, @@ -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 */, @@ -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 */, @@ -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 */, @@ -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 */, diff --git a/StepBack/Features/Player/PlayerSegmentView.swift b/StepBack/Features/Player/PlayerSegmentView.swift index 0442e4d..1f2284b 100644 --- a/StepBack/Features/Player/PlayerSegmentView.swift +++ b/StepBack/Features/Player/PlayerSegmentView.swift @@ -2,11 +2,13 @@ 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 { @@ -14,13 +16,19 @@ struct PlayerSegmentView: View { 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) @@ -28,8 +36,19 @@ struct PlayerSegmentView: View { .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) @@ -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) @@ -83,7 +136,6 @@ 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, @@ -91,7 +143,6 @@ struct PlayerSegmentView: View { categoryName: nil, variant: .stage ) - .frame(maxWidth: 360) } } @@ -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 { diff --git a/StepBack/Features/Player/PlayerStackedSegmentLayout.swift b/StepBack/Features/Player/PlayerStackedSegmentLayout.swift new file mode 100644 index 0000000..de8f046 --- /dev/null +++ b/StepBack/Features/Player/PlayerStackedSegmentLayout.swift @@ -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 + ) + } +} diff --git a/StepBack/Features/Player/PlayerStageView.swift b/StepBack/Features/Player/PlayerStageView.swift index 6ceb92e..5020a36 100644 --- a/StepBack/Features/Player/PlayerStageView.swift +++ b/StepBack/Features/Player/PlayerStageView.swift @@ -18,7 +18,8 @@ struct PlayerStageView: View { workout: workout, categoryName: categoryName, isWide: isWide, - countdownSize: countdownSize(for: proxy.size, isWide: isWide) + countdownSize: countdownSize(for: proxy.size, isWide: isWide), + stackedVisualHeight: stackedVisualHeight(for: proxy.size) ) PlayerProgressFoot(model: model) PlayerControlBar(model: model, end: end) @@ -44,4 +45,10 @@ struct PlayerStageView: View { let proportion = isWide ? size.height * 0.27 : size.height * 0.17 return min(220, max(72, proportion)) } + + private func stackedVisualHeight(for size: CGSize) -> CGFloat { + let canvasTarget = max(180, size.height * 0.30) + let shortCanvasBound = size.height * 0.34 + return min(canvasTarget, shortCanvasBound) + } } diff --git a/StepBack/Features/Shared/WorkoutVisual.swift b/StepBack/Features/Shared/WorkoutVisual.swift index f0f2ce1..b8c7e9c 100644 --- a/StepBack/Features/Shared/WorkoutVisual.swift +++ b/StepBack/Features/Shared/WorkoutVisual.swift @@ -1,6 +1,8 @@ import SwiftUI struct WorkoutVisual: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + enum Variant { case smallRow case galleryCard @@ -43,26 +45,47 @@ struct WorkoutVisual: View { var body: some View { let style = CategoryStyle.resolve(categoryID) + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .aspectRatio(aspectRatio, contentMode: .fit) + .background { + background(style: style) + } + .accessibilityElement(children: .ignore) + .accessibilityIdentifier(variant == .stage ? "player.visual.category" : "") + .accessibilityHidden(true) + .overlay { + visualContent(style: style) + } + .clipped() + } + + @ViewBuilder + private func visualContent(style: CategoryStyle) -> some View { + if variant == .stage { + visualLabel(style: style) + .accessibilityHidden(true) + } else { + visualLabel(style: style) + } + } + + private func visualLabel(style: CategoryStyle) -> some View { VStack(spacing: 8) { Image(systemName: style.symbolName) .font(symbolFont) .foregroundStyle(style.color) - .accessibilityHidden(true) - if showsCategoryName, let categoryName { + if showsCategoryName, + !(variant == .stage && dynamicTypeSize.isAccessibilitySize), + let categoryName { Text(categoryName.uppercased()) .font(.caption.bold()) .tracking(1) .foregroundStyle(.primary) .multilineTextAlignment(.center) - .accessibilityIdentifier(variant == .stage ? "player.visual.category" : "") } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .aspectRatio(aspectRatio, contentMode: .fit) - .background { - background(style: style) - } - .accessibilityHidden(true) + .padding(8) } private var symbolFont: Font { diff --git a/StepBack/Persistence/StepBackBootstrap.swift b/StepBack/Persistence/StepBackBootstrap.swift index 8b58720..da222b6 100644 --- a/StepBack/Persistence/StepBackBootstrap.swift +++ b/StepBack/Persistence/StepBackBootstrap.swift @@ -47,6 +47,15 @@ final class StepBackBootstrap { catalogService: catalog, flagStore: resolvedFlagStore ) + if isRunningTests, + ProcessInfo.processInfo.environment["StepBackUILongWorkoutNameFixture"] == "1", + let routine = try container.mainContext.fetch(FetchDescriptor()) + .first(where: { $0.seedIdentifier == "starter.quick-start" }), + let firstStep = routine.steps?.min(by: { $0.sortIndex < $1.sortIndex }) { + firstStep.workoutNameSnapshot = + "Alternating Single Leg Squat With Overhead Reach And Controlled Rotation" + try container.mainContext.saveOrRollback() + } if isRunningTests, ProcessInfo.processInfo.environment["StepBackUIRemovedPlanFixture"] == "1" { let weekday = Calendar.autoupdatingCurrent.component(.weekday, from: .now) diff --git a/StepBackUITests/PlayerPortraitStageUITests.swift b/StepBackUITests/PlayerPortraitStageUITests.swift new file mode 100644 index 0000000..0f6d625 --- /dev/null +++ b/StepBackUITests/PlayerPortraitStageUITests.swift @@ -0,0 +1,325 @@ +import XCTest + +@MainActor +final class PlayerPortraitStageUITests: XCTestCase { + func testPortraitPlayerKeepsWorkAndRestOnOneCenterAxis() { + XCUIDevice.shared.orientation = .portrait + addTeardownBlock { + XCUIDevice.shared.orientation = .portrait + } + + let app = XCUIApplication() + app.launchArguments.append("-StepBackUITesting") + app.launch() + + let play = app.buttons["Play Quick Start"] + if !play.exists { + app.swipeDown() + } + XCTAssertTrue(play.waitForExistence(timeout: 3)) + play.tap() + + let skip = app.descendants(matching: .any)["player.skip"] + XCTAssertTrue(skip.waitForExistence(timeout: 2)) + advance(toKickerContaining: "work", using: skip, in: app) + + let countdown = app.descendants(matching: .any)["player.countdown"] + let name = app.descendants(matching: .any)["player.name"] + let visual = app.descendants(matching: .any)["player.visual.category"] + let kicker = app.descendants(matching: .any)["player.kicker"] + let detail = app.descendants(matching: .any)["player.setIndicator"] + let controls = controlElements(in: app) + XCTAssertTrue(countdown.waitForExistence(timeout: 2)) + XCTAssertTrue(name.waitForExistence(timeout: 2)) + XCTAssertTrue(visual.waitForExistence(timeout: 2)) + XCTAssertTrue(app.descendants(matching: .any)["player.progress"].exists) + XCTAssertTrue(controls.allSatisfy(\.exists)) + XCTAssertTrue(detail.exists) + waitForStableLayout([kicker, countdown, name, detail, visual] + controls) + assertCentered([kicker, countdown, name, detail, visual], in: app) + assertPortraitVisualBudget(visual, in: app) + + let countdownFrameBeforeNext = countdown.frame + let visualFrameBeforeNext = visual.frame + let nextDuringWork = app.descendants(matching: .any)["player.next"] + XCTAssertFalse(nextDuringWork.exists) + XCTAssertTrue( + nextDuringWork.waitForExistence(timeout: 60), + "Next-up must appear during the final five seconds of work" + ) + let playPause = app.descendants(matching: .any)["player.playPause"] + playPause.tap() + let paused = NSPredicate { object, _ in + guard let element = object as? XCUIElement else { return false } + return element.exists && element.label.localizedCaseInsensitiveContains("resume") + } + XCTAssertEqual( + XCTWaiter.wait( + for: [XCTNSPredicateExpectation(predicate: paused, object: playPause)], + timeout: 2 + ), + .completed, + "The final-five work state must be frozen before rotation checks" + ) + waitForStableLayout([countdown, visual, nextDuringWork] + controls) + XCTAssertTrue(detail.exists, "Work detail must remain visible beside final-five next-up") + assertStablePosition( + countdown, + from: countdownFrameBeforeNext, + accuracy: 2, + message: "Countdown must not move when final-five next-up appears" + ) + assertStablePosition( + visual, + from: visualFrameBeforeNext, + accuracy: 2, + message: "Workout visual must not move when final-five next-up appears" + ) + assertWithinStage( + [kicker, countdown, name, detail, nextDuringWork, visual] + controls, + in: app + ) + XCTAssertGreaterThanOrEqual( + visual.frame.minY - nextDuringWork.frame.maxY, + 20, + "The visual must remain a separately budgeted band below the hero" + ) + attachScreenshot(named: "iPad portrait work", from: app) + + let portraitFrame = app.windows.firstMatch.frame + let runsIPadLandscapeRegression = portraitFrame.width >= 700 + if runsIPadLandscapeRegression { + XCUIDevice.shared.orientation = .landscapeLeft + waitForWindow(in: app, toBeLandscape: true, differingFrom: portraitFrame) + waitForStableLayout([countdown, name, detail, nextDuringWork, visual] + controls) + assertWideComposition( + hero: [countdown, name, detail, nextDuringWork], + visual: visual + ) + assertWithinStage( + [countdown, name, detail, nextDuringWork, visual] + controls, + in: app + ) + attachScreenshot(named: "iPad landscape work regression", from: app) + + let landscapeFrame = app.windows.firstMatch.frame + XCUIDevice.shared.orientation = .portrait + waitForWindow(in: app, toBeLandscape: false, differingFrom: landscapeFrame) + waitForStableLayout([countdown, name, detail, nextDuringWork, visual] + controls) + } + + advance(toKickerContaining: "rest", using: skip, in: app) + let next = app.descendants(matching: .any)["player.next"] + XCTAssertTrue(next.waitForExistence(timeout: 2)) + waitForStableLayout([kicker, next, countdown, detail, visual] + controls) + assertCentered([kicker, next, countdown, detail, visual], in: app) + assertPortraitVisualBudget(visual, in: app) + assertWithinStage([kicker, next, countdown, detail, visual] + controls, in: app) + attachScreenshot(named: "iPad portrait rest", from: app) + + if runsIPadLandscapeRegression { + let restPortraitFrame = app.windows.firstMatch.frame + XCUIDevice.shared.orientation = .landscapeLeft + waitForWindow(in: app, toBeLandscape: true, differingFrom: restPortraitFrame) + waitForStableLayout([next, countdown, detail, visual] + controls) + assertWideComposition(hero: [next, countdown, detail], visual: visual) + assertWithinStage( + [next, countdown, detail, visual] + controls, + in: app + ) + attachScreenshot(named: "iPad landscape regression", from: app) + } + } + + func testStackedPlayerYieldsForAccessibilityText() { + XCUIDevice.shared.orientation = .portrait + addTeardownBlock { + XCUIDevice.shared.orientation = .portrait + } + + let app = XCUIApplication() + app.launchArguments += [ + "-StepBackUITesting", + "-UIPreferredContentSizeCategoryName", + "UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge", + ] + app.launchEnvironment["StepBackUIAccessibilityXXXL"] = "1" + app.launchEnvironment["StepBackUILongWorkoutNameFixture"] = "1" + app.launch() + + let play = app.buttons["Play Quick Start"] + if !play.exists { + app.swipeDown() + } + XCTAssertTrue(play.waitForExistence(timeout: 3)) + play.tap() + + let skip = app.descendants(matching: .any)["player.skip"] + XCTAssertTrue(skip.waitForExistence(timeout: 2)) + advance(toKickerContaining: "work", using: skip, in: app) + + let kicker = app.descendants(matching: .any)["player.kicker"] + let countdown = app.descendants(matching: .any)["player.countdown"] + let name = app.descendants(matching: .any)["player.name"] + let detail = app.descendants(matching: .any)["player.setIndicator"] + let visual = app.descendants(matching: .any)["player.visual.category"] + let progress = app.descendants(matching: .any)["player.progress"] + let controls = controlElements(in: app) + XCTAssertTrue(name.waitForExistence(timeout: 2)) + XCTAssertTrue(name.label.contains("Alternating Single Leg Squat")) + waitForStableLayout([kicker, countdown, name, detail, visual, progress] + controls) + assertCentered([kicker, countdown, name, detail, visual], in: app) + assertWithinStage( + [kicker, countdown, name, detail, visual, progress] + controls, + in: app + ) + XCTAssertLessThanOrEqual( + visual.frame.maxY + 12, + progress.frame.minY, + "The compressed visual must remain above progress; visual=\(visual.frame), progress=\(progress.frame)" + ) + attachScreenshot(named: "Stacked player AX-XXXL long name", from: app) + } + + private func advance( + toKickerContaining expected: String, + using skip: XCUIElement, + in app: XCUIApplication + ) { + let kicker = app.descendants(matching: .any)["player.kicker"] + for _ in 0..<6 { + if kicker.exists, + kicker.label.localizedCaseInsensitiveContains(expected) { + return + } + let previousLabel = kicker.exists ? kicker.label : "" + skip.tap() + let changed = NSPredicate { object, _ in + guard let element = object as? XCUIElement else { return false } + return element.exists && element.label != previousLabel + } + let expectation = XCTNSPredicateExpectation(predicate: changed, object: kicker) + _ = XCTWaiter.wait(for: [expectation], timeout: 2) + } + XCTFail("Player did not reach the expected \(expected) segment") + } + + private func assertCentered(_ elements: [XCUIElement], in app: XCUIApplication) { + let stageMidX = app.windows.firstMatch.frame.midX + for element in elements { + XCTAssertEqual( + element.frame.midX, + stageMidX, + accuracy: 12, + "\(element.identifier) must share the portrait stage center axis" + ) + } + } + + private func assertStablePosition( + _ element: XCUIElement, + from originalFrame: CGRect, + accuracy: CGFloat, + message: String + ) { + XCTAssertEqual(element.frame.midX, originalFrame.midX, accuracy: accuracy, message) + XCTAssertEqual(element.frame.minY, originalFrame.minY, accuracy: accuracy, message) + } + + private func assertPortraitVisualBudget(_ visual: XCUIElement, in app: XCUIApplication) { + XCTAssertEqual( + visual.frame.width / visual.frame.height, + 4 / 3, + accuracy: 0.05, + "The stacked WorkoutVisual must preserve its 4:3 region" + ) + let heightFraction = visual.frame.height / app.windows.firstMatch.frame.height + XCTAssertTrue( + (0.24...0.36).contains(heightFraction), + "The stacked WorkoutVisual must consume roughly 30% of the stage height" + ) + } + + private func assertWideComposition(hero: [XCUIElement], visual: XCUIElement) { + for element in hero { + XCTAssertLessThan( + element.frame.midX, + visual.frame.midX, + "\(element.identifier) must remain in the leading landscape column" + ) + } + XCTAssertGreaterThan( + visual.frame.midX - hero.map(\.frame.midX).max()!, + 120, + "Landscape must retain visibly separate hero and visual columns" + ) + } + + private func controlElements(in app: XCUIApplication) -> [XCUIElement] { + ["player.back", "player.playPause", "player.skip", "player.end"].map { + app.descendants(matching: .any)[$0] + } + } + + private func waitForWindow( + in app: XCUIApplication, + toBeLandscape: Bool, + differingFrom originalFrame: CGRect, + timeout: TimeInterval = 3 + ) { + let window = app.windows.firstMatch + let deadline = Date().addingTimeInterval(timeout) + repeat { + let frame = window.frame + let matchesOrientation = toBeLandscape + ? frame.width > frame.height + : frame.height > frame.width + if matchesOrientation && frame != originalFrame { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } while Date() < deadline + + XCTFail("Player window did not settle into the expected orientation") + } + + private func assertWithinStage(_ elements: [XCUIElement], in app: XCUIApplication) { + let stageFrame = app.windows.firstMatch.frame + for element in elements { + XCTAssertTrue(element.exists, "\(element.identifier) must exist") + XCTAssertFalse(element.frame.isEmpty, "\(element.identifier) must have a visible frame") + XCTAssertTrue( + stageFrame.insetBy(dx: -2, dy: -2).contains(element.frame), + "\(element.identifier) must remain inside the stage" + ) + } + } + + private func attachScreenshot(named name: String, from app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + private func waitForStableLayout( + _ elements: [XCUIElement], + timeout: TimeInterval = 2 + ) { + let deadline = Date().addingTimeInterval(timeout) + var previousFrames: [CGRect]? + var stableSamples = 0 + + repeat { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + let frames = elements.map(\.frame) + if frames == previousFrames { + stableSamples += 1 + if stableSamples == 2 { return } + } else { + stableSamples = 0 + previousFrames = frames + } + } while Date() < deadline + + XCTFail("Player layout did not settle within \(timeout) seconds") + } +} diff --git a/design/ui-spec.html b/design/ui-spec.html index 61af4a4..d22d65e 100644 --- a/design/ui-spec.html +++ b/design/ui-spec.html @@ -100,6 +100,7 @@ box-shadow:0 26px 52px rgba(0,0,0,.5),0 0 0 1px rgba(255,255,255,.07)} .ipadframe.land{width:720px;height:548px} .ipadframe.stagefr{width:860px;height:632px} + .ipadframe.stageportrait{width:590px;height:780px} .ipadscr{width:100%;height:100%;border-radius:18px;overflow:hidden;position:relative; background:var(--ios-grouped);display:flex;flex-direction:column;font-size:13px;color:var(--ios-label)} .isbar{height:26px;flex:none;display:flex;justify-content:space-between;align-items:center;padding:8px 20px 0;font-size:11.5px;font-weight:600} @@ -1228,6 +1229,35 @@

Pre-roll — time to step back

Compact stage & completion

+
+
+
Morning Core9:41
+
+
+
Work · Set 2 of 2
+
0:19
+
Squats
+
Workout 2 of 5
+
+
+ + Legs & Glutes +
+
+
+
+
2:46−2:44
+
+
+
+
+
End
+
+
+
+
Work segment, iPad portrait. Every stacked stage shares one center axis: kicker → countdown → workout name → detail, then the 4:3 WorkoutVisual below. The visual consumes roughly 30% of the stage height, while the remaining height is deliberately distributed around the hero and visual bands instead of pooling into dead space. The countdown remains the single hero and landscape remains the two-column composition above.
+
+
diff --git a/docs/specs/player-portrait-stacked-stage.md b/docs/specs/player-portrait-stacked-stage.md index e8e3674..94ace3a 100644 --- a/docs/specs/player-portrait-stacked-stage.md +++ b/docs/specs/player-portrait-stacked-stage.md @@ -1,8 +1,9 @@ # Spec: Portrait (stacked) stage composition — centered hero stack -**Status:** Ready +**Status:** Implemented **Owner screens:** - `StepBack/Features/Player/PlayerSegmentView.swift` (stacked-branch layout, visual sizing) +- `StepBack/Features/Player/PlayerStackedSegmentLayout.swift` (proportional hero/visual band allocation) - `StepBack/Features/Player/PlayerStageView.swift` (stage vertical budget, stacked padding/spacers) - `design/ui-spec.html` §player (new iPad-portrait work-segment frame)