diff --git a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift index b3918f145..9900cd915 100644 --- a/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift +++ b/ios/HouseScan/Runtime/ScanViewState+AimProgress.swift @@ -1,18 +1,31 @@ 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? { + /// 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 - switch guidance { + switch step { case .aimAtGround(let at): band = .ground s = at @@ -22,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 d2ac09454..8fb4f7615 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -4,19 +4,50 @@ 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. @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,16 +68,19 @@ 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 + let line = held == nil ? legend(progress: progress) : nil WayfindingOverlay( projection: projection, wall: wall, path: state.path, target: state.target, progress: progress, - legend: legend(progress: progress), - onLegendShown: { - if legendStep == nil { legendStep = state.guidance } - } + completed: held, + legend: line, + legendShort: ScanCopy.aimRingLegendShort, + openArea: openArea, + onLegendPlaced: { placement in legendPlaced(placement, line) } ) .transition(.opacity) } @@ -54,9 +88,44 @@ 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 + } + + /// 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 } 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..c52dd6018 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,24 +14,47 @@ 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 + /// A shorter legend, drawn where `legend` doesn't fit between the ring and the card or the + /// controls. + var legendShort: String? = 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 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) } .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 { - legendView(legend, beside: point, radius: radius, in: size) + // 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, top: top) } } } @@ -38,21 +64,83 @@ 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, 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 + // 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. - private func legendView(_ text: String, beside point: CGPoint, radius: CGFloat, in size: CGSize) -> some View { + /// 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 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 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. + /// + /// 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 - 260 - let roomBelow = size.height - 300 - (point.y + radius) - let 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 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 + ? roomAbove - roomBelow < Self.legendSwitchMargin + : roomBelow - roomAbove >= Self.legendSwitchMargin + } else { + below = roomBelow >= roomAbove + } + // 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) + .onAppear { onLegendPlaced?(.besideRing) } + if let legendShort { + legendText(legendShort) + .onAppear { onLegendPlaced?(.besideRing) } + } + Color.clear.frame(width: 0, height: 0) + .onAppear { onLegendPlaced?(.underCard) } + } + .onAppear { legendBelow = below } + .onChange(of: below) { _, side in legendBelow = side } + .onDisappear { + legendBelow = nil + onLegendPlaced?(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) @@ -61,10 +149,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 { onLegendShown?() } - .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 @@ -104,6 +188,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 +210,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/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 b544aeb0a..0fc27ae1c 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -74,12 +74,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." - /// An aim step's card with what the hint adds. The title follows the target: on build 4.1 the /// chevron pointed up while the card said "Tilt down" (#81). Seen once from here, the step to /// the side is the whole instruction (#77). Too close, "step back" joins the same card @@ -176,6 +170,19 @@ enum ScanCopy { return Instruction(title: task.title, detail: detail) } + // 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, 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 /// 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 12f3da8e6..079f8c109 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -451,13 +451,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 548afa202..ea951755a 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -37,8 +37,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 3d685119b..8574656e9 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 ), 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"]