diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index a22dd26d8..17eeaa4d9 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -1,7 +1,7 @@ import SwiftUI -/// Three short pages before the camera opens: what happens and how long, how the photos work, -/// and staying safe. The last page asks for the camera. +/// Four short pages before the camera opens: what happens and how long, the moves the walk asks +/// for, how the photos work, and staying safe. The last page asks for the camera. struct OnboardingScreen: View { let state: ScanViewState let actions: any ScanActions @@ -15,6 +15,15 @@ struct OnboardingScreen: View { body: "Walk along the wall by your electric meter for about 2 minutes. Your phone measures as you go.", art: .walk ), + // Testers met each move first as a card they couldn't decode (#85, #77, #81): this page + // names them before the camera opens. The first two come first (`GuidancePlanner`); after + // that the card asks for whatever the walk still needs, so no fixed order is promised. + OnboardingPage( + title: "What the walk asks for", + body: "A card at the top of the screen asks for one of these at a time. After the first two, it asks for whatever the walk still needs.", + moves: OnboardingScreen.moves, + art: .moves + ), // What leaves the phone is said before the camera is asked for: uploads carry the wall's // measurements; the photos stay in the app until the scan is started over. OnboardingPage( @@ -97,12 +106,50 @@ struct OnboardingScreen: View { } .background(Palette.canvas.ignoresSafeArea()) } + + /// The moves page quotes the walk's own cards (`ScanCopy`), so a homeowner recognises each + /// card as something they were told about (#85). "Wall ends here" is the button's label + /// (`WallWalkScreen`). If the cards change, read these again. + private static var moves: [Instruction] { + let meter = ScanCopy.guidance(.holdOnMeter) + let walk = ScanCopy.guidance(.walk(side: .right, remaining: nil)) + let stepBack = ScanCopy.guidance(.stepBack) + let ground = ScanCopy.guidance(.aimAtGround(s: 0)) + let wall = ScanCopy.guidance(.aimAtWall(s: 0)) + let end = ScanCopy.guidance(.markEnd(side: .right)) + func sentences(_ parts: String?...) -> String { + parts.compactMap(\.self).joined(separator: " ") + } + return [ + Instruction(title: "Aim at your meter", detail: meter.detail), + // The ground by the meter comes before the walk (`GuidancePlanner.preferredTask`). + // A ground cell counts only once it is seen from two places a step apart + // (`coveringBaseline`); nothing said so, and the first tilt card blocked every run + // of the field test (#77). + Instruction( + title: "Tilt down at your meter", + detail: "\"\(ground.title).\" Later the card may ask for this again, or say \"\(wall.title).\" Some spots need a second look from a step to the side." + ), + Instruction( + title: "Walk along the wall", + detail: sentences(walk.detail, "If you're too close, the card says \"\(stepBack.title).\"") + ), + // The ring and the edge arrow in the picture are `TargetMarker` itself (`MovesArt`). + Instruction( + title: "Follow the blue ring", + detail: "Put it on the spot the card names. An arrow at the screen edge means the spot is off screen." + ), + Instruction(title: "Tap where the wall ends", detail: sentences(end.detail, "The walk asks at both ends.")), + ] + } } private struct OnboardingPage { - enum Art { case walk, fog, safety } + enum Art { case walk, moves, fog, safety } var title: String var body: String? + /// Numbered steps under the body, one accessibility element each. + var moves: [Instruction] = [] /// A line about what the walk draws over the camera, set apart with the ring's icon. var guide: String? = nil /// A quieter line under the body, set apart with an icon. @@ -124,6 +171,8 @@ private struct OnboardingPageView: View { Group { if page.art == .walk { WalkArt(isActive: isActive) + } else if page.art == .moves { + MovesArt(isActive: isActive) } else { FogArt(isActive: isActive) } @@ -132,6 +181,9 @@ private struct OnboardingPageView: View { .frame(height: 260) .accessibilityHidden(true) text + if !page.moves.isEmpty { + MoveList(moves: page.moves) + } } } .padding(.horizontal, 24) @@ -330,3 +382,96 @@ private struct SafetyArt: View { } } } + +/// The moves page's picture: the walk's own marks on the onboarding wall. A path on the ground to +/// a ring that fills, an arrow at the edge for a spot off screen, and a marked wall end on the +/// right. The ring and the arrow are `TargetMarker`, so they follow any change to the camera's. +private struct MovesArt: View { + var isActive: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + TimelineView(.animation(paused: reduceMotion || !isActive)) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + // The ring fills over 3.5 s, holds green for 1.5 s, then starts again: every 6 s. + let cycle = t.truncatingRemainder(dividingBy: 6) + let progress = reduceMotion ? 0.5 : min(1, max(0, (cycle - 0.5) / 3.5)) + GeometryReader { proxy in + let size = proxy.size + // On the ground strip along the wall, where the tilt-down card points. + let ring = CGPoint(x: size.width * 0.66, y: size.height * 0.86) + let endX = size.width * 0.9 + ZStack { + WallDrawing() + Canvas { context, size in + // The path toward the spot, as `WayfindingOverlay` draws it: blue dots on + // a white rim, stronger toward the far end. + let y = size.height * 0.86 + let count = 5 + for i in 0.. = [ - "onboarding", "wallWalk", "wallWalk-endQuestion", "wallWalk-endPreview", "wallWalk-endQuestionLeavesOut", "wallWalk-nextWallRefused", "wallWalk-overheadQuestion", "gapRequest-walkOut", "gapRequest-overheadQuestion", "meterCloseUp-cantGetClearShot", "meterCloseUp-chooseNumber", + "onboarding", "onboarding-moves", "wallWalk", "wallWalk-endQuestion", "wallWalk-endPreview", "wallWalk-endQuestionLeavesOut", "wallWalk-nextWallRefused", "wallWalk-overheadQuestion", "gapRequest-walkOut", "gapRequest-overheadQuestion", "meterCloseUp-cantGetClearShot", "meterCloseUp-chooseNumber", "markFeatures", "gapRequest", "uploading-offline", "uploading-rejected", "result-review", "cameraDenied", "wallWalk-hidden", "wallWalk-seeBehind", "gapRequest-followUp", "uploading-followUp", "markFeatures-groundQuestion", "markFeatures-groundAnswered", "markFeatures-lostPlace", @@ -126,6 +127,21 @@ final class ScreenStatesUITests: XCTestCase { "wallWalk-endScanTooShort": [("instruction", "You haven't walked enough of the wall")], // #76: a wall never walked shows no spot. "result-wallNotMeasured": [("result.headline", "We couldn't measure your wall")], + // #85: each move in the cards' own words, one element per move. + "onboarding-moves": [ + ("onboarding.move.1", "Aim at your meter"), + ("onboarding.move.2", "Tilt down to show the ground"), + ("onboarding.move.3", "Take a step back"), + ("onboarding.move.4", "An arrow at the screen edge means the spot is off screen"), + ("onboarding.move.5", "Wall ends here"), + ], + ] + + /// States past the screen's first view: the controls tapped to get there, and an element whose + /// page must then be the one across the screen. + private static let navigation: [String: (taps: [String], shows: String)] = [ + // #85: the page after the walk page previews the moves the walk asks for. + "onboarding-moves": (["action.onboardingNext"], "onboarding.move.1"), ] /// Controls a state must offer, by identifier. @@ -177,6 +193,8 @@ final class ScreenStatesUITests: XCTestCase { app.launchArguments = ["-uiDemo"] app.launch() XCTAssertTrue(element(app, "screen.onboarding").waitForExistence(timeout: 15)) + // The walk, the moves it asks for (#85), the haze, then safety with the camera prompt. + tap(app, "action.onboardingNext") tap(app, "action.onboardingNext") tap(app, "action.onboardingNext") // Both prompts come from "Allow camera", so the page says why before either shows. @@ -570,6 +588,27 @@ final class ScreenStatesUITests: XCTestCase { XCTFail("\(name): screen.\(screen) never appeared") return } + if let route = Self.navigation[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] { + for identifier in route.taps { tap(app, identifier) } + // Across, not down: at AX5 the element can sit below the fold of its page. + let window = app.windows.firstMatch.frame + let deadline = Date().addingTimeInterval(10) + var arrived = false + repeat { + if let frame = ElementRead.snapshot(element(app, route.shows))?.frame, + frame.minX >= window.minX, frame.maxX <= window.maxX { + arrived = true + } else { + Thread.sleep(forTimeInterval: 0.25) + } + } while !arrived && Date() < deadline + guard arrived else { + XCTFail("\(name): \(route.shows) never slid into view") + return + } + // The page slides in; let it settle before the screenshot and the audit. + Thread.sleep(forTimeInterval: 0.5) + } let shot = XCTAttachment(screenshot: app.screenshot()) shot.name = name shot.lifetime = .keepAlways