From f838eb66c54c13d5b1fa1f5f42a67b364a6709c2 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 16:29:27 -0500 Subject: [PATCH 1/4] fix(ios): say "AC unit" and spell out lengths for VoiceOver (#25) The marking prompt and the mark and remove labels lowercased the feature name, so they read "Tap the ac unit" and "Mark ac unit". ScanCopy.noun keeps "AC" in capitals. The result's measurement lines and the 3D view's summary now give VoiceOver feet and inches spelled out (Distance.spoken), since it reads "ft" and "in" as letters. The on-screen text is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 25 +++++++++++++------ ios/HouseScan/UI/Result/ResultScene3D.swift | 19 +++----------- .../UI/Screens/MarkFeaturesScreen.swift | 4 +-- ios/HouseScan/UI/Screens/ResultScreen.swift | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 4598cc081..9415a1162 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -142,6 +142,15 @@ enum ScanCopy { } } + /// The name mid-sentence: "Tap the gas meter", "Mark AC unit". Lowercasing `name` read + /// "ac unit" (B-16). + static func noun(_ kind: FeatureKind) -> String { + switch kind { + case .acUnit: "AC unit" + default: name(kind).lowercased() + } + } + static func symbol(_ kind: FeatureKind) -> String { switch kind { case .gasMeter: "flame.fill" @@ -154,10 +163,10 @@ enum ScanCopy { } static func markingPrompt(_ marking: MarkingState) -> Instruction { - let noun = name(marking.kind).lowercased() + let item = noun(marking.kind) switch (marking.kind, marking.step) { case (.door, 0), (.window, 0): - return Instruction(title: "Tap the \(noun)'s bottom-left corner", detail: "Put the circle on it and tap Mark, or tap it on screen.") + return Instruction(title: "Tap the \(item)'s bottom-left corner", detail: "Put the circle on it and tap Mark, or tap it on screen.") case (.door, _), (.window, _): return Instruction(title: "Now tap its top-right corner", detail: nil) case (.driveway, 0): @@ -169,7 +178,7 @@ enum ScanCopy { case (.fence, _): return Instruction(title: "Now tap the bottom at the other end", detail: nil) case (.gasMeter, _), (.acUnit, _): - return Instruction(title: "Tap the \(noun)", detail: "Put the circle on it and tap Mark, or tap it on screen.") + return Instruction(title: "Tap the \(item)", detail: "Put the circle on it and tap Mark, or tap it on screen.") } } @@ -355,14 +364,16 @@ enum ScanCopy { } /// "Measured 3 ft 2 in. The rule is 3 ft, and the measurement can be off by about 4 in." - static func measurement(_ row: CheckRow) -> String? { + /// `spoken` spells out feet and inches for VoiceOver, which reads "ft" and "in" as letters. + static func measurement(_ row: CheckRow, spoken: Bool = false) -> String? { guard let measured = row.measured else { return nil } - var parts = ["Measured \(Distance.feetAndInches(measured))."] + let length: (Float) -> String = spoken ? Distance.spoken : Distance.feetAndInches + var parts = ["Measured \(length(measured))."] if let threshold = row.threshold { if let plusMinus = row.plusMinus, plusMinus > 0 { - parts.append("The rule is \(Distance.feetAndInches(threshold)), and the measurement can be off by about \(Distance.feetAndInches(plusMinus)).") + parts.append("The rule is \(length(threshold)), and the measurement can be off by about \(length(plusMinus)).") } else { - parts.append("The rule is \(Distance.feetAndInches(threshold)).") + parts.append("The rule is \(length(threshold)).") } } return parts.joined(separator: " ") diff --git a/ios/HouseScan/UI/Result/ResultScene3D.swift b/ios/HouseScan/UI/Result/ResultScene3D.swift index b100e5dd3..0da2383c6 100644 --- a/ios/HouseScan/UI/Result/ResultScene3D.swift +++ b/ios/HouseScan/UI/Result/ResultScene3D.swift @@ -293,33 +293,22 @@ struct ResultScene3D: View { // MARK: - Accessibility + /// Lengths are spelled out: VoiceOver reads "ft" and "in" as letters (B-16). private var accessibilitySummary: String { guard let spot = result.spot else { return "No battery spot shown" } var parts: [String] if spot.span.lowerBound > 0 { - parts = ["Battery \(Self.feetAndInches(spot.span.lowerBound)) right of your meter"] + parts = ["Battery \(Distance.spoken(spot.span.lowerBound)) right of your meter"] } else if spot.span.upperBound < 0 { - parts = ["Battery \(Self.feetAndInches(-spot.span.upperBound)) left of your meter"] + parts = ["Battery \(Distance.spoken(-spot.span.upperBound)) left of your meter"] } else { parts = ["Battery below your meter"] } if let cable = result.cableLength { - parts.append("cable \(Self.feetAndInches(cable))") + parts.append("cable \(Distance.spoken(cable))") } return parts.joined(separator: ", ") } - - /// "3 ft 4 in", "12 ft", "8 in", rounded to the nearest inch. - private static func feetAndInches(_ meters: Float) -> String { - let inches = Int((meters / 0.0254).rounded()) - let feet = inches / 12 - let rest = inches % 12 - switch (feet, rest) { - case (0, _): return "\(rest) in" - case (_, 0): return "\(feet) ft" - default: return "\(feet) ft \(rest) in" - } - } } /// Fixed scene colors. They match the asset-catalog palette in light mode; a model of a house diff --git a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift index c8cbefc32..a315d76e2 100644 --- a/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift +++ b/ios/HouseScan/UI/Screens/MarkFeaturesScreen.swift @@ -175,7 +175,7 @@ private struct FeatureRow: View { .frame(width: Metrics.minTarget, height: Metrics.minTarget) } .foregroundStyle(Palette.danger) - .accessibilityLabel("Remove \(ScanCopy.name(feature.kind).lowercased())") + .accessibilityLabel("Remove \(ScanCopy.noun(feature.kind))") .accessibilityIdentifier("action.deleteFeature") } if feature.kind == .window { @@ -229,7 +229,7 @@ private struct FlowChips: View { .contentShape(.rect(cornerRadius: 14)) } .buttonStyle(PressableStyle()) - .accessibilityLabel("Add \(ScanCopy.name(kind).lowercased())") + .accessibilityLabel("Add \(ScanCopy.noun(kind))") .accessibilityIdentifier("feature.\(kind.rawValue)") } } diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index e7689dce2..3aa4541a9 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -245,7 +245,7 @@ private struct CheckRowView: View { .padding(14) .accessibilityElement(children: .combine) .accessibilityLabel("\(row.title): \(ScanCopy.outcomeWord(row.outcome))") - .accessibilityValue([row.reason, ScanCopy.measurement(row), row.outcome == .unsure ? ScanCopy.unsureNote(row) : nil] + .accessibilityValue([row.reason, ScanCopy.measurement(row, spoken: true), row.outcome == .unsure ? ScanCopy.unsureNote(row) : nil] .compactMap(\.self).joined(separator: ". ")) .accessibilityIdentifier("check.\(row.id)") } From d3d053cecbb385f881ca0650e5868e7656406916 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 16:29:27 -0500 Subject: [PATCH 2/4] fix(ios): show "Check clearances" once the scan is sent (#31) The upload delegate held progress at 0.99 until the server answered, and the server works out the spot inside that request. So the screen sat on "Sending measurements, 99%" for the whole analysis and "Check clearances" never lit up. Report 1 once every byte of the body is sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ResultClient.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift index 0d860e5f6..9ed18b780 100644 --- a/ios/HouseScan/Runtime/ResultClient.swift +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -105,9 +105,17 @@ final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { self.progress = progress } + /// Once the whole body is sent the server is working out the spot in the same request, so + /// report 1 and let the screen move on to "Check clearances". Before that, stop at 0.99 so + /// rounding never shows 100%. Holding 0.99 until the answer left the screen on "Sending + /// measurements, 99%" for the whole analysis (field test run 1). func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { guard totalBytesExpectedToSend > 0 else { return } - progress(min(0.99, Double(totalBytesSent) / Double(totalBytesExpectedToSend))) + if totalBytesSent >= totalBytesExpectedToSend { + progress(1) + } else { + progress(min(0.99, Double(totalBytesSent) / Double(totalBytesExpectedToSend))) + } } } From e7603276b7bde8084540a9495ba9fd00df1609e3 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 16:29:28 -0500 Subject: [PATCH 3/4] fix(ios): no "Slow down" while the mark tray is open (#26) In field test run 1 "Slow down" showed twice while the tester stood still with "What do you see?" open: turning the phone to aim reads as moving to the capture gate. Hide that one prompt while the tray is open; tracking problems still show. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 5022e88df..d2a594b9a 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -79,13 +79,21 @@ struct WallWalkScreen: View { } if let side = state.endQuestion { return ScanCopy.endQuestion(side) } if state.overheadQuestion { return ScanCopy.overheadQuestion } - if let coaching = state.coaching { return ScanCopy.coaching(coaching) } + if let coaching { return ScanCopy.coaching(coaching) } return ScanCopy.guidance(state.guidance) } + /// "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. + private var coaching: Coaching? { + if trayOpen, state.coaching == .slowDown { return nil } + return state.coaching + } + private var tone: InstructionCard.Tone { if state.marking?.refusal != nil { return .refusal } - if state.marking == nil, state.endQuestion == nil, !state.overheadQuestion, let coaching = state.coaching { + if state.marking == nil, state.endQuestion == nil, !state.overheadQuestion, let coaching { return .coaching(symbol: ScanCopy.coachingSymbol(coaching)) } if state.marking == nil, case .markNextWall(_, _?) = state.guidance { return .refusal } @@ -327,7 +335,7 @@ struct FeatureTray: View { .contentShape(.rect(cornerRadius: 14)) } .buttonStyle(PressableStyle()) - .accessibilityLabel("Mark \(ScanCopy.name(kind).lowercased())") + .accessibilityLabel("Mark \(ScanCopy.noun(kind))") .accessibilityIdentifier("feature.\(kind.rawValue)") } } From 57a3ae1b2cec516cbaf4f3fd8415b8d47925d125 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 16:34:35 -0500 Subject: [PATCH 4/4] fix(ios): spell the measurement's length formatter as a function The ternary between two static methods made the compiler fail with "failed to produce diagnostic for expression" on CI. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 9415a1162..b23837d7f 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -367,7 +367,9 @@ enum ScanCopy { /// `spoken` spells out feet and inches for VoiceOver, which reads "ft" and "in" as letters. static func measurement(_ row: CheckRow, spoken: Bool = false) -> String? { guard let measured = row.measured else { return nil } - let length: (Float) -> String = spoken ? Distance.spoken : Distance.feetAndInches + func length(_ meters: Float) -> String { + spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) + } var parts = ["Measured \(length(measured))."] if let threshold = row.threshold { if let plusMinus = row.plusMinus, plusMinus > 0 {