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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions ios/HouseScan/Runtime/ScanViewState+AimProgress.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
Expand Down
81 changes: 75 additions & 6 deletions ios/HouseScan/UI/Camera/CameraOverlays.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>? = 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<Float>? = 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
}
}

Expand All @@ -37,26 +68,64 @@ 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)
}
}
}
}

/// 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<Float>? {
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<Float>? {
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<Float>?
}
148 changes: 123 additions & 25 deletions ios/HouseScan/UI/Camera/WayfindingOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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<Float>? = 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)
}
}
}
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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<Float>, 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.
Expand All @@ -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
Expand Down
Loading