Skip to content
Open
10 changes: 9 additions & 1 deletion ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,21 @@ enum GuidanceStep: Equatable, Sendable {
case seeBehind(s: Float)
}

/// A problem that overrides guidance until it clears. UI/ owns the words.
/// A problem to fix while it lasts. Tracking problems and `pastWallEnd` override guidance; the
/// capture gate's (`slowDown`, `turnSlowly`, `holdSteady`, `tooDark`, `tooDarkToMeasure`) and
/// `needsTexture` ride along with it on the walk. UI/ owns the words.
enum Coaching: Equatable, Sendable {
case initializing
case slowDown
case needsTexture
case tooDark
/// Frames have been mostly too dark for a long while: waiting won't help, daylight will
/// (`CoachingDebouncer.GateProblem.persistentlyDark`).
case tooDarkToMeasure
case holdSteady
/// Turning or tilting the phone faster than the capture gate keeps photos at, as opposed to
/// walking too fast (#26).
case turnSlowly
case relocalizing
case trackingLost
/// The phone stands past an end it can't see back from, so the walk keeps no photos: nothing
Expand Down
72 changes: 52 additions & 20 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ final class ScanEngine {
// Stored evidence
private(set) var store: KeyframeStore
private var keptSourceIDs: Set<String> = []
/// Debounces coaching that comes from the capture gate (see `gateCoaching`).
/// Debounces "past the end of the wall" during the walk (see `walkCoaching`).
private var gateProblem: (coaching: Coaching, since: Double)?
private var gateClearSince: Double?
/// Debounces the capture gate's own coaching during the walk (see `walkCoaching`).
private var gateCoaching = CoachingDebouncer()
private var closeUpPending = false
/// Why the last close-up has to be retaken (from the meter-number reader, or "None of
/// these"), and when that was said, in screen seconds. Shown until the next shot fires.
Expand Down Expand Up @@ -596,33 +598,32 @@ final class ScanEngine {
keptSourceIDs.insert(frame.id)
keep(frame)
}
state.coaching = walkCoaching(tracking: frame.tracking, skip: skip, pastEnd: pastEnd != nil, time: frame.timestamp)
state.coaching = walkCoaching(tracking: frame.tracking, skip: skip, meanLuma: frame.quality?.meanLuma, sharpness: frame.quality?.sharpness, pastEnd: pastEnd != nil, time: frame.timestamp)
afterCoverageChange(camera: frame.camera, time: frame.timestamp)
askOverheadIfTiltedUp(frame)
}

/// Tracking problems show at once. A problem the capture gate reports (moving, blurry, too
/// dark) shows only once it has lasted `showAfter` seconds and clears after `clearAfter`
/// seconds of frames without it: the gate judges every frame, and one fast frame at 30 fps
/// would otherwise flash a prompt for a single frame. During the walk moving and blurry read
/// as "Slow down", never "Hold steady", which would tell a walking homeowner to stop. Both
/// durations are guesses to try on a phone, not measured. Standing past an end the phone
/// can't see back from (`pastEnd`) is debounced the same way and comes before the gate's
/// reasons: no photo is kept there whatever the gate says.
private func walkCoaching(tracking: TrackingQuality, skip: CaptureDecision.SkipReason?, pastEnd: Bool, time: Double) -> Coaching? {
/// Tracking problems show at once. The capture gate's problems go through `gateCoaching`
/// (`CoachingDebouncer`): each has its own clock, darkness is judged from the frames' luma
/// with hysteresis, and "Slow down" is kept for walking, so while the homeowner stands and
/// aims (`isAiming`) only turning and blur are said. Blur reads as "Slow down" while walking
/// and "Hold steady" while aiming. Standing past an end the phone can't see back from
/// (`pastEnd`) shows once it has lasted `showAfter` seconds, clears after `clearAfter`
/// seconds without it, and comes before the gate's problems: no photo is kept there whatever
/// the gate says. Both durations are guesses to try on a phone, not measured.
private func walkCoaching(tracking: TrackingQuality, skip: CaptureDecision.SkipReason?, meanLuma: Double?, sharpness: Double?, pastEnd: Bool, time: Double) -> Coaching? {
let showAfter = 0.7
let clearAfter = 0.5
let aiming = isAiming
let gate = gateCoaching.update(time: time, skip: skip, meanLuma: meanLuma, sharpness: sharpness, aiming: aiming)
guard tracking == .normal else {
gateProblem = nil
gateClearSince = nil
// The light is what it was, but the motion from before the phone lost its place is not.
gateCoaching.forgetMotion()
return coaching(for: tracking, skip: nil)
}
var candidate: Coaching? = switch skip {
case .moving?, .blurry?: .slowDown
case .tooDark?: .tooDark
default: nil
}
if pastEnd { candidate = .pastWallEnd }
let candidate: Coaching? = pastEnd ? .pastWallEnd : nil
if let problem = gateProblem, time < problem.since { gateProblem = nil } // replay restarted
if let candidate {
gateClearSince = nil
Expand All @@ -637,8 +638,36 @@ final class ScanEngine {
gateClearSince = nil
}
}
guard let problem = gateProblem, time - problem.since >= showAfter else { return nil }
return problem.coaching
if let problem = gateProblem, time - problem.since >= showAfter { return problem.coaching }
return gate.map { Self.coaching(for: $0, aiming: aiming) }
}

/// Whether the homeowner is asked to stand and aim rather than walk: an aim, tilt, step-back,
/// see-behind or mark-the-end step, marking, a question on screen, or a gap request's view.
/// Walking too fast is not coached then (#26). Two steps ask for walking and so still say
/// "Slow down": the corner ("walk round it, aim at the next wall and mark it"; the mark
/// itself is `state.marking`, which aims) and a request to walk the stretch far enough out
/// (`GapPlan.Need.asksToWalk`). Read before this frame's guidance update, so it is the step
/// on screen when the frame arrived.
private var isAiming: Bool {
if state.marking != nil || state.endQuestion != nil || state.overheadQuestion { return true }
switch state.guidance {
case .aimAtGround, .aimAtWall, .tiltUp, .stepBack, .seeBehind, .markEnd: return true
case .gap: return !(gapPlan?.need.asksToWalk ?? false)
case .findMeter, .aimAtWallForMeter, .holdOnMeter, .walk, .walkComplete, .markNextWall: return false
}
}

/// The coaching for a gate problem. Blur reads as "Slow down" while walking and "Hold
/// steady" while aiming, where "Slow down" would tell someone standing still to walk slower.
static func coaching(for problem: CoachingDebouncer.GateProblem, aiming: Bool) -> Coaching {
switch problem {
case .tooDark: .tooDark
case .persistentlyDark: .tooDarkToMeasure
case .movingFast: .slowDown
case .turningFast: .turnSlowly
case .blurry: aiming ? .holdSteady : .slowDown
}
}

private func afterCoverageChange(camera: CameraFrame?, time: Double) {
Expand Down Expand Up @@ -1182,6 +1211,7 @@ final class ScanEngine {
state.lastCapture = nil
gateProblem = nil
gateClearSince = nil
gateCoaching = CoachingDebouncer()
autoCapture.reset()
planner.reset()
go(.findMeter)
Expand Down Expand Up @@ -1644,6 +1674,7 @@ final class ScanEngine {
resetPacketLog()
keptSourceIDs = []
autoCapture.reset()
gateCoaching = CoachingDebouncer()
planner.reset()
closeUpGate = CloseUpGate()
gapPlan = nil
Expand Down Expand Up @@ -1845,7 +1876,8 @@ extension ScanEngine {
case .limited(.unknown): return .trackingLost
case .normal:
switch skip {
case .moving?, .blurry?: return .holdSteady
case .movingFast?, .blurry?: return .holdSteady
case .turningFast?: return .turnSlowly
case .tooDark?: return .tooDark
default: return nil
}
Expand Down
44 changes: 42 additions & 2 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,13 @@ enum ScanCopy {
case .needsTexture:
Instruction(title: "Aim at a corner or somewhere with more texture", detail: "A plain wall or the sky gives your phone nothing to follow.")
case .tooDark:
Instruction(title: "It's too dark to see the wall", detail: "Turn on a porch light, or try again in daylight.")
Instruction(title: "It's dark here", detail: "Some photos won't count. Try your phone's flashlight, or come back in daylight.")
case .tooDarkToMeasure:
Instruction(title: "It's too dark to measure here", detail: "Try in daylight.")
case .holdSteady:
Instruction(title: "Hold steady", detail: nil)
case .turnSlowly:
Instruction(title: "Turn more slowly", detail: "Photos taken while turning come out blurred.")
case .relocalizing:
Instruction(title: "Point at the meter like this.", detail: "Your phone lost its place for a moment.")
case .trackingLost:
Expand All @@ -94,13 +98,49 @@ enum ScanCopy {
case .initializing: "iphone.gen3.radiowaves.left.and.right"
case .slowDown: "tortoise.fill"
case .needsTexture: "square.grid.3x3.middle.filled"
case .tooDark: "moon.fill"
case .tooDark, .tooDarkToMeasure: "moon.fill"
case .holdSteady: "hand.raised.fill"
case .turnSlowly: "arrow.clockwise"
case .relocalizing, .trackingLost: "location.slash.fill"
case .pastWallEnd: "arrow.uturn.backward"
}
}

/// One short line for coaching that rides along with the task instead of replacing it (the
/// walk's capture-gate coaching): the task's title and second line stay, and this goes under
/// them. Mostly only the coaching's title, so the task's own words stay the bigger part of the
/// card. The dark coaching keeps what to do about it: it can stay up for a whole night walk, and
/// "It's dark here" alone doesn't say what would help.
static func coachingNote(_ coaching: Coaching) -> String {
switch coaching {
case .tooDark: return "It's dark here. Try your phone's flashlight, or come back in daylight."
case .tooDarkToMeasure: return "It's too dark to measure here. Try in daylight."
default:
let title = ScanCopy.coaching(coaching).title
return title.hasSuffix(".") ? title : "\(title)."
}
}

/// Tracking problems and standing past the end replace the task on the card: nothing the task
/// asks for counts until they clear. The capture gate's coaching and too little texture ride
/// along with it instead (`withCoaching`). No `default`, so a new case has to pick a side.
static func coachingReplacesTask(_ coaching: Coaching) -> Bool {
switch coaching {
case .initializing, .relocalizing, .trackingLost, .pastWallEnd: true
case .slowDown, .needsTexture, .tooDark, .tooDarkToMeasure, .holdSteady, .turnSlowly: false
}
}

/// The task with ride-along coaching under it: the task's title and second line both stay (on
/// an aim step the second line is the only thing that says where to aim), and the coaching adds
/// its own short line (`coachingNote`). Replacing the whole card hid the task each time the
/// coaching came up (#80), and the dark coaching can stay up for a whole night walk.
Comment on lines +135 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Darkness advice disappears. When dark coaching appears beneath a walk task or gap request, withCoaching adds only its title. The flashlight and daylight advice lives in the coaching detail, so users see that it is dark but not what they can do about photos that will not count.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ios/HouseScan/UI/Copy/ScanCopy.swift
Line: 127-129

Comment:
**Darkness advice disappears.** When dark coaching appears beneath a walk task or gap request, `withCoaching` adds only its title. The flashlight and daylight advice lives in the coaching detail, so users see that it is dark but not what they can do about photos that will not count.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caretaker check at 5f8f877: fixed. 040d9a6 is in this head. ScanCopy.coachingNote keeps the flashlight and daylight advice for .tooDark and .tooDarkToMeasure (ScanCopy.swift:114-122), and both ride-along UI tests assert "flashlight" (ScreenStatesUITests.swift:246, 264). Greptile confirmed the blur thread in 4115595121.

static func withCoaching(_ task: Instruction, _ coaching: Coaching?) -> Instruction {
guard let coaching else { return task }
let detail = [task.detail, coachingNote(coaching)].compactMap { $0 }.joined(separator: "\n")
return Instruction(title: task.title, detail: detail)
}

// MARK: Wall ends

/// Under the wall map when ending the wall at the dashed line would cut off part of the walk.
Expand Down
2 changes: 2 additions & 0 deletions ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,9 @@ final class DemoEngine: ScanActions {
case "slowDown": .slowDown
case "needsTexture": .needsTexture
case "tooDark": .tooDark
case "tooDarkToMeasure": .tooDarkToMeasure
case "holdSteady": .holdSteady
case "turnSlowly": .turnSlowly
case "relocalizing": .relocalizing
case "trackingLost": .trackingLost
case "pastWallEnd": .pastWallEnd
Expand Down
2 changes: 1 addition & 1 deletion ios/HouseScan/UI/Preview/UIDemo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import SwiftUI
/// - `-uiDemoFreeze`: don't run the timed scripts, so the screen holds still.
/// - `-uiDemoMarking <FeatureKind raw value>`: open the walk in marking mode.
/// - `-uiDemoRefusal`: the marking shows a refusal.
/// - `-uiDemoCoaching <slowDown|needsTexture|tooDark|holdSteady|relocalizing|trackingLost|pastWallEnd>`.
/// - `-uiDemoCoaching <slowDown|needsTexture|tooDark|tooDarkToMeasure|holdSteady|turnSlowly|relocalizing|trackingLost|pastWallEnd>`.
/// - `-uiDemoCloseUpFailed`: the close-up has failed twice, so the way out shows.
/// - `-uiDemoMeterChoose`: the close-up asks which of three made-up readings is the meter number.
/// - `-uiDemoOffline`: uploads fail offline.
Expand Down
21 changes: 17 additions & 4 deletions ios/HouseScan/UI/Screens/GapRequestScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct GapRequestScreen: View {
}
CameraChrome(
instruction: instruction,
tone: asking ? .normal : state.coaching.map { .coaching(symbol: ScanCopy.coachingSymbol($0)) } ?? .normal,
tone: asking ? .normal : coaching.map { .coaching(symbol: ScanCopy.coachingSymbol($0)) } ?? .normal,
reply: state.gap?.isSatisfied == true || asking ? nil : InstructionCard.Reply(
title: "I can't get there",
identifier: "action.skipGap",
Expand Down Expand Up @@ -77,14 +77,27 @@ struct GapRequestScreen: View {
return max(1, result.missing.filter(\.capturable).count)
}

/// As on the walk: tracking problems and standing past an end replace the request, and the
/// capture gate's coaching rides along under it (`ScanCopy.withCoaching`). The dark coaching
/// 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 {
if asking { return ScanCopy.overheadQuestion }
if let coaching = state.coaching { return ScanCopy.coaching(coaching) }
guard let gap = state.gap else { return ScanCopy.guidance(.gap) }
if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching) }
guard let gap = state.gap else { return ScanCopy.withCoaching(ScanCopy.guidance(.gap), coaching) }
if gap.isSatisfied {
return Instruction(title: "Got it, thanks", detail: followUps > 0 ? "Updating your result." : "That's the view we needed.")
}
return ScanCopy.gap(gap)
return ScanCopy.withCoaching(ScanCopy.gap(gap), coaching)
}

/// The coaching on the card, marked with its symbol (`tone`). Once the view is in, the
/// capture gate's coaching has nothing left to ask for, so only coaching that replaces the
/// card still shows.
private var coaching: Coaching? {
guard let coaching = state.coaching else { return nil }
if state.gap?.isSatisfied == true, !ScanCopy.coachingReplacesTask(coaching) { return nil }
return coaching
}
}

Expand Down
38 changes: 25 additions & 13 deletions ios/HouseScan/UI/Screens/WallWalkScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import SwiftUI
///
/// Over the camera: haze on what the phone hasn't seen, a blue dotted path on the ground, a
/// ring on the next thing to aim at, pins on what's been marked. At the bottom: the tape map
/// and at most two actions. At the top: one instruction, replaced by coaching while there is a
/// problem, or by the marking prompt while marking.
/// and at most two actions. At the top: one instruction, with coaching on a line of its own
/// under it while the photos have a problem, replaced while tracking has one, or by the marking
/// prompt while marking.
struct WallWalkScreen: View {
let state: ScanViewState
let actions: any ScanActions
Expand Down Expand Up @@ -81,26 +82,37 @@ struct WallWalkScreen: View {
}
if let side = state.endQuestion { return ScanCopy.endQuestion(side) }
if state.overheadQuestion { return ScanCopy.overheadQuestion }
if let coaching { return ScanCopy.coaching(coaching) }
if state.wallTooShort { return Instruction(title: ScanCopy.wallTooShort, detail: ScanCopy.guidance(state.guidance).title) }
return ScanCopy.guidance(state.guidance)
if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching) }
// 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)
}
return ScanCopy.withCoaching(ScanCopy.guidance(state.guidance), coaching)
}

/// "Slow down" is for walking. With the tray open the homeowner has stopped to pick a mark
/// and is only turning the phone, which the gate also reads as moving (field test run 1).
/// Tracking problems still show.
/// "Slow down", "Turn more slowly" and "Hold steady" are for walking and aiming. With the tray
/// open the homeowner has stopped to pick a mark and is only turning the phone, which the gate
/// also reads as moving or turning (field test run 1). Tracking problems still show.
private var coaching: Coaching? {
if trayOpen, state.coaching == .slowDown { return nil }
return state.coaching
switch state.coaching {
case .slowDown?, .turnSlowly?, .holdSteady?: trayOpen ? nil : state.coaching
default: state.coaching
}
}

/// Coaching that replaces the task marks the card with its symbol. A refusal on the task
/// (the next wall wasn't marked, the wall is too short) keeps its red triangle over coaching
/// that only rides along: the refusal's words are on the card, so its tone should match.
private var tone: InstructionCard.Tone {
if state.marking?.refusal != nil { return .refusal }
if state.marking == nil, state.endQuestion == nil, !state.overheadQuestion, let coaching {
let coachingShows = state.marking == nil && state.endQuestion == nil && !state.overheadQuestion
if coachingShows, let coaching, ScanCopy.coachingReplacesTask(coaching) {
return .coaching(symbol: ScanCopy.coachingSymbol(coaching))
}
if state.marking == nil, case .markNextWall(_, _?) = state.guidance { return .refusal }
if state.marking == nil, state.endQuestion == nil, !state.overheadQuestion, state.wallTooShort { return .refusal }
if coachingShows, state.wallTooShort { return .refusal }
if coachingShows, let coaching { return .coaching(symbol: ScanCopy.coachingSymbol(coaching)) }
return .normal
}

Expand Down Expand Up @@ -269,7 +281,7 @@ struct WallWalkScreen: View {
/// place, as does being past an end.
private var coachingHidesReply: Bool {
switch state.coaching {
case nil, .slowDown?, .needsTexture?, .tooDark?, .holdSteady?: false
case nil, .slowDown?, .needsTexture?, .tooDark?, .tooDarkToMeasure?, .holdSteady?, .turnSlowly?: false
case .initializing?, .relocalizing?, .trackingLost?, .pastWallEnd?: true
}
}
Expand Down
Loading
Loading