diff --git a/ios/HouseScan/UI/Components/EndAimCircle.swift b/ios/HouseScan/UI/Components/EndAimCircle.swift new file mode 100644 index 000000000..7cef095aa --- /dev/null +++ b/ios/HouseScan/UI/Components/EndAimCircle.swift @@ -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") + } +} diff --git a/ios/HouseScan/UI/Components/MovedReplyButton.swift b/ios/HouseScan/UI/Components/MovedReplyButton.swift new file mode 100644 index 000000000..312cc0d48 --- /dev/null +++ b/ios/HouseScan/UI/Components/MovedReplyButton.swift @@ -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 + } + } +} diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b981b38c6..d4cff3aad 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -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`), @@ -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 @@ -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) } + 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 } } @@ -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", diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 77d39f6db..0ca546ba1 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -26,11 +26,15 @@ final class DemoEngine: ScanActions { /// `-uiDemoUnusableAnswer `: 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. @@ -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 @@ -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 } } @@ -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 = 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 = 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: @@ -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 } diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 39d635e00..63577b486 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -35,13 +35,20 @@ struct GapRequestScreen: View { // The end lands under the circle, as on the walk's "Is this the right end?". It fades // with "Wall ends here" on the same spring, so the pair comes and goes together; // scoped to the circle, so nothing else on the camera animates with it. Under Reduce - // Motion only its opacity changes, briefly: nothing moves. + // Motion only its opacity changes, briefly: nothing moves. It is there only while + // offered, rather than kept at zero opacity: kept, its "Aiming circle" stayed in the + // accessibility tree under the end question, hidden or not (CI run 37171342665). if state.gap?.pastEndSide != nil { - Reticle(diameter: 56) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .opacity(offersEndMark ? 1 : 0) - .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: offersEndMark) + ZStack { + if offersEndMark { + Reticle(diameter: 56) + .endAimCircle() + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: offersEndMark) } if state.gap?.isSatisfied == true { SuccessBadge() @@ -52,7 +59,7 @@ struct GapRequestScreen: View { CameraChrome( instruction: instruction, tone: tone, - reply: state.gap?.isSatisfied == true || asking || askingEnd ? nil : InstructionCard.Reply( + reply: state.gap?.isSatisfied == true || asking || askingEnd || endAimFolds ? nil : InstructionCard.Reply( title: "I can't get there", identifier: "action.skipGap", hint: skipHint, @@ -61,7 +68,7 @@ struct GapRequestScreen: View { // as the phone moves: neither locks the reply again (InstructionCard.replyLock). task: state.gap.map { ScanCopy.gapTask($0) } ), - eyebrow: followUps > 0 && state.gap?.isSatisfied != true ? ScanCopy.followUp(remaining: followUps) : nil, + eyebrow: followUps > 0 && state.gap?.isSatisfied != true && !endAimFolds ? ScanCopy.followUp(remaining: followUps) : nil, photoCount: state.captureCount, lastCaptureID: state.lastCapture?.id, isReplay: state.isReplay, @@ -85,6 +92,9 @@ struct GapRequestScreen: View { if offersEndMark { markEndButton } + if endAimFolds { + declineButton + } } .animation(reduceMotion ? nil : Motion.settle, value: askingEnd) .animation(reduceMotion ? nil : Motion.settle, value: offersEndMark) @@ -174,6 +184,28 @@ struct GapRequestScreen: View { .transition(.opacity) } + /// "I can't get there" among the actions, under "Wall ends here", while the card folds around + /// the circle (`endAimFolds`): the same words, hint and action as the card's reply. + @ViewBuilder private var declineButton: some View { + if let gap = state.gap { + MovedReplyButton( + title: "I can't get there", hint: skipHint, identifier: "action.skipGap", + task: ScanCopy.gapTask(gap), perform: { actions.skipGap() }) + .transition(.opacity) + } + } + + /// At the largest text sizes while "Wall ends here" is offered, the card folds to a two-line + /// lead, as on the walk's end, so the circle in the middle of the camera stays open: the + /// request's words and its "One more view to finish" go under Details, and "I can't get + /// there" moves from the card to the actions. With either still on the card it reached past + /// the circle (`ScanCopy.endAimFold`). Not while coaching replaces the request: then the card + /// says why nothing can be aimed yet. + private var endAimFolds: Bool { + guard offersEndMark, typeSize.isAccessibilitySize else { return false } + return !(coaching.map(ScanCopy.coachingReplacesTask) ?? false) + } + /// A refusal on "Wall ends here" keeps its red triangle, as on the walk; otherwise coaching /// marks the card. private var tone: InstructionCard.Tone { @@ -212,9 +244,13 @@ struct GapRequestScreen: View { /// needs" reading or "tap I can't get there", tilting up to the roof or sky, and a server's /// own words, which this screen can't judge. Nor the overhead question, a request already /// seen, or coaching that replaces the request. + /// + /// A past_end request offering "Wall ends here" aims too, refusal or not (`endAimFolds`). private var aims: Bool { - guard let gap = state.gap, !asking, !askingEnd, refusal == nil, !gap.isSatisfied else { return false } + guard let gap = state.gap, !asking, !askingEnd, !gap.isSatisfied else { return false } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } + if offersEndMark { return true } + guard refusal == nil else { return false } if case .groundNearCandidate = gap.reason { return true } return false } @@ -224,6 +260,20 @@ struct GapRequestScreen: View { /// can stay up for a whole night request, and replacing the card hid the request and "Got it, /// thanks" for as long as it did (field test 4.1, run 3). private var instruction: Instruction { + var card = requestCard + guard endAimFolds else { return card } + // Folded around the circle, ride-along coaching leads or opens Details + // (`ScanCopy.foldedAroundCircle`), and "One more view to finish" leaves the card's top + // and leads the words under Details. + card = ScanCopy.foldedAroundCircle(card, coaching: coaching, refused: refusal != nil) + if followUps > 0, let folded = card.folded { + card.folded?.detail = "\(ScanCopy.followUp(remaining: followUps)). \(folded.detail)" + } + return card + } + + /// The card's words before folding around the circle moves the eyebrow (`instruction`). + private var requestCard: Instruction { if asking { return ScanCopy.overheadQuestion } if askingEnd, let side = state.endQuestion { return ScanCopy.endQuestion(side) } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching, meterPhoto: meterPhoto != nil) } diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index ae42499a6..699259dd0 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -34,6 +34,7 @@ struct WallWalkScreen: View { // asks for it) lands under the circle. if state.marking != nil || controlsKey == .nextWall || controlsKey == .markEnd { Reticle(diameter: 56) + .endAimCircle() .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() TapRipple(ripples: $taps) @@ -68,7 +69,10 @@ struct WallWalkScreen: View { ) } } - .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: controlsKey) + // Under Reduce Motion the controls swap at once: the swap changes the stack's height + // (the end's step adds its reply at the largest sizes), and an eased resize still + // moves the map under it. + .animation(reduceMotion ? nil : Motion.settle, value: controlsKey) } } .animation(.easeOut(duration: 0.2), value: showsMeterPhoto) @@ -80,6 +84,14 @@ struct WallWalkScreen: View { // MARK: Instruction private var instruction: Instruction { + // Folded around the circle, ride-along coaching leads or opens Details instead of sitting + // under the lead (`ScanCopy.foldedAroundCircle`). + guard endAimFolds else { return stepCard } + return ScanCopy.foldedAroundCircle(stepCard, coaching: coaching, refused: state.endMarkRefusal != nil || state.wallTooShort) + } + + /// The card's words for the step, before folding around the circle (`instruction`). + private var stepCard: Instruction { if let marking = state.marking { let prompt = ScanCopy.markingPrompt(marking) if let refusal = marking.refusal { @@ -99,7 +111,11 @@ struct WallWalkScreen: View { // Coaching about how the photos come out (the capture gate's, and too little texture) // rides along with the task (`ScanCopy.withCoaching`), and its symbol marks the card (`tone`). if state.wallTooShort { - return ScanCopy.withCoaching(Instruction(title: ScanCopy.wallTooShort, detail: ScanCopy.guidance(state.guidance).title), coaching) + let step = ScanCopy.guidance(state.guidance).title + // Folded around the circle (the walk can ask for the end again after the too-close + // ends were cleared), the long title would cover it: the correction leads instead. + let folded = Instruction.Folded(title: ScanCopy.wallTooShortLead, detail: "\(ScanCopy.wallTooShort) \(step)") + return ScanCopy.withCoaching(Instruction(title: ScanCopy.wallTooShort, detail: step, folded: folded), coaching) } return ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: state.guidanceHint), coaching) } @@ -136,21 +152,32 @@ struct WallWalkScreen: View { /// place, and a step to the side moves into the title when it is needed (`ScanCopy.aim`). /// Looking past an obstruction folds the other way: its action ("Look around it") leads and /// the situation and how go under Details (`Instruction.folded`), since unfolded at AX5 it - /// covered the camera and the spot it asks about. Not where the second line is the action - /// and nothing leads with it: a step back added to an aim step, the wall's end, whose title - /// only asks ("Aim where the wall stops"), marking the next wall. Nor a question, a mark, the - /// tray, coaching that replaces the task, or a refusal. + /// covered the camera and the spot it asks about. The wall's end folds the same way, around + /// the circle (`endAimFolds`). Not where the second line is the action and nothing leads with + /// it: a step back added to an aim step, marking the next wall. Nor a question, a mark, the + /// tray, coaching that replaces the task, or a refusal other than the end's. private var aims: Bool { switch controlsKey { case .walking, .finish: if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } if state.guidanceHint?.stepBack == true { return false } return tone != .refusal - case .markEnd, .nextWall, .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: + case .markEnd: + // Folds around the circle, refusal or not (`endAimFolds`). + if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } + return true + case .nextWall, .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: return false } } + /// At the largest text sizes on "Is this the right end of the wall?", the card folds to a + /// two-line lead ("Aim at the right end", or a refusal's correction) so the circle in the + /// middle of the camera stays open, and "The wall keeps going" moves from the card to the + /// actions under "Wall ends here": with it on the card, or the unfolded words, the card + /// reached past the circle (`ScanCopy.endAimFold`). As on a past_end request. + private var endAimFolds: Bool { controlsKey == .markEnd && aims && typeSize.isAccessibilitySize } + // MARK: Controls private enum ControlsKey: Hashable { @@ -338,6 +365,13 @@ struct WallWalkScreen: View { .accessibilityHint("Marks the end of the wall at the circle in the middle of the screen") .accessibilityIdentifier("action.markEnd") .transition(.opacity) + if endAimFolds, let copy = ScanCopy.reply(for: state.guidance) { + // The card's reply, moved here while the card folds around the circle. + MovedReplyButton( + title: copy.title, hint: copy.hint, identifier: "action.cannotAccess", + task: replyTask, perform: { actions.cannotAccessArea() }) + .transition(.opacity) + } case .walking where offersEndHere: Button { actions.endWallHere() @@ -403,7 +437,7 @@ struct WallWalkScreen: View { /// every step. private var reply: InstructionCard.Reply? { guard state.marking == nil, !state.endScanQuestion, state.endQuestion == nil, !state.overheadQuestion, state.nextWallConfirm == nil, !coachingHidesReply, !trayOpen, - let copy = ScanCopy.reply(for: state.guidance) else { return nil } + !endAimFolds, let copy = ScanCopy.reply(for: state.guidance) else { return nil } return InstructionCard.Reply( title: copy.title, identifier: "action.cannotAccess", diff --git a/ios/HouseScanUITests/EndAimCircleAssertions.swift b/ios/HouseScanUITests/EndAimCircleAssertions.swift new file mode 100644 index 000000000..01c9a1e0a --- /dev/null +++ b/ios/HouseScanUITests/EndAimCircleAssertions.swift @@ -0,0 +1,41 @@ +import XCTest + +/// The circle "Wall ends here" marks at (`endAimCircle`, identifier `aim.circle`), read from the +/// production layout: the app's own view, as VoiceOver sees it, not a test-only overlay. +extension XCTestCase { + /// Checks the circle is the 56 pt reticle drawn in the middle of the window, where the ray + /// "Wall ends here" uses goes through (`ScanEngine.circleEnd`: the middle of the sensor image, + /// which the camera view fills and centres), is wholly on screen, and is under neither the + /// card nor any of `covers`. + /// + /// The card ends at its last part plus the scrim's padding under it (`InstructionCard`): + /// unfolded, the reply (`reply`), 12 pt above the scrim's edge; folded around the circle, + /// Details, 8 pt above it, since the reply has moved to the actions. + @MainActor + func assertEndCircleOpen( + _ app: XCUIApplication, folded: Bool, reply: String, covers: [String], _ context: String, + file: StaticString = #filePath, line: UInt = #line + ) { + func element(_ identifier: String) -> XCUIElement { app.descendants(matching: .any)[identifier].firstMatch } + let window = app.windows.firstMatch.frame + let circle = element("aim.circle") + XCTAssertTrue(circle.waitForExistence(timeout: 5), "\(context): no aiming circle", file: file, line: line) + let frame = circle.frame + XCTAssertEqual(frame.width, 56, accuracy: 1, "\(context): the aiming circle is \(frame.size), not the 56 pt reticle", file: file, line: line) + XCTAssertEqual(frame.height, 56, accuracy: 1, "\(context): the aiming circle is \(frame.size), not the 56 pt reticle", file: file, line: line) + XCTAssertEqual(frame.midX, window.midX, accuracy: 1, "\(context): the circle \(frame) must be the middle of the camera", file: file, line: line) + XCTAssertEqual(frame.midY, window.midY, accuracy: 1, "\(context): the circle \(frame) must be the middle of the camera", file: file, line: line) + XCTAssertTrue(window.contains(frame), "\(context): the circle \(frame) is not wholly on screen", file: file, line: line) + + let parts = (folded ? ["instruction", "instruction.details"] : ["instruction", reply]).map(element).filter(\.exists) + let padding: CGFloat = folded ? 8 : 12 + if let last = parts.map(\.frame.maxY).max() { + XCTAssertGreaterThanOrEqual(frame.minY, last + padding, "\(context): the circle \(frame) is under the card, which ends at \(last + padding)", file: file, line: line) + } + for identifier in covers { + let cover = element(identifier) + guard cover.exists else { continue } + XCTAssertFalse(frame.intersects(cover.frame), "\(context): the circle \(frame) is under \(identifier) \(cover.frame)", file: file, line: line) + } + } +} diff --git a/ios/HouseScanUITests/PastEndRecoveryUITests.swift b/ios/HouseScanUITests/PastEndRecoveryUITests.swift index ccd9a7894..14d2250ab 100644 --- a/ios/HouseScanUITests/PastEndRecoveryUITests.swift +++ b/ios/HouseScanUITests/PastEndRecoveryUITests.swift @@ -32,21 +32,118 @@ final class PastEndRecoveryUITests: XCTestCase { try offersWallEndsHere(textSize: Self.largestText, name: "gapRequest-pastEnd-AX5") } + @MainActor + func testAPastEndRequestOnTheLeftKeepsTheCircleOpenAtLargestTextSize() throws { + let app = launch(["-uiDemoGap", "pastEndLeft"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Keep walking left"), "the folded lead names the side: \(card)") + let mark = app.buttons["action.markEnd"] + XCTAssertTrue(mark.waitForExistence(timeout: 5)) + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") + assertEndCircleOpen(app, folded: true, reply: "action.skipGap", covers: Self.endCovers, "left past-end, AX5") + snap(app, "gapRequest-pastEndLeft-AX5") + } + @MainActor private func offersWallEndsHere(textSize: [String], name: String) throws { + let folded = !textSize.isEmpty let app = launch(["-uiDemoGap", "pastEnd"] + textSize) let card = label(app, "instruction") - XCTAssertTrue(card.contains("Keep walking past the right end"), "the request's own words: \(card)") - XCTAssertTrue(card.contains("If the wall stops sooner, aim where it stops and tap Wall ends here."), "card reads: \(card)") + if folded { + // Folded around the circle: walking on leads; the request's words, the eyebrow and + // the way to mark the end are under Details. + XCTAssertTrue(card.contains("Keep walking right"), "card reads: \(card)") + XCTAssertFalse(card.contains("If the wall stops sooner"), "the how-to words must fold under Details: \(card)") + XCTAssertFalse(card.contains("One more view to finish"), "the eyebrow must fold under Details: \(card)") + } else { + XCTAssertTrue(card.contains("One more view to finish"), "card reads: \(card)") + XCTAssertTrue(card.contains("Keep walking past the right end"), "the request's own words: \(card)") + XCTAssertTrue(card.contains("If the wall stops sooner, aim where it stops and tap Wall ends here."), "card reads: \(card)") + } XCTAssertTrue(element(app, "action.skipGap").exists, "\"I can't get there\" stays the decline") let mark = app.buttons["action.markEnd"] XCTAssertTrue(mark.waitForExistence(timeout: 5), "a past-end request must offer Wall ends here") XCTAssertEqual(mark.label, "Wall ends here") scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") + // Where "Wall ends here" can be pressed, the circle it marks at is in view. + assertEndCircleOpen(app, folded: folded, reply: "action.skipGap", covers: Self.endCovers, name) snap(app, name) + guard folded else { return } + + // Details opens every word of the unfolded card, and closes back to the open circle. + scrollToTop(app) + let details = app.buttons["instruction.details"] + XCTAssertTrue(details.waitForExistence(timeout: 5), "the folded card needs Details") + tapWhenReady(details) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the folded words") + for words in ["One more view to finish.", "Show the ground", "Keep walking past the right end", "If the wall stops sooner, aim where it stops and tap Wall ends here."] { + XCTAssertTrue(detail.label.contains(words), "Details must hold \"\(words)\": \(detail.label)") + } + snap(app, "\(name)-details") + tapWhenReady(details) + XCTAssertTrue(waitForAbsence(detail), "Details must close again") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: true, reply: "action.skipGap", covers: Self.endCovers, "\(name), Details closed") + // The decline moved from the card to the actions, and stays within reach. + let decline = app.buttons["action.skipGap"] + scrollIntoView(decline, in: app) + XCTAssertTrue(decline.isHittable, "I can't get there can't be reached") + XCTAssertEqual(decline.label, "I can't get there") + XCTAssertGreaterThan(decline.frame.minY, mark.frame.maxY - 0.5, "the decline sits under Wall ends here") } + /// Coaching riding along leads the folded card in a few words, and its whole note opens + /// Details: under the lead, the dark note's lines reached the circle. + @MainActor + func testCoachingOnAFoldedPastEndCardKeepsTheCircleOpen() throws { + let app = launch(["-uiDemoGap", "pastEnd", "-uiDemoCoaching", "tooDark"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Try your flashlight"), "card reads: \(card)") + XCTAssertFalse(card.contains("Try your phone's flashlight"), "the coaching's note must fold under Details: \(card)") + let mark = app.buttons["action.markEnd"] + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: true, reply: "action.skipGap", covers: Self.endCovers, "coached past-end, AX5") + snap(app, "gapRequest-pastEnd-tooDark-AX5") + scrollToTop(app) + tapWhenReady(app.buttons["instruction.details"]) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5)) + for words in ["It's dark here. Try your phone's flashlight", "Keep walking past the right end", "If the wall stops sooner"] { + XCTAssertTrue(detail.label.contains(words), "Details must hold \"\(words)\": \(detail.label)") + } + } + + /// The circle on the other side of the meter, on either side: the folded card leads with + /// which end to face, and the circle stays open. + @MainActor + func testTheOtherSideRefusalFoldsOnBothSides() throws { + for (gap, asked, landed) in [("pastEnd", "right", "left"), ("pastEndLeft", "left", "right")] { + let app = launch(["-uiDemoGap", gap, "-uiDemoEndMarkOtherSide"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Face the \(asked) end"), "\(gap): card reads: \(card)") + let mark = app.buttons["action.markEnd"] + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: true, reply: "action.skipGap", covers: Self.endCovers, "\(gap) other side, AX5") + snap(app, "gapRequest-\(gap)-otherSide-AX5") + scrollToTop(app) + tapWhenReady(app.buttons["instruction.details"]) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5)) + XCTAssertTrue(detail.label.contains("That's the \(landed) side of your meter. Turn to the \(asked) end"), "\(gap): Details reads: \(detail.label)") + app.terminate() + } + } + + /// What may cover the circle: the actions and the card's reply. The card itself is checked by + /// its bottom edge (`assertEndCircleOpen`). + private static let endCovers = ["action.markEnd", "action.skipGap", "action.showResult", "action.endCorner", "action.endBlocked", "action.endEnds"] + /// The circle off the wall: the card says why and what to do, the decline stays, and pressing /// again with the circle still off the wall refuses again rather than marking anything. @MainActor @@ -61,18 +158,33 @@ final class PastEndRecoveryUITests: XCTestCase { @MainActor private func refusal(textSize: [String], name: String) throws { + let folded = !textSize.isEmpty let app = launch(["-uiDemoGap", "pastEnd", "-uiDemoEndMarkRefusal"] + textSize) + // Folded, the correction leads and the reason goes under Details. + let says = folded ? "Aim at the wall" : "The circle isn't on the wall" var card = label(app, "instruction") - XCTAssertTrue(card.contains("The circle isn't on the wall"), "card reads: \(card)") - XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") + XCTAssertTrue(card.contains(says), "card reads: \(card)") + if folded { + XCTAssertFalse(card.contains("The circle isn't on the wall"), "the reason must fold under Details: \(card)") + } else { + XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") + } XCTAssertTrue(element(app, "action.skipGap").exists) - snap(app, name) let mark = app.buttons["action.markEnd"] scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: folded, reply: "action.skipGap", covers: Self.endCovers, name) + snap(app, name) tapWhenReady(mark) card = label(app, "instruction") - XCTAssertTrue(card.contains("The circle isn't on the wall"), "a second press marked something: \(card)") + XCTAssertTrue(card.contains(says), "a second press marked something: \(card)") XCTAssertFalse(element(app, "action.endBlocked").exists, "no end was marked, so nothing to ask") + guard folded else { return } + scrollToTop(app) + tapWhenReady(app.buttons["instruction.details"]) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5)) + XCTAssertTrue(detail.label.contains("The circle isn't on the wall. Aim it at the wall where it stops or turns, then tap Wall ends here."), "Details reads: \(detail.label)") } /// "Wall ends here" asks the walk's end question; it replaces the request and its decline @@ -101,6 +213,10 @@ final class PastEndRecoveryUITests: XCTestCase { for answer in ["action.endCorner", "action.endBlocked", "action.endEnds"] { XCTAssertTrue(app.buttons[answer].exists, "\(answer) missing") } + // In their order, and the question needs no circle. + let tops = ["action.endCorner", "action.endBlocked", "action.endEnds"].map { app.buttons[$0].frame.minY } + XCTAssertEqual(tops, tops.sorted(), "the answers moved out of order: \(tops)") + XCTAssertFalse(element(app, "aim.circle").exists, "the question shows no circle") snap(app, name) let blocked = app.buttons["action.endBlocked"] scrollIntoView(blocked, in: app) @@ -385,6 +501,23 @@ final class PastEndRecoveryUITests: XCTestCase { } } + /// Drags the screen down until its top shows. + @MainActor + private func scrollToTop(_ app: XCUIApplication) { + let scroll = app.scrollViews.firstMatch + guard scroll.exists else { return } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)) + for _ in 0..<4 { + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: app.windows.firstMatch.frame.height * 0.4))) + } + } + + @MainActor + private func waitForAbsence(_ target: XCUIElement) -> Bool { + let gone = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: target) + return XCTWaiter().wait(for: [gone], timeout: 5) == .completed + } + @MainActor private func snap(_ app: XCUIApplication, _ name: String) { let shot = XCTAttachment(screenshot: app.screenshot()) diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift index 3f2b45f07..e13626a39 100644 --- a/ios/HouseScanUITests/WalkRecoveryUITests.swift +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -16,6 +16,7 @@ final class WalkRecoveryUITests: XCTestCase { func testTheEndCardAnswersThatTheWallKeepsGoing() throws { let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd"]) XCTAssertTrue(label(app, "instruction").contains("Is this the right end of the wall?")) + assertEndCircleOpen(app, folded: false, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end") snap(app, "wallWalk-markEnd-keepsGoing") XCTAssertTrue(element(app, "action.markEnd").exists, "Wall ends here must stay on offer") let keepsGoing = reply(app, "The wall keeps going") @@ -39,6 +40,105 @@ final class WalkRecoveryUITests: XCTestCase { XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") XCTAssertTrue(element(app, "action.markEnd").exists) XCTAssertTrue(reply(app, "The wall keeps going").waitForExistence(timeout: 5)) + assertEndCircleOpen(app, folded: false, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end refused") + } + + /// At AX5 the card covered the circle "Wall ends here" marks at (CI run 37165788075, + /// `wallWalk-markEnd-AX5-stacked`). Folded, it leads with where to aim, every word is under + /// Details, and "The wall keeps going" moves to the actions under "Wall ends here", so with + /// "Wall ends here" in reach the circle is in view. + @MainActor + func testTheEndCircleStaysOpenAtLargestTextSize() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Aim at the right end"), "card reads: \(card)") + XCTAssertFalse(card.contains("Is this the right end of the wall?"), "the question folds under Details: \(card)") + let mark = element(app, "action.markEnd") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") + assertEndCircleOpen(app, folded: true, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end, AX5") + snap(app, "wallWalk-markEnd-AX5") + + scrollToTop(app) + let details = element(app, "instruction.details") + tapWhenReady(details) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the folded words") + XCTAssertTrue( + detail.label.contains("Is this the right 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."), + "Details reads: \(detail.label)") + snap(app, "wallWalk-markEnd-AX5-details") + tapWhenReady(details) + let gone = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: detail) + XCTAssertEqual(XCTWaiter().wait(for: [gone], timeout: 5), .completed, "Details must close again") + scrollIntoView(mark, in: app) + assertEndCircleOpen(app, folded: true, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end, AX5, Details closed") + + let keepsGoing = reply(app, "The wall keeps going") + XCTAssertTrue(keepsGoing.waitForExistence(timeout: 5), "the wall that goes on still needs its answer") + scrollIntoView(keepsGoing, in: app) + XCTAssertTrue(keepsGoing.isHittable, "The wall keeps going can't be reached") + XCTAssertGreaterThan(keepsGoing.frame.minY, mark.frame.maxY - 0.5, "the answer sits under Wall ends here") + } + + /// Folded, a refusal leads with what to do, and keeps the circle in view. + @MainActor + func testARefusedWallEndKeepsTheCircleOpenAtLargestTextSize() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd", "-uiDemoEndMarkRefusal"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Aim at the wall"), "card reads: \(card)") + let mark = element(app, "action.markEnd") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: true, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end refused, AX5") + snap(app, "wallWalk-markEnd-refused-AX5") + } + + /// The walk's end with coaching riding along: the coaching leads in a few words, its note + /// opens Details, and the circle stays open. + @MainActor + func testCoachingOnTheFoldedEndCardKeepsTheCircleOpen() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd", "-uiDemoCoaching", "tooDark"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Try your flashlight"), "card reads: \(card)") + XCTAssertFalse(card.contains("Try your phone's flashlight"), "the coaching's note must fold under Details: \(card)") + let mark = element(app, "action.markEnd") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: true, reply: "action.cannotAccess", covers: Self.endCovers, "coached walk mark-end, AX5") + snap(app, "wallWalk-markEnd-tooDark-AX5") + } + + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + /// What may cover the circle on the walk's end: its actions and the card's reply. + private static let endCovers = ["action.markEnd", "action.markSomething", "action.cannotAccess"] + + /// Scrolls in measured steps until the whole element is in the window: at the largest text + /// sizes the actions sit below the card, reached by scrolling. + @MainActor + private func scrollIntoView(_ target: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let scroll = app.scrollViews.firstMatch + guard scroll.exists else { return } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + for _ in 0..<12 where !window.contains(target.frame) { + let frame = target.frame + let shift: CGFloat = frame.maxY > window.maxY + ? -min(frame.maxY - window.maxY + 24, window.height / 3) + : min(window.minY - frame.minY + 24, window.height / 3) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: shift))) + } + } + + /// Drags the screen down until its top shows. + @MainActor + private func scrollToTop(_ app: XCUIApplication) { + let scroll = app.scrollViews.firstMatch + guard scroll.exists else { return } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)) + for _ in 0..<4 { + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: app.windows.firstMatch.frame.height * 0.4))) + } } /// B-23: "Point at the meter like this." needs the photo it points to. The follow-up view