Skip to content
3 changes: 3 additions & 0 deletions ios/HouseScan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@
membershipExceptions = (
Fixtures/results,
"Fixtures/results/no-clean-spot.json",
"Fixtures/results/no-spot-no-nearest.json",
Fixtures/results/pass.json,
"Fixtures/results/reject-nearest.json",
"Fixtures/results/review-band.json",
"Fixtures/results/unsure-view.json",
"Fixtures/results/view-not-offered.json",
"Fixtures/synthetic-wall",
"Fixtures/synthetic-wall-lidar",
"Fixtures/synthetic-wall-lidar/keyframes",
Expand Down
18 changes: 15 additions & 3 deletions ios/HouseScan/Contract/ResultPresentation+Reading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,23 @@ extension ResultPresentation {
ResultReading.cardLines(readingChecks).map { checks[$0] }
}

/// The view `row`'s line on the card offers to take, or nil: the view the server named to
/// settle it (`settledBy`), when `ResultCardActions.lineOffersView` says the line offers it.
/// The line's "Show me" and its words (`ScanCopy.cardLine`, `ScanCopy.unsureNote`) both read
/// this, so the words never promise a photo the line doesn't let the homeowner take.
func offeredView(for row: CheckRow, sourceAvailable: Bool) -> MissingEvidence? {
let view = row.settledBy.flatMap { id in missing.first { $0.id == id } }
guard ResultCardActions.lineOffersView(
outcome: row.outcome.placementOutcome, needsPerson: row.needsPerson,
settlingViewCapturable: view?.capturable, sourceAvailable: sourceAvailable)
else { return nil }
return view
}

/// The view that would settle `row` and that the camera can take now: only for an unsure
/// check a person needn't judge.
/// check a person needn't judge. `offeredView` with the camera up.
func viewToTake(for row: CheckRow) -> MissingEvidence? {
guard row.outcome == .unsure, !row.needsPerson, let id = row.settledBy else { return nil }
return missing.first { $0.id == id && $0.capturable }
offeredView(for: row, sourceAvailable: true)
}

/// The first view in `missing` that settles an unsure check and can be taken now.
Expand Down
7 changes: 7 additions & 0 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,13 @@ struct CheckRow: Identifiable, Equatable, Sendable {
var plusMinus: Float? = nil
/// Whether `threshold` is a minimum or a maximum, when the server said which.
var comparison: RuleComparison? = nil
/// A stricter line inside `threshold`, meters, when the server sent one (`review_threshold_ft`,
/// on the cable run): a measurement that doesn't clear it needs review even within the limit.
/// Nil when the answer has none.
var reviewThreshold: Float? = nil
/// True when `reviewThreshold` explains this unsure check, so the card names it
/// (`PlacementCheck.reviewBandApplies`, decided in the server's feet before conversion).
var reviewBandApplies = false
/// The `MissingEvidence.id` of the first view that would settle this check, when the server
/// named one.
var settledBy: String? = nil
Expand Down
26 changes: 14 additions & 12 deletions ios/HouseScan/Runtime/ScanEngine+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,26 +252,28 @@ extension ScanEngine {
needsPerson: check.needsPerson,
measured: check.measuredFt.map(meters), threshold: check.thresholdFt.map(meters), plusMinus: check.plusMinusFt.map(meters),
comparison: check.comparison.map(Self.comparison),
reviewThreshold: check.reviewThresholdFt.map(meters), reviewBandApplies: check.reviewBandApplies,
settledBy: result.evidenceIndex(settling: check.id).map(Self.missingID)
)
}

// A run's start_ft is a range of battery LEFT edges, so the wall it describes reaches one
// battery width past its last start. The spot carries the size, or after a reject the
// nearest spot, which the server sized the same way. With neither, the zone is drawn over
// the starts alone at 0.3 m deep, which understates it.
let sized = spot ?? nearestSpot
let depth = sized?.depth ?? 0.3
let width = sized.map { $0.span.upperBound - $0.span.lowerBound } ?? 0
let clearances = result.sweep.enumerated().map { index, run in
let first = meters(min(run.startFt.x, run.startFt.y))
let last = meters(max(run.startFt.x, run.startFt.y))
// The spot carries the battery's size, or without one the nearest spot, which the server
// sized the same way. With neither, `sweepZones` gives no zones: the result keeps its
// wall, summary and reasons, and no view draws floor the server never sized.
let sized = (spot ?? nearestSpot).map {
ResultMarkLayout.BatterySize(width: $0.span.upperBound - $0.span.lowerBound, depth: $0.depth)
}
let zones = ResultMarkLayout.sweepZones(
starts: result.sweep.map { SIMD2(meters($0.startFt.x), meters($0.startFt.y)) }, battery: sized)
// One zone per run, or none.
let clearances = zip(result.sweep, zones).enumerated().map { index, pair in
let (run, zone) = pair
return ClearanceZone(
id: "sweep-\(index)",
label: (run.failing + run.unsure).joined(separator: ", "),
outcome: Self.outcome(run.outcome),
span: first...(last + width),
depth: depth
span: zone.span,
depth: zone.depth
)
}

Expand Down
44 changes: 38 additions & 6 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -635,11 +635,13 @@ enum ScanCopy {

/// The sentence under a failed or unsure line on the result card: the measurement against the
/// rule, or without a measurement, the server's reason (fail) or who settles it (unsure).
static func cardLine(_ row: CheckRow, spoken: Bool = false) -> String? {
/// `photoOffered` is whether the line offers the camera for the view that settles it
/// (`ResultPresentation.offeredView`).
static func cardLine(_ row: CheckRow, photoOffered: Bool, spoken: Bool = false) -> String? {
switch row.outcome {
case .pass: nil
case .fail: measurement(row, spoken: spoken) ?? row.reason
case .unsure: measurement(row, spoken: spoken) ?? unsureNote(row)
case .unsure: measurement(row, spoken: spoken) ?? unsureNote(photoOffered: photoOffered)
}
}

Expand Down Expand Up @@ -696,29 +698,59 @@ enum ScanCopy {
/// review was sent: the app only shows the server's answer and contacts nobody.
static let needsInstaller = "Needs an installer to check"

static func unsureNote(_ row: CheckRow) -> String {
row.needsPerson ? Self.needsInstaller : "One more photo would settle this"
static let onePhoto = "One more photo would settle this"

/// Who settles an unsure check. One more photo only when the app offers the camera for it
/// (`ResultPresentation.offeredView`): keyed on the check alone, a view the homeowner
/// couldn't get to, or one the app can't plan, still promised a photo nothing would take.
static func unsureNote(photoOffered: Bool) -> String {
photoOffered ? Self.onePhoto : Self.needsInstaller
Comment on lines +703 to +707

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Camera failure implies installer needed If the camera fails after upload, a check that could otherwise be settled by a photo has no offered photo, so this note changes to “Needs an installer to check.” The check does not require a person to judge it, and the headline still says “One more look.” This gives the homeowner a false installer requirement instead of explaining that capture is unavailable.

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

Comment:
**Camera failure implies installer needed** If the camera fails after upload, a check that could otherwise be settled by a photo has no offered photo, so this note changes to “Needs an installer to check.” The check does not require a person to judge it, and the headline still says “One more look.” This gives the homeowner a false installer requirement instead of explaining that capture is unavailable.

---

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

Fix in Codex Fix in Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Intended, per the brief this PR implements: a line must not promise that a photo will settle the check when the app won't take that photo. After the camera fails, the result offers no capture for the rest of this session, so the photo line would be a promise nothing can keep. "Needs an installer to check" is the same wording Details already used for views it can't offer, at 75f6c37 and before. It says a check is still needed, not that one was arranged. The headline and the notes come from the server's outcome and aren't recomputed here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That’s fair, and the brief makes this behavior intentional: after capture failure, the app no longer offers a photo, so “One more photo would settle this” would be an unfulfillable promise. “Needs an installer to check” describes the remaining action without implying that anyone was contacted or that the server outcome was recomputed. I’m withdrawing this finding.

}

/// "Measured 3 ft 2 in. The rule is at least 3 ft, and the measurement can be off by about 4 in."
/// The limit says whether it is a minimum or a maximum: without it, the 20 ft cable limit read
/// like a minimum under "Measured 3 ft".
/// `spoken` spells out feet and inches for VoiceOver, which reads "ft" and "in" as letters.
///
/// When the server's review line explains an unsure check (`reviewBand`), the rule names it too:
/// "Measured 16 ft. The rule is at most 20 ft, and anything 15 ft or more needs an installer's
/// review. The measurement can be off by about 6 in." Without it, a cable run inside the
/// maximum read as passing under "Not sure yet". It says what the rule needs, never that a
/// review was sent: the app contacts nobody.
static func measurement(_ row: CheckRow, spoken: Bool = false) -> String? {
guard let measured = row.measured else { return nil }
let length = spoken ? Distance.spoken : Distance.feetAndInches
var parts = [measuredLine(measured, length: length)]
if let threshold = row.threshold {
let limit = ruleLimit(threshold, row.comparison, length: length)
if let plusMinus = row.plusMinus, plusMinus > 0 {
parts.append("The rule is \(limit), and the measurement can be off by about \(length(plusMinus)).")
let error = row.plusMinus.flatMap { $0 > 0 ? length($0) : nil }
if let band = reviewBand(row, length: length) {
parts.append("The rule is \(limit), and \(band) needs an installer's review.")
if let error {
parts.append("The measurement can be off by about \(error).")
}
} else if let error {
parts.append("The rule is \(limit), and the measurement can be off by about \(error).")
} else {
parts.append("The rule is \(limit).")
}
}
return parts.joined(separator: " ")
}

/// "anything 15 ft or more" under a maximum, "anything 4 ft or less" over a minimum: the side of the
/// check's review line that needs review. Nil unless the line explains this check's outcome
/// (`CheckRow.reviewBandApplies`).
static func reviewBand(_ row: CheckRow, length: (Float) -> String = Distance.feetAndInches) -> String? {
guard row.reviewBandApplies, let review = row.reviewThreshold, let comparison = row.comparison else { return nil }
return switch comparison {
// Inclusive: a confident pass needs measured + error < review_threshold_ft (at_most), so a
// value landing exactly on the line already needs review (result.schema.json).
case .atMost: "anything \(length(review)) or more"
case .atLeast: "anything \(length(review)) or less"
}
}

/// "Measured 3 ft 2 in.", or "Overlaps by 1 ft 3 in." below zero. A clearance the server
/// measures to an area (the meter's working space, a box on the wall above) goes negative
/// once the battery is inside it, and the bare magnitude read as clearance (#40). Less than
Expand Down
25 changes: 16 additions & 9 deletions ios/HouseScan/UI/Screens/ResultScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ struct ResultScreen: View {
.accessibilityIdentifier(id)
}

/// The checks whose card line offers the camera, so Details says the same as the card.
private func photoOffered(_ result: ResultPresentation) -> Set<String> {
Set(result.checks.filter { result.offeredView(for: $0, sourceAvailable: state.spatialResultAvailable) != nil }.map(\.id))
}

private func details(_ result: ResultPresentation) -> some View {
DisclosureGroup(isExpanded: $detailsExpanded) {
VStack(alignment: .leading, spacing: 22) {
Expand All @@ -144,7 +149,7 @@ struct ResultScreen: View {
.accessibilityIdentifier("result.summary")
}
if !result.checks.isEmpty {
ChecksList(checks: result.checks)
ChecksList(checks: result.checks, photoOffered: photoOffered(result))
}
if !result.missing.isEmpty {
MissingList(missing: result.missing, checks: result.checks, sourceAvailable: state.spatialResultAvailable, actions: actions)
Expand Down Expand Up @@ -310,13 +315,12 @@ private struct AnswerCard: View {
private func line(_ row: CheckRow) -> some View {
// After a reject, the line above already gave the nearest spot's failing measurement.
let repeatsNearest = row.id == result.nearestFailingCheck && result.nearestSpot != nil
let view = result.viewToTake(for: row).flatMap { view in
ResultCardActions.offersView(capturable: view.capturable, sourceAvailable: sourceAvailable) ? view : nil
}
// One fact for the line's "Show me" and its words, so they can't disagree.
let view = result.offeredView(for: row, sourceAvailable: sourceAvailable)
return CardCheckLine(
row: row,
sentence: repeatsNearest ? nil : ScanCopy.cardLine(row),
spokenSentence: repeatsNearest ? nil : ScanCopy.cardLine(row, spoken: true),
sentence: repeatsNearest ? nil : ScanCopy.cardLine(row, photoOffered: view != nil),
spokenSentence: repeatsNearest ? nil : ScanCopy.cardLine(row, photoOffered: view != nil, spoken: true),
showMe: view.map { view in { actions.captureMissing(view.id) } }
)
}
Expand Down Expand Up @@ -459,6 +463,8 @@ private enum CheckSymbol {

private struct ChecksList: View {
var checks: [CheckRow]
/// The ids of the checks whose card line offers the camera (`ResultPresentation.offeredView`).
var photoOffered: Set<String>

var body: some View {
VStack(alignment: .leading, spacing: 12) {
Expand All @@ -467,7 +473,7 @@ private struct ChecksList: View {
.accessibilityAddTraits(.isHeader)
VStack(spacing: 0) {
ForEach(Array(checks.enumerated()), id: \.element.id) { index, row in
CheckRowView(row: row)
CheckRowView(row: row, photoOffered: photoOffered.contains(row.id))
if index < checks.count - 1 {
Divider().padding(.leading, 52)
}
Expand All @@ -480,6 +486,7 @@ private struct ChecksList: View {

private struct CheckRowView: View {
var row: CheckRow
var photoOffered: Bool

var body: some View {
HStack(alignment: .top, spacing: 12) {
Expand All @@ -501,7 +508,7 @@ private struct CheckRowView: View {
.fixedSize(horizontal: false, vertical: true)
}
if row.outcome == .unsure {
Label(ScanCopy.unsureNote(row), systemImage: row.needsPerson ? "person.fill" : "camera.fill")
Label(ScanCopy.unsureNote(photoOffered: photoOffered), systemImage: photoOffered ? "camera.fill" : "person.fill")
.font(.footnote.weight(.semibold))
.foregroundStyle(Palette.reviewInk)
}
Expand All @@ -511,7 +518,7 @@ private struct CheckRowView: View {
.padding(14)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(row.title): \(ScanCopy.outcomeWord(row.outcome))")
.accessibilityValue([row.reason, ScanCopy.measurement(row, spoken: true), row.outcome == .unsure ? ScanCopy.unsureNote(row) : nil]
.accessibilityValue([row.reason, ScanCopy.measurement(row, spoken: true), row.outcome == .unsure ? ScanCopy.unsureNote(photoOffered: photoOffered) : nil]
.compactMap(\.self).joined(separator: ". "))
// The card shows the deciding checks as `check.<id>`; this is the full list.
.accessibilityIdentifier("detail.check.\(row.id)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import Foundation
//
// Unknown keys are ignored. The server adds optional fields within schema 1.0 without changing
// schema_version: `checks[].review_threshold_ft` and `sweep[].segment` arrived that way on
// 2026-09-26, and rejecting them stopped every real upload from reaching the result screen. An
// 2026-09-26, and rejecting them stopped every real upload from reaching the result screen
// (`review_threshold_ft` is now read, and stays optional). An
// unknown value of an enum the app presents (decision, outcome, unsure_cause, and the others
// below) still fails, because the app can't show a decision or outcome it doesn't know.
// `reasons[].code`, `policy.sources` and `route.crossings[].effect` are plain strings: the app
Expand Down Expand Up @@ -372,6 +373,10 @@ public struct PlacementCheck: Codable, Sendable, Equatable {
public var measuredFt: Double?
public var plusMinusFt: Double?
public var thresholdFt: Double?
/// A second, stricter line on the passing side of `thresholdFt`, on checks with a review band
/// (route_length): a measurement that doesn't clear it is at best UNSURE even when it clears
/// `thresholdFt` (result.schema.json). Optional in the schema; nil when the answer has none.
public var reviewThresholdFt: Double?
public var comparison: PlacementComparison?
public var subject: String?
public var rule: PlacementRule
Expand All @@ -382,6 +387,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable {
case measuredFt = "measured_ft"
case plusMinusFt = "plus_minus_ft"
case thresholdFt = "threshold_ft"
case reviewThresholdFt = "review_threshold_ft"
}

public init(from decoder: any Decoder) throws {
Expand All @@ -394,6 +400,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable {
measuredFt = try c.decode(Double?.self, forKey: .measuredFt)
plusMinusFt = try c.decode(Double?.self, forKey: .plusMinusFt)
thresholdFt = try c.decode(Double?.self, forKey: .thresholdFt)
reviewThresholdFt = try c.decodeIfPresent(Double.self, forKey: .reviewThresholdFt)
comparison = try c.decode(PlacementComparison?.self, forKey: .comparison)
subject = try c.decodeIfPresent(String.self, forKey: .subject)
rule = try c.decode(PlacementRule.self, forKey: .rule)
Expand All @@ -409,6 +416,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable {
try c.encode(measuredFt, forKey: .measuredFt)
try c.encode(plusMinusFt, forKey: .plusMinusFt)
try c.encode(thresholdFt, forKey: .thresholdFt)
try c.encodeIfPresent(reviewThresholdFt, forKey: .reviewThresholdFt)
try c.encode(comparison, forKey: .comparison)
try c.encodeIfPresent(subject, forKey: .subject)
try c.encode(rule, forKey: .rule)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,17 @@ public enum ResultCardActions {
public static func offersView(capturable: Bool, sourceAvailable: Bool) -> Bool {
capturable && sourceAvailable
}

/// True when a check's line offers the camera for the view that settles it: an UNSURE check
/// no person has to judge, whose settling view the server named (`settlingViewCapturable` is
/// nil when it named none) and the app can take now (`offersView`). The line's "Show me" and
/// its words both read this, so a line says one more photo would settle it only when it lets
/// the homeowner take that photo. A view the homeowner couldn't get to, or a request the app
/// can't plan, is not capturable.
public static func lineOffersView(
outcome: PlacementOutcome, needsPerson: Bool, settlingViewCapturable: Bool?, sourceAvailable: Bool
) -> Bool {
outcome == .unsure && !needsPerson
&& offersView(capturable: settlingViewCapturable == true, sourceAvailable: sourceAvailable)
}
}
Loading
Loading