From e085372d9007eaeaee3e0625cbd0ab9c448d3b0f Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:38:16 -0500 Subject: [PATCH 1/2] ios: preview the walk's moves in onboarding Adds a page between the walk page and the haze page that lists the five moves the walk asks for, in order: aim at the meter, walk along the wall, tilt down or up (with a second look from a step to the side), follow the blue ring and its edge arrow, and tap where the wall ends. Each step quotes the walk's cards from ScanCopy, and the picture draws the ring and the edge arrow with TargetMarker itself on the onboarding wall. The screen-state audit gains an onboarding-moves state (default and AX5) that pages to it and checks each move's words; the button-flow test taps Next once more. Fixes #85 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Screens/OnboardingScreen.swift | 149 +++++++++++++++++- .../ScreenStatesUITests.swift | 41 ++++- 2 files changed, 186 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index 3d685119b..086cc32c3 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,14 @@ 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, in the order the walk asks for them. + OnboardingPage( + title: "What the walk asks for", + body: "A card at the top of the screen asks for one of these at a time.", + 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( @@ -95,12 +103,49 @@ 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), + Instruction( + title: "Walk along the wall", + detail: sentences(walk.detail, "If you're too close, the card says \"\(stepBack.title).\"") + ), + // 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 or up when asked", + detail: "\"\(ground.title)\" or \"\(wall.title).\" Some spots need a second look from a step to the side." + ), + // 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. @@ -122,6 +167,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) } @@ -130,6 +177,9 @@ private struct OnboardingPageView: View { .frame(height: 260) .accessibilityHidden(true) text + if !page.moves.isEmpty { + MoveList(moves: page.moves) + } } } .padding(.horizontal, 24) @@ -328,3 +378,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", @@ -112,6 +113,21 @@ final class ScreenStatesUITests: XCTestCase { "resultAR": [("ar.overlay", "drawn on your wall")], // #81: the aim ring fills as its stretch is captured. "wallWalk-aim": [("aim.ring", "50 percent captured")], + // #85: each move in the cards' own words, one element per move. + "onboarding-moves": [ + ("onboarding.move.1", "Aim at your meter"), + ("onboarding.move.2", "Take a step back"), + ("onboarding.move.3", "Tilt down to show the ground"), + ("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. @@ -159,6 +175,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. @@ -497,6 +515,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 From b2d80e845c22e51588b11bdd5461f861e371e1c6 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 04:54:12 -0500 Subject: [PATCH 2/2] ios: preview the ground tilt at the meter before the walk GuidancePlanner.preferredTask asks for the ground by the meter before either side's walk, so the moves page now lists the tilt second and stops promising a fixed order after the first two moves. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Screens/OnboardingScreen.swift | 18 ++++++++++-------- ios/HouseScanUITests/ScreenStatesUITests.swift | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index 086cc32c3..4d9ff8429 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -16,10 +16,11 @@ struct OnboardingScreen: View { 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, in the order the walk asks for them. + // 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.", + 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 ), @@ -119,16 +120,17 @@ struct OnboardingScreen: View { } return [ Instruction(title: "Aim at your meter", detail: meter.detail), - Instruction( - title: "Walk along the wall", - detail: sentences(walk.detail, "If you're too close, the card says \"\(stepBack.title).\"") - ), + // 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 or up when asked", - detail: "\"\(ground.title)\" or \"\(wall.title).\" Some spots need a second look from a step to the side." + 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( diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 0d0fdf364..48883e845 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -116,8 +116,8 @@ final class ScreenStatesUITests: XCTestCase { // #85: each move in the cards' own words, one element per move. "onboarding-moves": [ ("onboarding.move.1", "Aim at your meter"), - ("onboarding.move.2", "Take a step back"), - ("onboarding.move.3", "Tilt down to show the ground"), + ("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"), ],