Skip to content
Open
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
16 changes: 16 additions & 0 deletions ios/HouseScan/UI/Components/EndAimCircle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import SwiftUI

extension View {
/// The circle a mark lands under (`Reticle`), as one accessibility element: "Aiming circle".
/// The reticle itself is hidden from VoiceOver as decoration. Here it says where "Wall ends
/// here" and the other marks land, and UI tests read its real frame to check that nothing
/// covers it (`PastEndRecoveryUITests`, `WalkRecoveryUITests`). Apply it before the reticle
/// is stretched to the screen, so the element's frame is the circle's. Show the circle only
/// while it is offered: `.accessibilityHidden` on this element left it in the tree at zero
/// opacity (CI run 37171342665), so VoiceOver would stop on a circle that isn't there.
func endAimCircle() -> some View {
accessibilityElement(children: .ignore)
.accessibilityLabel("Aiming circle")
.accessibilityIdentifier("aim.circle")
}
}
37 changes: 37 additions & 0 deletions ios/HouseScan/UI/Components/MovedReplyButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import SwiftUI

/// A card's reply ("I can't get there", "The wall keeps going") shown among the actions instead,
/// while the card folds around the circle at the largest text sizes (`GapRequestScreen`,
/// `WallWalkScreen`). It keeps the card reply's input lock: after its task changes it takes no
/// taps for `InstructionCard.replyLock`, so a tap meant for the step that just left can't answer
/// this one (#82). Coaching or a refusal within the same task keeps it unlocked, as on the card.
/// The secondary style doesn't read `isEnabled`, so the lock doesn't change how it looks;
/// VoiceOver and UI tests read it as dimmed.
struct MovedReplyButton: View {
let title: String
let hint: String
let identifier: String
/// The step the reply answers; a new one locks the button again.
let task: Instruction
let perform: () -> Void

@State private var unlockedTask: Instruction?

var body: some View {
Button(action: perform) {
Text(title)
.frame(maxWidth: .infinity)
}
.buttonStyle(.secondary)
.disabled(unlockedTask != task)
.accessibilityHint(hint)
.accessibilityIdentifier(identifier)
.task(id: task) {
// Cleared first, so a task that comes back within the lock is locked again.
let shown = task
unlockedTask = nil
do { try await Task.sleep(for: InstructionCard.replyLock) } catch { return }
unlockedTask = shown
}
}
}
77 changes: 69 additions & 8 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ enum ScanCopy {
case .markEnd(let side):
Instruction(
title: "Is this the \(side.rawValue) end of the wall?",
detail: "Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going."
detail: "Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going.",
folded: endAimFold("Aim at the \(side.rawValue) end", "Is this the \(side.rawValue) end of the wall? Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going.")
)
case .aimAtGround(let s):
// A cell counts once seen from two places at least 0.25 m apart (`coveringBaseline`),
Expand Down Expand Up @@ -251,6 +252,9 @@ enum ScanCopy {
/// Over the walk's own prompt after "Done with this wall" was refused and the ends cleared
/// (`ScanViewState.wallTooShort`).
static let wallTooShort = "The ends were too close. Walk along the wall first."
/// `wallTooShort` on a card folded around the circle, as the walk's end can be when both ends
/// were cleared: two lines beside the refusal triangle, the rest under Details.
static let wallTooShortLead = "Walk the wall first"

// MARK: Close-up

Expand Down Expand Up @@ -389,20 +393,73 @@ enum ScanCopy {
static let wallKeepsGoing = "The wall keeps going"

/// Why "Wall ends here" marked nothing, and what to do instead (`EndMarkRefusal`).
/// Folded at the largest text sizes, each refusal leads with what to do about it, and its
/// reason and the full words go under Details (`endAimFold`).
static func endMarkRefusal(_ refusal: EndMarkRefusal, asked side: WallSide) -> Instruction {
switch refusal {
case .noWall:
Instruction(title: "The circle isn't on the wall", detail: "Aim it at the wall where it stops or turns, then tap Wall ends here.")
Instruction(
title: "The circle isn't on the wall", detail: "Aim it at the wall where it stops or turns, then tap Wall ends here.",
folded: endAimFold("Aim at the wall", "The circle isn't on the wall. Aim it at the wall where it stops or turns, then tap Wall ends here."))
case .otherSide(let landed):
Instruction(title: "That's the \(landed.rawValue) side of your meter", detail: "Turn to the \(side.rawValue) end, then tap Wall ends here.")
Instruction(
title: "That's the \(landed.rawValue) side of your meter", detail: "Turn to the \(side.rawValue) end, then tap Wall ends here.",
folded: endAimFold("Face the \(side.rawValue) end", "That's the \(landed.rawValue) side of your meter. Turn to the \(side.rawValue) end, then tap Wall ends here."))
case .trackingNotReady:
Instruction(title: "One moment, your phone is still finding its place", detail: "Then aim at the \(side.rawValue) end.")
Instruction(
title: "One moment, your phone is still finding its place", detail: "Then aim at the \(side.rawValue) end.",
folded: endAimFold("Wait a moment", "Your phone is still finding its place. Then aim at the \(side.rawValue) end."))
case .tooLittleWall:
// Says what the app can't do, not where to aim: the homeowner must not be nudged to
// mark an end farther than the wall really goes. "I can't get there" stays the way on.
// The app's own minimum (`WallFrame.minWallLength`), not a measured installation rule:
// no reason is given for it, and rule distances belong to the server.
Instruction(title: "House Scan can't use a wall that short", detail: "If this is where the wall ends, tap I can't get there to continue.")
// no reason is given for it, and rule distances belong to the server. Folded, the lead
// says only that; aiming elsewhere is right only if the circle was off the real end,
// so it doesn't lead.
Instruction(
title: "House Scan can't use a wall that short", detail: "If this is where the wall ends, tap I can't get there to continue.",
folded: endAimFold("Too short to use", "House Scan can't use a wall that short. If this is where the wall ends, tap I can't get there to continue."))
}
}

/// The words of a step that marks the wall's end at the circle, folded at the largest text
/// sizes (`InstructionCard.foldsDetail`): a lead short enough to stay two lines, even beside
/// the refusal triangle, and every word of the unfolded card under Details. Unfolded, the
/// card covered the circle in the middle of the camera at AX5 (CI run 37165788075, iPhone
/// 17): the circle's top is 28 pt above the middle, and a two-line card without its reply
/// ends above it, where one with a third line or the reply doesn't (`GapRequestScreen`,
/// `WallWalkScreen` move the reply to their actions at those sizes).
static func endAimFold(_ lead: String, _ everything: String) -> Instruction.Folded {
Instruction.Folded(title: lead, detail: everything)
}

/// A folded end-aim card with coaching riding along (`withCoaching`). Its note under the lead
/// would reach the circle again: the long dark note is several lines at AX5, and even one
/// line uses the room above the circle. So the coaching leads instead, in a few words beside
/// its symbol, and its whole note opens Details. With a refusal, the refusal's correction
/// keeps the lead and the coaching opens Details. Only for a card that folds; coaching that
/// replaces the task isn't folded at all.
static func foldedAroundCircle(_ card: Instruction, coaching: Coaching?, refused: Bool) -> Instruction {
guard let coaching, !coachingReplacesTask(coaching), var folded = card.folded else { return card }
var out = card
out.note = nil
folded.detail = "\(coachingNote(coaching)) \(folded.detail)"
if !refused { folded.title = coachingLead(coaching) }
Comment thread
greptile-apps[bot] marked this conversation as resolved.
out.folded = folded
return out
}

/// Coaching in a few words, for a lead that must stay two lines beside its symbol at AX5: what
/// to do, since the symbol already says what is wrong and darkness can last a whole walk.
static func coachingLead(_ coaching: Coaching) -> String {
switch coaching {
case .slowDown: "Slow down"
case .needsTexture: "Find more texture"
case .tooDark: "Try your flashlight"
case .tooDarkToMeasure: "Try in daylight"
case .holdSteady: "Hold steady"
case .turnSlowly: "Turn more slowly"
case .initializing, .relocalizing, .trackingLost, .pastWallEnd: ScanCopy.coaching(coaching).title
}
}

Expand Down Expand Up @@ -462,8 +519,12 @@ enum ScanCopy {
let title = gap.band == .ground ? "Show the ground \(stretch)" : "Show the wall \(stretch)"
// A past_end request asks to walk on past an end; the wall may really stop before
// that, and the screen offers the walk's own "Wall ends here" for it (B-12).
guard gap.pastEndSide != nil else { return Instruction(title: title, detail: detail) }
return Instruction(title: title, detail: "\(detail) \(pastEndAlternative)")
guard let side = gap.pastEndSide else { return Instruction(title: title, detail: detail) }
// Folded, walking on leads, as the request asks; marking the end is under Details,
// with the circle and "Wall ends here" in view.
return Instruction(
title: title, detail: "\(detail) \(pastEndAlternative)",
folded: endAimFold("Keep walking \(side.rawValue)", "\(title). \(detail) \(pastEndAlternative)"))
case .groundOut(let out):
return Instruction(
title: "Show the ground out to about \(Distance.feetAtLeast(out)) from the wall",
Expand Down
55 changes: 37 additions & 18 deletions ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ final class DemoEngine: ScanActions {
/// `-uiDemoUnusableAnswer <n>`: how many answers in a row House Scan can't use; 0 when off.
private let unusableAnswers: Int
/// Which request the gap screen shows (`-uiDemoGap`); the phone's ground request by default.
/// `pastEnd` is a server past_end request on the right, with "Wall ends here" (B-12).
/// `pastEnd` is a server past_end request on the right, with "Wall ends here" (B-12);
/// `pastEndLeft` the same on the left.
private let gapKind: String?
/// `-uiDemoEndMarkRefusal`: "Wall ends here" was pressed with the circle off the wall, and the
/// demo's circle stays off it, so pressing again refuses again.
private let endMarkRefused: Bool
/// `-uiDemoEndMarkOtherSide`: as `-uiDemoEndMarkRefusal`, but the circle is on the wall on
/// the meter's other side (`EndMarkRefusal.otherSide`).
private let endMarkOtherSide: Bool
/// The tilt-up step was answered or skipped.
private var tiltUpSettled = false
/// Walk-script ticks spent on the tilt-up step, standing in for the phone being tilted up.
Expand Down Expand Up @@ -88,7 +92,8 @@ final class DemoEngine: ScanActions {
rejectUpload = arguments.contains("-uiDemoRejected")
unusableAnswers = arguments.contains("-uiDemoUnusableAnswer") ? max(value("-uiDemoUnusableAnswer").flatMap(Int.init) ?? 1, 1) : 0
gapKind = value("-uiDemoGap")
endMarkRefused = arguments.contains("-uiDemoEndMarkRefusal")
endMarkOtherSide = arguments.contains("-uiDemoEndMarkOtherSide")
endMarkRefused = arguments.contains("-uiDemoEndMarkRefusal") || endMarkOtherSide
state.feed = DemoScene.image.map(CameraFeed.still) ?? .none
state.isReplay = true
state.tracking = .normal
Expand Down Expand Up @@ -128,9 +133,9 @@ final class DemoEngine: ScanActions {
reachedRight = demoRightEnd
refreshCoverage()
refreshGuidance()
if arguments.contains("-uiDemoEndMarkRefusal") {
// As in the real engine, a circle off the wall has no end preview (`circleEnd`).
state.endMarkRefusal = .noWall
if endMarkRefused {
// As in the real engine, a refused circle has no end preview (`circleEnd`).
state.endMarkRefusal = demoRefusal(asked: .right)
state.endPreview = nil
}
}
Expand Down Expand Up @@ -422,25 +427,33 @@ final class DemoEngine: ScanActions {
state.gap?.band = .wall
state.target = DemoScene.wall.world(s: 1.7, height: 3.0)
state.path = []
case "pastEnd":
// The check asks to walk on past the right end, which it clears, as the real engine's
case "pastEnd", "pastEndLeft":
// The check asks to walk on past an end, which it clears, as the real engine's
// `beginServerGap` does; the wall may stop sooner, so "Wall ends here" is offered.
let end = demoRightEnd
let past: ClosedRange<Float> = end...(end + 0.6)
state.wall?.rightEnd = nil
let side: WallSide = gapKind == "pastEndLeft" ? .left : .right
let sign: Float = side == .right ? 1 : -1
let end = side == .right ? demoRightEnd : demoLeftEnd
let past: ClosedRange<Float> = side == .right ? end...(end + 0.6) : (end - 0.6)...end
if side == .right { state.wall?.rightEnd = nil } else { state.wall?.leftEnd = nil }
state.gap?.origin = .server
state.gap?.reason = .server(detail: "Keep walking past the right end of the scan (14 ft 1 in right of the meter): a spot within reach may be there.")
state.gap?.reason = .server(detail: side == .right
? "Keep walking past the right end of the scan (14 ft 1 in right of the meter): a spot within reach may be there."
: "Keep walking past the left end of the scan (9 ft 6 in left of the meter): a spot within reach may be there.")
state.gap?.span = past
state.gap?.pastEndSide = .right
state.target = DemoScene.wall.world(s: end + 0.3, height: 0, out: 0.5)
state.path = DemoScene.path(toward: end + 0.3)
state.gap?.pastEndSide = side
// The check sends a past_end request with its answer, so the card says "One more view
// to finish" over it, as the real engine's does (`GapRequestScreen.followUps`).
state.result = sample
state.followUps = 1
state.target = DemoScene.wall.world(s: end + sign * 0.3, height: 0, out: 0.5)
state.path = DemoScene.path(toward: end + sign * 0.3)
if endMarkRefused {
// As in the real engine, a circle off the wall has no end preview (`circleEnd`).
state.endMarkRefusal = .noWall
// As in the real engine, a refused circle has no end preview (`circleEnd`).
state.endMarkRefusal = demoRefusal(asked: side)
state.endPreview = nil
} else {
// The circle on the wall a little before the old end: where the wall really stops.
state.endPreview = EndPreview(side: .right, s: end - 0.4, atReticle: true)
state.endPreview = EndPreview(side: side, s: end - sign * 0.4, atReticle: true)
}
return
default:
Expand Down Expand Up @@ -880,10 +893,16 @@ final class DemoEngine: ScanActions {
}
}

/// Why the demo's "Wall ends here" marks nothing: the circle off the wall, or on the wall on
/// the other side of the meter from the end `asked` (`-uiDemoEndMarkOtherSide`).
private func demoRefusal(asked: WallSide) -> EndMarkRefusal {
endMarkOtherSide ? .otherSide(asked == .right ? .left : .right) : .noWall
}

func markWallEnd(at point: CGPoint?, viewSize: CGSize) {
if state.phase == .gapRequest, let side = state.gap?.pastEndSide, state.gap?.isSatisfied == false, state.endQuestion == nil {
if endMarkRefused {
state.endMarkRefusal = .noWall
state.endMarkRefusal = demoRefusal(asked: side)
state.endPreview = nil
return
}
Expand Down
Loading
Loading