From 6d82e62a9c1d08a2247694d36e0b3be7ae05673c Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 02:03:06 -0500 Subject: [PATCH 1/3] Aim ring: hold the completed ring green with its tick for a moment The planner leaves an aim step in the same update that completes it, so the ring was scored against the next step and never drew its full, green state. The overlay now scores the outgoing step against the new strip and, if it is complete, holds a full ring at that spot for 1 s. The demo leaves the step in the same tick that fills it, like the engine. Also: the legend keeps its side of the ring until the other side has 40 pt more room, the lane insets are shared by the arrows and the legend, the onboarding line covers rings that only mark a place, and the legend string moves out of the guidance section of ScanCopy. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/ScanViewState+AimProgress.swift | 22 ++++-- ios/HouseScan/UI/Camera/CameraOverlays.swift | 57 +++++++++++++- .../UI/Camera/WayfindingOverlay.swift | 76 +++++++++++++++---- ios/HouseScan/UI/Copy/ScanCopy.swift | 14 ++-- ios/HouseScan/UI/Preview/DemoEngine.swift | 9 +-- ios/HouseScan/UI/Preview/UIDemo.swift | 4 +- .../UI/Screens/OnboardingScreen.swift | 5 +- 7 files changed, 149 insertions(+), 38 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift index b3918f145..e22fba175 100644 --- a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift +++ b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift @@ -1,18 +1,28 @@ import HouseScanKit extension ScanViewState { - /// How far the walk's aim task is toward done, 0...1, for the ring that fills (#81): the - /// covered share of its band over the stretch the planner checks, `aimHalfWidth` either side - /// of its s, over the share that completes it (`aimSatisfied`). The planner's numbers are - /// read here, on the engine's side of the contract, so the screens don't import them. + /// How far the walk's aim task is toward done, 0...1, for the ring that fills (#81). Nil for + /// every step but an aim task. + var aimProgress: Double? { + aimProgress(for: guidance) + } + + /// How far `step` is toward done, 0...1: the covered share of its band over the stretch the + /// planner checks, `aimHalfWidth` either side of its s, over the share that completes it + /// (`aimSatisfied`). The planner's numbers are read here, on the engine's side of the + /// contract, so the screens don't import them. + /// + /// The overlay also scores the step that just ended with it. The planner moves on in the + /// same update that completes a step, so by the time a screen sees the full strip, + /// `guidance` is already the next step. /// /// It reads the published strip, which holds the cells in view on the wall map /// (`CoverageMap.visibleRange`); the planner reads the whole map. Near an end of that range /// the two can differ by a cell. Nil for every step but an aim task. - var aimProgress: Double? { + func aimProgress(for step: GuidanceStep) -> Double? { let band: CoverageBand let s: Float - switch guidance { + switch step { case .aimAtGround(let at): band = .ground s = at diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index d2ac09454..ef00c9e0a 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -9,14 +9,39 @@ struct CameraOverlays: View { /// fills and retires once that step ends (#81); it isn't needed on every ring after. @State private var legendStep: GuidanceStep? = nil @State private var legendRetired = false + /// The aim step on screen, with its target, so the update that ends it can still score it. + @State private var lastAim: AimSnapshot? = nil + /// The target of an aim step that just completed, held on screen as a full green ring with a + /// tick for `completedHold` (#81). The planner moves on in the same update that fills the + /// strip, so without the hold the ring would vanish short of full and never turn green. + @State private var completedTarget: SIMD3? = nil + /// Counts completions, so a second one inside the hold restarts its timer. + @State private var completions = 0 + + /// How long a completed ring stays: long enough to read as done, short enough not to hold up + /// the next step's marker. A display choice. + private static let completedHold: Duration = .seconds(1) var body: some View { ZStack { overlays } .animation(.easeOut(duration: 0.2), value: state.tracking == .normal) - .onChange(of: state.guidance) { _, step in - if let legendStep, step != legendStep { legendRetired = true } + .onChange(of: AimSnapshot(step: state.guidance, target: state.target), initial: true) { old, new in + if new.step != old.step { + if let legendStep, new.step != legendStep { legendRetired = true } + if let target = completed(old) { + completedTarget = target + completions += 1 + } + } + lastAim = state.aimProgress(for: new.step) == nil ? nil : new + } + .task(id: completions) { + guard completedTarget != nil else { return } + // A newer completion replaces this task; its cancellation must not clear the new ring. + do { try await Task.sleep(for: Self.completedHold) } catch { return } + completedTarget = nil } } @@ -37,13 +62,15 @@ struct CameraOverlays: View { if state.marking == nil { // While marking, the reticle is the only aim; the path and ring would compete. let progress = state.aimProgress + let held = heldTarget WayfindingOverlay( projection: projection, wall: wall, path: state.path, target: state.target, progress: progress, - legend: legend(progress: progress), + completed: held, + legend: held == nil ? legend(progress: progress) : nil, onLegendShown: { if legendStep == nil { legendStep = state.guidance } } @@ -54,9 +81,33 @@ struct CameraOverlays: View { } } + /// The completed ring to hold. The update that ends an aim step draws before `onChange` + /// runs, so the outgoing step is scored here too; otherwise that frame would show the next + /// step's marker between the filling ring and the green one. + private var heldTarget: SIMD3? { + if let completedTarget { return completedTarget } + guard let lastAim, lastAim.step != state.guidance else { return nil } + return completed(lastAim) + } + + /// The target of `snapshot`'s aim step if the strip as it is now completes it. The strip + /// holds the view that ended the step. + private func completed(_ snapshot: AimSnapshot) -> SIMD3? { + guard let target = snapshot.target, let progress = state.aimProgress(for: snapshot.step), progress >= 1 else { + return nil + } + return target + } + /// The legend, while the ring fills for the first time. private func legend(progress: Double?) -> String? { guard !legendRetired, let progress, progress < 1 else { return nil } return ScanCopy.aimRingLegend } } + +/// The step and its target together, so a change of step still has the target it ended with. +private struct AimSnapshot: Equatable { + var step: GuidanceStep + var target: SIMD3? +} diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index d6eb3ca45..ad0a9391c 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -3,6 +3,9 @@ import SwiftUI /// Where to stand and where to aim: a blue dotted path on the ground toward the next place to /// stand, and a ring on the point to aim at (or an edge arrow when it is off screen). +/// +/// Each part sets its own accessibility: the path and arrow are hidden, while the filling ring +/// and the legend are read (#81). A new part is read by VoiceOver unless it hides itself. struct WayfindingOverlay: View { var projection: CameraProjection var wall: WallGeometry? @@ -11,11 +14,17 @@ struct WayfindingOverlay: View { /// How far the aim task is toward done, 0...1 (`ScanViewState.aimProgress`): the ring fills /// with it. Nil when the target only marks a place. var progress: Double? = nil + /// An aim step's target that was just completed, drawn as a full ring in place of `target` + /// while it is on screen (`CameraOverlays` holds it for a moment). + var completed: SIMD3? = nil /// One line saying what the ring is for, drawn beside it while it is on screen. var legend: String? = nil /// Called when the legend is drawn. var onLegendShown: (() -> Void)? = nil + /// The side of the ring the legend keeps to once shown: true for below. + @State private var legendBelow: Bool? = nil + var body: some View { GeometryReader { proxy in let size = proxy.size @@ -24,10 +33,11 @@ struct WayfindingOverlay: View { drawPath(in: &context, size: size) } .accessibilityHidden(true) - if let target { - let marker = placement(for: target, in: size) - TargetMarker(placement: marker, progress: progress) - if let legend, case .onScreen(let point, let radius) = marker { + // One marker either way, so a ring that completes keeps its identity and animates + // from its last fill to green. + if let shown = marker(in: size) { + TargetMarker(placement: shown.placement, progress: shown.progress) + if let legend, case .onScreen(let point, let radius) = shown.placement { legendView(legend, beside: point, radius: radius, in: size) } } @@ -38,17 +48,36 @@ struct WayfindingOverlay: View { .allowsHitTesting(false) } + // MARK: Lane + + /// The band clear of the instruction card above and the buttons and map below, which are + /// drawn over this layer. The off-screen arrows and the legend both keep to it. + private static let laneTop: CGFloat = 260 + private static let laneBottomInset: CGFloat = 300 + // MARK: Legend - /// The legend goes above or below the ring, whichever has more room between the instruction - /// card and the controls (the band the off-screen arrows keep to), and moves with the ring - /// without covering it. + /// How much more room the other side of the ring needs before the legend moves there. The + /// two sides are equal near the middle of the screen, where the ring is held, and pose + /// jitter there would otherwise flip the legend across the ring every few frames. + private static let legendSwitchMargin: CGFloat = 40 + + /// The legend goes above or below the ring, whichever has more room in the lane, and moves + /// with the ring without covering it. Once shown, it changes sides only when the other side + /// has `legendSwitchMargin` more room. private func legendView(_ text: String, beside point: CGPoint, radius: CGFloat, in size: CGSize) -> some View { let width = max(120, min(300, size.width - 48)) let x = min(max(point.x, 24 + width / 2), size.width - 24 - width / 2) - let roomAbove = point.y - radius - 260 - let roomBelow = size.height - 300 - (point.y + radius) - let below = roomBelow >= roomAbove + let roomAbove = point.y - radius - Self.laneTop + let roomBelow = size.height - Self.laneBottomInset - (point.y + radius) + let below: Bool + if let kept = legendBelow { + below = kept + ? roomAbove - roomBelow < Self.legendSwitchMargin + : roomBelow - roomAbove >= Self.legendSwitchMargin + } else { + below = roomBelow >= roomAbove + } // A tall frame pinned at the edge nearer the ring, so the text's height (it wraps, and // grows with Dynamic Type) never moves its near edge onto the ring. let reach: CGFloat = 400 @@ -61,7 +90,12 @@ struct WayfindingOverlay: View { .background(Color.black.opacity(0.7), in: .rect(cornerRadius: 14, style: .continuous)) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("aim.legend") - .onAppear { onLegendShown?() } + .onAppear { + legendBelow = below + onLegendShown?() + } + .onChange(of: below) { _, side in legendBelow = side } + .onDisappear { legendBelow = nil } .frame(width: width, height: reach, alignment: below ? .top : .bottom) .position(x: x, y: below ? point.y + radius + 12 + reach / 2 : point.y - radius - 12 - reach / 2) .transition(.opacity) @@ -104,6 +138,16 @@ struct WayfindingOverlay: View { // MARK: Target + /// The completed ring while it is on screen, else the target's ring or arrow. + private func marker(in size: CGSize) -> (placement: TargetMarker.Placement, progress: Double?)? { + if let completed { + let held = placement(for: completed, in: size) + if case .onScreen = held { return (placement: held, progress: 1.0) } + } + guard let target else { return nil } + return (placement: placement(for: target, in: size), progress: progress) + } + private func placement(for target: SIMD3, in size: CGSize) -> TargetMarker.Placement { // The ring shows while its center is comfortably on screen; the chevron takes over // near the edges, where a half-visible ring would be ambiguous. @@ -116,9 +160,13 @@ struct WayfindingOverlay: View { guard let direction = projection.screenDirection(toward: target) else { return .hidden } - // Chevrons stay clear of the instruction card above and the buttons and map below, - // which are drawn over this layer. - let lane = CGRect(x: 40, y: 260, width: size.width - 80, height: max(size.height - 260 - 300, 80)) + // Arrows keep to the lane, clear of the card and the controls. + let lane = CGRect( + x: 40, + y: Self.laneTop, + width: size.width - 80, + height: max(size.height - Self.laneTop - Self.laneBottomInset, 80) + ) let center = CGPoint(x: lane.midX, y: lane.midY) let halfWidth = lane.width / 2 let halfHeight = lane.height / 2 diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 5dbbaeb1c..9b20d8a75 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -64,12 +64,6 @@ enum ScanCopy { } } - /// The line beside the walk's first aim ring, which fills as its stretch is captured (#81). - /// A stretch counts only once it is seen from two places a step apart - /// (`CoverageConfig.coveringBaseline`), so holding still never fills the ring: the line asks - /// for a step. - static let aimRingLegend = "Keep the ring in view and take a small step to one side. It fills as your phone captures this spot." - // MARK: Coaching static func coaching(_ coaching: Coaching) -> Instruction { @@ -106,6 +100,14 @@ enum ScanCopy { } } + // MARK: Aim ring + + /// The line beside the walk's first aim ring, which fills as its stretch is captured (#81). + /// A stretch counts only once it is seen from two places a step apart + /// (`CoverageConfig.coveringBaseline`), so holding still never fills the ring: the line asks + /// for a step. + static let aimRingLegend = "Keep the ring in view and take a small step to one side. It fills as your phone captures this spot." + // MARK: Wall ends /// Under the wall map when ending the wall at the dashed line would cut off part of the walk. diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index c2c8a1e8c..e137222f0 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -430,13 +430,12 @@ final class DemoEngine: ScanActions { } if case .aimAtGround(let s) = state.guidance { // A step to the side at a time: one more ground cell by the ring is covered each - // tick, from its middle out, and the walk goes on once the ring is full. + // tick, from its middle out. Like the real engine, the walk goes on in the same + // update that fills the ring, so the demo shows the hold a phone shows (#81). + capture(.walk) + coverGroundCell(nearest: s) if (state.aimProgress ?? 0) >= 1 { - capture(.walk) - refreshCoverage() refreshGuidance() - } else { - coverGroundCell(nearest: s) } } } diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 2529be43d..79685012c 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -33,8 +33,8 @@ import SwiftUI /// - `-uiDemoHidden`: on a phone with depth, the walk has two stretches hidden behind something. /// - `-uiDemoSeeBehind`: as `-uiDemoHidden`, and the walk asks to look past the one on the right. /// - `-uiDemoAim`: with `-uiDemoPhase wallWalk`, the walk asks to tilt down at the ground right of -/// the meter, and the ring is half full with its legend beside it. Unfrozen, it fills and the -/// walk goes on. +/// the meter, and the ring is half full with its legend beside it. Unfrozen, it fills, the walk +/// goes on in the same update as on a phone, and the ring holds green with a tick for a moment. /// - `-uiDemoAimOffScreen`: as `-uiDemoAim`, for ground left of the view: the edge arrow shows. /// - `-uiDemoCorner`: the wall turns an outside corner 1.8 m right of the meter and the walk /// followed it, so the window and part of its clearance zone are round the corner. For the diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index aef9e5b28..fecd9ca05 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -21,8 +21,9 @@ struct OnboardingScreen: View { title: "Your phone takes the photos", body: "Just walk slowly. The haze on the wall clears as your phone sees it.", // The ring and the arrows carry most of the walk's guidance, and nothing said what - // they were for (#81). - guide: "A blue ring marks a spot to show. It fills and turns green once captured. When the spot is off screen, an arrow at the edge points to it.", + // they were for (#81). Most rings only mark where to go or look; only a spot to show + // fills, so the line covers both. + guide: "A blue ring shows where to go or look. When it asks you to show a spot, it fills as your phone captures it and turns green when done. If it's off screen, an arrow at the edge points to it.", note: "Only the wall's measurements are sent. Your photos stay on this phone and are deleted when you start over.", art: .fog ), From b2c636738f63b95ebdb77b9fa5043d95ad5a0fa2 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:02:03 -0500 Subject: [PATCH 2/3] Aim ring: legend keeps to the lane, asks for a whole step, ring count matches the planner - The legend is drawn only in the room between the ring and the lane's edge on its side: the full line if it fits, else a short "It fills as your phone captures this spot.", else nothing. At accessibility text sizes the full line ran to eight lines or more and went under the instruction card or the controls. - "take a small step to one side" becomes "take one step to the side", the aim hint's own words. A shuffle of a few inches keeps no new view (0.5 m capture spacing) and so never fills the ring. - aimProgress narrows its window by the coverage map's overlap tolerance, so a covered cell that only touches the window's edge can't show the ring done while the planner keeps the task up. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/ScanViewState+AimProgress.swift | 7 ++- ios/HouseScan/UI/Camera/CameraOverlays.swift | 1 + .../UI/Camera/WayfindingOverlay.swift | 47 ++++++++++++++----- ios/HouseScan/UI/Copy/ScanCopy.swift | 9 ++-- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift index e22fba175..9900cd915 100644 --- a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift +++ b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift @@ -18,7 +18,10 @@ extension ScanViewState { /// /// It reads the published strip, which holds the cells in view on the wall map /// (`CoverageMap.visibleRange`); the planner reads the whole map. Near an end of that range - /// the two can differ by a cell. Nil for every step but an aim task. + /// the two can differ by a cell. The window is narrowed by the map's own tolerance + /// (`CoverageMap.indices(overlapping:)`), so a cell that only touches its edge counts for + /// neither, and the ring can't show done while the task stays up. Nil for every step but an + /// aim task. func aimProgress(for step: GuidanceStep) -> Double? { let band: CoverageBand let s: Float @@ -32,7 +35,7 @@ extension ScanViewState { default: return nil } - let half = GuidancePlanner.aimHalfWidth + let half = GuidancePlanner.aimHalfWidth - coverage.cellWidth * 1e-3 let covered = coverage.coveredFraction(band, in: (s - half)...(s + half)) return min(1, covered / GuidancePlanner.aimSatisfied) } diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index ef00c9e0a..2300a16ef 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -71,6 +71,7 @@ struct CameraOverlays: View { progress: progress, completed: held, legend: held == nil ? legend(progress: progress) : nil, + legendShort: ScanCopy.aimRingLegendShort, onLegendShown: { if legendStep == nil { legendStep = state.guidance } } diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index ad0a9391c..d8150ec68 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -19,6 +19,8 @@ struct WayfindingOverlay: View { var completed: SIMD3? = nil /// One line saying what the ring is for, drawn beside it while it is on screen. var legend: String? = nil + /// A shorter legend, drawn where `legend` doesn't fit between the ring and the lane's edge. + var legendShort: String? = nil /// Called when the legend is drawn. var onLegendShown: (() -> Void)? = nil @@ -62,9 +64,17 @@ struct WayfindingOverlay: View { /// jitter there would otherwise flip the legend across the ring every few frames. private static let legendSwitchMargin: CGFloat = 40 + /// The gap between the ring and the legend. + private static let legendGap: CGFloat = 12 + /// The legend goes above or below the ring, whichever has more room in the lane, and moves /// with the ring without covering it. Once shown, it changes sides only when the other side /// has `legendSwitchMargin` more room. + /// + /// It keeps to the lane: the longest wording that fits between the ring and the lane's edge + /// on its side is drawn, and none when neither fits. At the largest text sizes the full line + /// runs to eight lines or more, and past the lane it went under the instruction card or the + /// controls, which are drawn over this layer. private func legendView(_ text: String, beside point: CGPoint, radius: CGFloat, in size: CGSize) -> some View { let width = max(120, min(300, size.width - 48)) let x = min(max(point.x, 24 + width / 2), size.width - 24 - width / 2) @@ -78,10 +88,30 @@ struct WayfindingOverlay: View { } else { below = roomBelow >= roomAbove } - // A tall frame pinned at the edge nearer the ring, so the text's height (it wraps, and - // grows with Dynamic Type) never moves its near edge onto the ring. - let reach: CGFloat = 400 - return Text(text) + // The room on the legend's side, pinned at the edge nearer the ring, so the text's height + // (it wraps, and grows with Dynamic Type) never moves its near edge onto the ring. + let reach = max(0, (below ? roomBelow : roomAbove) - Self.legendGap) + return ViewThatFits(in: .vertical) { + legendText(text) + if let legendShort { legendText(legendShort) } + Color.clear.frame(width: 0, height: 0) + } + .onAppear { + legendBelow = below + onLegendShown?() + } + .onChange(of: below) { _, side in legendBelow = side } + .onDisappear { legendBelow = nil } + .frame(width: width, height: reach, alignment: below ? .top : .bottom) + .position( + x: x, + y: below ? point.y + radius + Self.legendGap + reach / 2 : point.y - radius - Self.legendGap - reach / 2 + ) + .transition(.opacity) + } + + private func legendText(_ text: String) -> some View { + Text(text) .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .multilineTextAlignment(.center) @@ -90,15 +120,6 @@ struct WayfindingOverlay: View { .background(Color.black.opacity(0.7), in: .rect(cornerRadius: 14, style: .continuous)) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("aim.legend") - .onAppear { - legendBelow = below - onLegendShown?() - } - .onChange(of: below) { _, side in legendBelow = side } - .onDisappear { legendBelow = nil } - .frame(width: width, height: reach, alignment: below ? .top : .bottom) - .position(x: x, y: below ? point.y + radius + 12 + reach / 2 : point.y - radius - 12 - reach / 2) - .transition(.opacity) } // MARK: Path diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 68430b131..bc124b858 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -174,9 +174,12 @@ enum ScanCopy { /// The line beside the walk's first aim ring, which fills as its stretch is captured (#81). /// A stretch counts only once it is seen from two places a step apart - /// (`CoverageConfig.coveringBaseline`), so holding still never fills the ring: the line asks - /// for a step. - static let aimRingLegend = "Keep the ring in view and take a small step to one side. It fills as your phone captures this spot." + /// (`CoverageConfig.coveringBaseline`, 0.25 m), and a view is kept only after about half a + /// meter of moving (`AutoCaptureConfig`), so holding still or shuffling a few inches never + /// fills the ring: the line asks for a whole step, in the words of the aim hint's title. + static let aimRingLegend = "Keep the ring in view and take one step to the side. It fills as your phone captures this spot." + /// The legend where the whole line doesn't fit beside the ring (large text sizes). + static let aimRingLegendShort = "It fills as your phone captures this spot." // MARK: Wall ends From 8b42e13a532f5c3fc72b6b78bcec8824a3715721 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:24:58 -0500 Subject: [PATCH 3/3] Aim ring legend: measure the card, move under it when there is no room beside the ring - The chrome reports the camera area left open between its card and its controls (CameraChrome.openArea, measured in global coordinates), and the legend's room beside the ring comes from it instead of the fixed 260/300 pt lane. At AX1-AX3 the enlarged card no longer covers the legend. - Where no wording fits beside the ring (AX4-AX5, where the chrome fills the screen, or a 667 pt screen), the legend is drawn under the card in the chrome's own stack, which scrolls at any text size, instead of being dropped. - The legend counts as shown only where it drew, so one with no room doesn't retire unseen. - The legend asks for "a small step sideways", in the tilt-down card's words. - The aim-ring UI test checks the legend keeps clear of the card at the default and the largest text size. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Camera/CameraOverlays.swift | 25 ++++++- .../UI/Camera/WayfindingOverlay.swift | 73 +++++++++++++------ .../UI/Components/CameraChrome.swift | 60 ++++++++++++++- ios/HouseScan/UI/Copy/ScanCopy.swift | 12 +-- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 10 ++- .../ScreenStatesUITests.swift | 17 ++++- 6 files changed, 162 insertions(+), 35 deletions(-) diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index 2300a16ef..8fb4f7615 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -4,6 +4,12 @@ import SwiftUI struct CameraOverlays: View { let state: ScanViewState var highlight: GapRequest? + /// The camera area the chrome leaves open (`CameraChrome.openArea`); the ring's legend + /// keeps inside it. + var openArea: OpenCameraArea? = nil + /// Set to the ring's legend while it doesn't fit beside the ring, for the screen to draw + /// under its card (`CameraChrome.legend`). Without it, the legend shows only beside the ring. + var cardLegend: Binding? = nil /// The step the aim ring's legend first showed with. The legend explains the first ring that /// fills and retires once that step ends (#81); it isn't needed on every ring after. @@ -63,6 +69,7 @@ struct CameraOverlays: View { // While marking, the reticle is the only aim; the path and ring would compete. let progress = state.aimProgress let held = heldTarget + let line = held == nil ? legend(progress: progress) : nil WayfindingOverlay( projection: projection, wall: wall, @@ -70,11 +77,10 @@ struct CameraOverlays: View { target: state.target, progress: progress, completed: held, - legend: held == nil ? legend(progress: progress) : nil, + legend: line, legendShort: ScanCopy.aimRingLegendShort, - onLegendShown: { - if legendStep == nil { legendStep = state.guidance } - } + openArea: openArea, + onLegendPlaced: { placement in legendPlaced(placement, line) } ) .transition(.opacity) } @@ -100,6 +106,17 @@ struct CameraOverlays: View { return target } + /// Where the legend went. Beside the ring it is drawn there; where no wording fits there, + /// it goes under the card. It counts as shown, and so retires with its step, only where it + /// drew: a legend with no room on a short screen or at a large text size once used up the + /// one explanation without being seen. + private func legendPlaced(_ placement: WayfindingOverlay.LegendPlacement?, _ legend: String?) { + let underCard = placement == .underCard ? legend : nil + if let cardLegend, cardLegend.wrappedValue != underCard { cardLegend.wrappedValue = underCard } + let drawn = placement == .besideRing || (placement == .underCard && cardLegend != nil) + if drawn, legendStep == nil { legendStep = state.guidance } + } + /// The legend, while the ring fills for the first time. private func legend(progress: Double?) -> String? { guard !legendRetired, let progress, progress < 1 else { return nil } diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index d8150ec68..c52dd6018 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -19,10 +19,23 @@ struct WayfindingOverlay: View { var completed: SIMD3? = nil /// One line saying what the ring is for, drawn beside it while it is on screen. var legend: String? = nil - /// A shorter legend, drawn where `legend` doesn't fit between the ring and the lane's edge. + /// A shorter legend, drawn where `legend` doesn't fit between the ring and the card or the + /// controls. var legendShort: String? = nil - /// Called when the legend is drawn. - var onLegendShown: (() -> Void)? = nil + /// The camera area the chrome leaves open, in global coordinates (`CameraChrome.openArea`). + /// The legend keeps inside it. Nil: the fixed lane the arrows use. + var openArea: OpenCameraArea? = nil + /// Called with where the legend went each time that changes: beside the ring, or nowhere + /// here because no wording fits (the caller draws it under the card instead). Nil once it + /// is gone. + var onLegendPlaced: ((LegendPlacement?) -> Void)? = nil + + enum LegendPlacement: Equatable { + case besideRing + /// No wording fits beside the ring: at the largest text sizes, where the card and the + /// controls fill most of the screen, and on short screens. + case underCard + } /// The side of the ring the legend keeps to once shown: true for below. @State private var legendBelow: Bool? = nil @@ -30,6 +43,7 @@ struct WayfindingOverlay: View { var body: some View { GeometryReader { proxy in let size = proxy.size + let top = proxy.frame(in: .global).minY ZStack { Canvas { context, size in drawPath(in: &context, size: size) @@ -40,7 +54,7 @@ struct WayfindingOverlay: View { if let shown = marker(in: size) { TargetMarker(placement: shown.placement, progress: shown.progress) if let legend, case .onScreen(let point, let radius) = shown.placement { - legendView(legend, beside: point, radius: radius, in: size) + legendView(legend, beside: point, radius: radius, in: size, top: top) } } } @@ -53,7 +67,8 @@ struct WayfindingOverlay: View { // MARK: Lane /// The band clear of the instruction card above and the buttons and map below, which are - /// drawn over this layer. The off-screen arrows and the legend both keep to it. + /// drawn over this layer, at the default text size. The off-screen arrows keep to it, and + /// the legend does where the chrome's open area isn't measured. private static let laneTop: CGFloat = 260 private static let laneBottomInset: CGFloat = 300 @@ -64,22 +79,29 @@ struct WayfindingOverlay: View { /// jitter there would otherwise flip the legend across the ring every few frames. private static let legendSwitchMargin: CGFloat = 40 - /// The gap between the ring and the legend. + /// The gap between the ring and the legend, and between the legend and the card or the + /// controls. private static let legendGap: CGFloat = 12 - /// The legend goes above or below the ring, whichever has more room in the lane, and moves - /// with the ring without covering it. Once shown, it changes sides only when the other side - /// has `legendSwitchMargin` more room. + /// The legend goes above or below the ring, whichever has more room between the card and + /// the controls, and moves with the ring without covering it. Once shown, it changes sides + /// only when the other side has `legendSwitchMargin` more room. /// - /// It keeps to the lane: the longest wording that fits between the ring and the lane's edge - /// on its side is drawn, and none when neither fits. At the largest text sizes the full line - /// runs to eight lines or more, and past the lane it went under the instruction card or the - /// controls, which are drawn over this layer. - private func legendView(_ text: String, beside point: CGPoint, radius: CGFloat, in size: CGSize) -> some View { + /// The room is measured from the card's real bottom and the controls' real top + /// (`openArea`), which move with Dynamic Type. Both are drawn over this layer, and against + /// a fixed lane the legend went under the enlarged card. The longest wording that fits on + /// its side is drawn. Where neither fits, nothing is drawn here and `onLegendPlaced` says + /// so, and the caller puts the legend under the card, which scrolls at any text size. + private func legendView( + _ text: String, beside point: CGPoint, radius: CGFloat, in size: CGSize, top: CGFloat + ) -> some View { let width = max(120, min(300, size.width - 48)) let x = min(max(point.x, 24 + width / 2), size.width - 24 - width / 2) - let roomAbove = point.y - radius - Self.laneTop - let roomBelow = size.height - Self.laneBottomInset - (point.y + radius) + // The open area in this layer's coordinates. + let laneTop = openArea.map { $0.top - top + Self.legendGap } ?? Self.laneTop + let laneBottom = openArea.map { $0.bottom - top - Self.legendGap } ?? size.height - Self.laneBottomInset + let roomAbove = point.y - radius - laneTop + let roomBelow = laneBottom - (point.y + radius) let below: Bool if let kept = legendBelow { below = kept @@ -91,17 +113,24 @@ struct WayfindingOverlay: View { // The room on the legend's side, pinned at the edge nearer the ring, so the text's height // (it wraps, and grows with Dynamic Type) never moves its near edge onto the ring. let reach = max(0, (below ? roomBelow : roomAbove) - Self.legendGap) + // Each choice reports itself when it is the one drawn, so the legend counts as shown + // only where it drew. return ViewThatFits(in: .vertical) { legendText(text) - if let legendShort { legendText(legendShort) } + .onAppear { onLegendPlaced?(.besideRing) } + if let legendShort { + legendText(legendShort) + .onAppear { onLegendPlaced?(.besideRing) } + } Color.clear.frame(width: 0, height: 0) + .onAppear { onLegendPlaced?(.underCard) } } - .onAppear { - legendBelow = below - onLegendShown?() - } + .onAppear { legendBelow = below } .onChange(of: below) { _, side in legendBelow = side } - .onDisappear { legendBelow = nil } + .onDisappear { + legendBelow = nil + onLegendPlaced?(nil) + } .frame(width: width, height: reach, alignment: below ? .top : .bottom) .position( x: x, diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index 78c03b8b2..181cd5858 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -15,6 +15,14 @@ struct CameraChrome: View { /// Taps on the open camera area, in the camera view's coordinates (full screen, which is /// the window's global space). Nil when the screen has nothing to tap. var onCameraTap: ((CGPoint) -> Void)? + /// A line under the card about something drawn on the camera: the aim ring's legend where + /// it doesn't fit beside the ring (`WayfindingOverlay.LegendPlacement.underCard`). It is + /// part of the chrome, so it scrolls with the card at the largest text sizes instead of + /// being covered or dropped. + var legend: String? = nil + /// Set to the camera area left open between the card (with `legend`) and `bottom`, for + /// overlays that draw beside a point on the camera and must keep clear of both. + var openArea: Binding? = nil @ViewBuilder var bottom: Bottom var body: some View { @@ -51,14 +59,64 @@ struct CameraChrome: View { } } .frame(minHeight: 36) - InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow) + VStack(spacing: 8) { + InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow) + if let legend { + legendLine(legend) + } + } + .background { edgeReader { area, frame in area.top = frame.maxY } } Spacer(minLength: 0) bottom + .background { edgeReader { area, frame in area.bottom = frame.minY } } } .padding(.horizontal, Metrics.edge) .padding(.top, 4) .padding(.bottom, 8) } + + private func legendLine(_ text: String) -> some View { + Label(text, systemImage: "scope") + .font(Typeface.hint) + .foregroundStyle(Palette.chalk) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ScrimShape.rounded(14)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("aim.legend") + .transition(.opacity) + } + + /// Writes one edge of the open area from the frame of the view it is the background of, in + /// global coordinates (the camera layers fill the screen, as `CameraSizeReader` notes). + @ViewBuilder + private func edgeReader(_ write: @escaping (inout OpenCameraArea, CGRect) -> Void) -> some View { + if let openArea { + GeometryReader { proxy in + Color.clear + .onAppear { update(openArea, write, proxy.frame(in: .global)) } + .onChange(of: proxy.frame(in: .global)) { _, frame in update(openArea, write, frame) } + } + .accessibilityHidden(true) + } + } + + private func update( + _ binding: Binding, _ write: (inout OpenCameraArea, CGRect) -> Void, _ frame: CGRect + ) { + var area = binding.wrappedValue ?? OpenCameraArea(top: 0, bottom: .greatestFiniteMagnitude) + write(&area, frame) + if area != binding.wrappedValue { binding.wrappedValue = area } + } +} + +/// The camera area between the chrome's card and its controls, in global coordinates. The +/// top is below the bottom when the chrome fills the screen (the largest text sizes). +struct OpenCameraArea: Equatable { + var top: CGFloat + var bottom: CGFloat } /// Reads the full-screen camera view size so buttons can send "the reticle" (nil point) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index bc124b858..0fc27ae1c 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -174,11 +174,13 @@ enum ScanCopy { /// The line beside the walk's first aim ring, which fills as its stretch is captured (#81). /// A stretch counts only once it is seen from two places a step apart - /// (`CoverageConfig.coveringBaseline`, 0.25 m), and a view is kept only after about half a - /// meter of moving (`AutoCaptureConfig`), so holding still or shuffling a few inches never - /// fills the ring: the line asks for a whole step, in the words of the aim hint's title. - static let aimRingLegend = "Keep the ring in view and take one step to the side. It fills as your phone captures this spot." - /// The legend where the whole line doesn't fit beside the ring (large text sizes). + /// (`CoverageConfig.coveringBaseline`), so holding still never fills the ring: the line asks + /// for a step, in the tilt-down card's own words ("Take a small step sideways as you look."), + /// so the two lines on screen together ask for the same thing. If the card's wording + /// changes, change this with it. + static let aimRingLegend = "Keep the ring in view and take a small step sideways. It fills as your phone captures this spot." + /// The legend where the whole line doesn't fit beside the ring (large text sizes, short + /// screens). Under the card, the whole line is drawn instead. static let aimRingLegendShort = "It fills as your phone captures this spot." // MARK: Wall ends diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index bf6f1b010..04688962c 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -14,13 +14,17 @@ struct WallWalkScreen: View { @State private var cameraSize: CGSize = .zero @State private var trayOpen = false @State private var taps: [TapRipple.Ripple] = [] + /// The camera area between the card and the controls, for the aim ring's legend. + @State private var openArea: OpenCameraArea? = nil + /// The aim ring's legend while it doesn't fit beside the ring: drawn under the card (#81). + @State private var cardLegend: String? = nil @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ZStack { CameraSizeReader(size: $cameraSize) - CameraOverlays(state: state, highlight: nil) + CameraOverlays(state: state, highlight: nil, openArea: openArea, cardLegend: $cardLegend) if state.coaching == .relocalizing, let meterPhoto { // "Point at the meter like this.": the saved close-up shows what to aim at. SavedMeterPhoto(image: meterPhoto) @@ -45,7 +49,9 @@ struct WallWalkScreen: View { onCameraTap: state.marking == nil ? nil : { point in taps.append(.init(point: point)) actions.markFeaturePoint(at: point, viewSize: cameraSize) - } + }, + legend: cardLegend, + openArea: $openArea ) { VStack(spacing: 10) { controls diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 48aa40048..e210e0a98 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -423,8 +423,23 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(ring.waitForExistence(timeout: 5), "the aim ring must show its progress") XCTAssertEqual(ring.value as? String, "50 percent captured") let legend = element(app, "aim.legend") - XCTAssertTrue(legend.exists, "the first aim ring must come with its legend") + XCTAssertTrue(legend.waitForExistence(timeout: 5), "the first aim ring must come with its legend") XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)") + let card = element(app, "instruction") + XCTAssertFalse(legend.frame.intersects(card.frame), "the legend must keep clear of the card: \(legend.frame) vs \(card.frame)") + app.terminate() + + // At the largest text size the card and the controls fill the screen, so there is no + // room beside the ring: the legend moves under the card instead of going behind it or + // disappearing. + app.launchArguments = ["-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText + app.launch() + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + let largeLegend = element(app, "aim.legend") + XCTAssertTrue(largeLegend.waitForExistence(timeout: 5), "the legend must still show at the largest text size") + XCTAssertTrue(largeLegend.label.contains("It fills as your phone captures this spot"), "legend reads \(largeLegend.label)") + let largeCard = element(app, "instruction") + XCTAssertFalse(largeLegend.frame.intersects(largeCard.frame), "the legend must keep clear of the card: \(largeLegend.frame) vs \(largeCard.frame)") app.terminate() app.launchArguments = ["-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAimOffScreen"]