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))) + } } } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 12c56b6d0..d18729e0f 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -153,6 +153,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" @@ -165,10 +174,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): @@ -180,7 +189,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.") } } @@ -366,14 +375,18 @@ 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))."] + 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 { - 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)") } diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 9d7d5dd44..7b3f7cc01 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -81,13 +81,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 } @@ -360,7 +368,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)") } }