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
151 changes: 148 additions & 3 deletions ios/HouseScan/UI/Screens/OnboardingScreen.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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..<count {
let x = size.width * (0.3 + 0.056 * Double(i))
let rect = CGRect(x: x - 5, y: y - 3.5, width: 10, height: 7)
let fade = 0.55 + 0.45 * Double(i) / Double(count - 1)
context.fill(Path(ellipseIn: rect.insetBy(dx: -2, dy: -1.5)), with: .color(.white.opacity(0.7 * fade)))
context.fill(Path(ellipseIn: rect), with: .color(Palette.signal.opacity(fade)))
}
// The wall's end, as `WallMarksOverlay` draws a marked end: a white line
// with a dark rim and a cap at the ground.
let bottom = CGPoint(x: endX, y: size.height * 0.72 - 4)
var line = Path()
line.move(to: bottom)
line.addLine(to: CGPoint(x: endX, y: 20))
context.stroke(line, with: .color(.black.opacity(0.35)), style: StrokeStyle(lineWidth: 7, lineCap: .round))
context.stroke(line, with: .color(.white), style: StrokeStyle(lineWidth: 4, lineCap: .round))
context.fill(Path(ellipseIn: CGRect(x: bottom.x - 7, y: bottom.y - 7, width: 14, height: 14)), with: .color(.white))
}
// "Wall ends here" carries a flag.
Image(systemName: "flag.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(Palette.signal, in: .circle)
.position(x: endX, y: 20)
TargetMarker(placement: .onScreen(ring, radius: 30), progress: progress)
TargetMarker(placement: .offScreen(CGPoint(x: 34, y: size.height * 0.36), angle: .degrees(180)))
}
}
}
}
}

/// The moves in order, numbered like a briefing. Each is one accessibility element.
private struct MoveList: View {
var moves: [Instruction]

var body: some View {
VStack(alignment: .leading, spacing: 16) {
ForEach(moves.indices, id: \.self) { index in
HStack(alignment: .top, spacing: 14) {
Text("\(index + 1)")
.font(Typeface.sectionTitle)
.foregroundStyle(Palette.signalText)
// Grows with Dynamic Type rather than clipping the number.
.frame(minWidth: 40, minHeight: 40)
.padding(2)
.background(Palette.signal.opacity(0.1), in: .circle)
VStack(alignment: .leading, spacing: 3) {
Text(moves[index].title)
.font(Typeface.sectionTitle)
.fixedSize(horizontal: false, vertical: true)
if let detail = moves[index].detail {
Text(detail)
.font(Typeface.hint)
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("onboarding.move.\(index + 1)")
}
}
.padding(.bottom, 8)
}
}
41 changes: 40 additions & 1 deletion ios/HouseScanUITests/ScreenStatesUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class ScreenStatesUITests: XCTestCase {
/// Name, extra launch arguments, and the screen identifier that must appear.
private static let states: [(name: String, arguments: [String], screen: String)] = [
("onboarding", [], "onboarding"),
("onboarding-moves", [], "onboarding"),
("findMeter", ["-uiDemoPhase", "findMeter"], "findMeter"),
("meterCloseUp-cantGetClearShot", ["-uiDemoPhase", "meterCloseUp", "-uiDemoCloseUpFailed"], "meterCloseUp"),
("meterCloseUp-chooseNumber", ["-uiDemoPhase", "meterCloseUp", "-uiDemoMeterChoose"], "meterCloseUp"),
Expand Down Expand Up @@ -77,7 +78,7 @@ final class ScreenStatesUITests: XCTestCase {

/// The screens with the most text, also checked at AX5.
private static let largestTextStates: Set<String> = [
"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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading