Skip to content
Open
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
3 changes: 3 additions & 0 deletions .greptile/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"autoReview": ["open", "push"]
}
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ State what the old code did before describing the new behavior. Link the reviewe

The `size:*` label describes the effective diff. It is a review signal, not a merge gate. Do not set it by hand.

Greptile reviews new commits through [`.greptile/config.json`](.greptile/config.json). The dashboard's open-only trigger left follow-up commits unreviewed. This repository override adds push reviews without changing other repositories' settings. Greptile reads this setting from the PR's source branch, so older branches need the config too. For a branch without it, request a current review with `@greptileai review` after pushing. Use `@greptileai review this draft` for drafts.

## Checks

| Check | Runs on GitHub when | Local command |
Expand Down
14 changes: 1 addition & 13 deletions ios/HouseScan/Contract/ResultPresentation+Reading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,7 @@ extension ResultPresentation {
checks.map { row in
ResultReading.Check(
id: row.id, outcome: row.outcome.placementOutcome, needsPerson: row.needsPerson,
viewCapturable: viewToTake(for: row) != nil,
margin: ResultReading.margin(
measured: row.measured.map(Double.init), threshold: row.threshold.map(Double.init),
plusMinus: row.plusMinus.map(Double.init), comparison: row.comparison?.placementComparison)
viewCapturable: viewToTake(for: row) != nil
)
}
}
Expand All @@ -102,12 +99,3 @@ private extension CheckOutcome {
}
}
}

private extension RuleComparison {
var placementComparison: PlacementComparison {
switch self {
case .atLeast: .atLeast
case .atMost: .atMost
}
}
}
31 changes: 24 additions & 7 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -627,10 +627,11 @@ enum ScanCopy {

// MARK: Result

/// The answer in the homeowner's words (`ResultPresentation.answer`).
/// The answer in the homeowner's words (`ResultPresentation.answer`). A passing spot is a
/// possible one, never "fits": see `candidateNote`.
static func headline(_ answer: ResultReading.Answer) -> String {
switch answer {
case .fits: "A battery fits here"
case .candidate: "A possible battery spot"
case .oneMoreLook: "One more look"
case .installer: "Needs an installer's review"
case .notHere: "Not on this wall"
Expand Down Expand Up @@ -690,7 +691,18 @@ enum ScanCopy {
return "Settles: \(titles.joined(separator: ", "))"
}

static let seeOnWall = "See it on your wall"
/// Under a possible spot's headline and placement. The server's checks pass on space the scan
/// recorded, some of it beyond what the homeowner confirmed in the spot check, so the app
/// can't say all the space a battery needs is clear (`ResultReading`, B17). It says what the
/// fit still needs, never that a review was sent: the app contacts nobody.
static let candidateNote = "The scan suggests this spot, but it couldn't confirm all the space a battery needs. An installer needs to check the fit on site."

/// The AR title over a possible spot, and the words for what the AR view draws there.
static let proposedSpotOverlay = "A proposed battery spot, drawn on your wall"
static let proposedSpotThisWay = "The proposed spot is this way"
static let proposedSpotOffScreen = "The proposed spot is off screen. Turn the phone toward the arrow."

static let seeOnWall = "See this spot on your wall"
/// For a spot an installer still has to confirm against the meter's working space.
static let seeClosest = "See the closest spot"
static let showMe = "Show me"
Expand All @@ -703,12 +715,12 @@ enum ScanCopy {
static let wallNotMeasuredDetail = "The scan stopped before you walked along the wall on either side of your meter, so we can't tell where a battery would fit. Scan again and walk a few steps each way."
static let scanAgain = "Scan again"

/// Shown on every result, with or without a spot ("Not on this wall" has none), so it names
/// no spot.
/// Shown on every result but a possible spot, whose `candidateNote` says it already, with or
/// without a spot ("Not on this wall" has none), so it names no spot.
static let installerConfirms = "Before any battery goes in, an installer has to confirm where it goes on site."
static let rulesNotFinal = "The placement rules aren't final yet, so every result needs an installer's review for now."
// The server's result covers where the battery goes, not the panel itself.
static let panelReview = "Your electrical panel still needs an electrician's review. This scan only covers where the battery can go."
static let panelReview = "Your electrical panel still needs an electrician's review. This scan only suggests where the battery could go."

/// Which rules answered, for a reviewer: "Rules 2f52ec35".
static func rulesHash(_ hash: String) -> String {
Expand Down Expand Up @@ -807,9 +819,14 @@ enum ScanCopy {
}
}

/// The heading over every check in Details, and what a passing one means there: the server's
/// calculation on the scan's measurements, not space anyone confirmed clear.
static let calculatedTitle = "What the server calculated"
static let calculatedNote = "From what your scan recorded. A passing check means those measurements meet the rule, not that the space is confirmed clear."

static func outcomeWord(_ outcome: CheckOutcome) -> String {
switch outcome {
case .pass: "Looks good"
case .pass: "Passes on recorded data"
case .unsure: "Not sure yet"
case .fail: "Doesn't work"
}
Expand Down
2 changes: 1 addition & 1 deletion ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1212,7 +1212,7 @@ final class DemoEngine: ScanActions {
/// The offline sample result. Placeholder rules, so the decision is manual review.
static let reviewSample = ResultPresentation(
decision: .manualReview,
summary: "A spot 3 ft right of your meter fits, but it's close to a window, so it needs an installer's review.",
summary: "The best spot, 3 ft right of your meter, is close to a window, so it needs an installer's review.",
policyApproved: false,
spot: BatterySpot(span: 0.55...1.34, depth: 0.56, height: 1.1, offsetFromWall: 0.03),
cableRoute: [SIMD2(0.16, 1.45), SIMD2(0.95, 1.45), SIMD2(0.95, 1.1)],
Expand Down
16 changes: 9 additions & 7 deletions ios/HouseScan/UI/Result/ResultScene3D.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ struct ResultScene3D: View {
}

for (index, zone) in result.clearances.enumerated() {
var material = UnlitMaterial(color: SceneColor.outcome(zone.outcome))
var material = UnlitMaterial(color: SceneColor.zone(zone.outcome))
material.blending = .transparent(opacity: .init(floatLiteral: 0.35))
// Stacked zones sit a few millimeters apart so overlapping ones don't flicker.
let lift = ResultMarkLayout.zoneLift(index: index, base: Self.zoneBase)
Expand Down Expand Up @@ -467,11 +467,11 @@ struct ResultScene3D: View {
}
var parts: [String]
if spot.span.lowerBound > 0 {
parts = ["Battery \(Distance.spoken(spot.span.lowerBound)) right of your meter"]
parts = ["Proposed battery spot \(Distance.spoken(spot.span.lowerBound)) right of your meter"]
} else if spot.span.upperBound < 0 {
parts = ["Battery \(Distance.spoken(-spot.span.upperBound)) left of your meter"]
parts = ["Proposed battery spot \(Distance.spoken(-spot.span.upperBound)) left of your meter"]
} else {
parts = ["Battery below your meter"]
parts = ["Proposed battery spot below your meter"]
}
if let cable = result.cableLength {
parts.append("cable \(Distance.spoken(cable))")
Expand Down Expand Up @@ -549,7 +549,7 @@ enum ResultARModel {
wall.world(s: s, height: height, out: out) - wall.meter
}
for (index, zone) in result.clearances.enumerated() {
var material = UnlitMaterial(color: SceneColor.outcome(zone.outcome))
var material = UnlitMaterial(color: SceneColor.zone(zone.outcome))
material.blending = .transparent(opacity: .init(floatLiteral: 0.35))
let width = zone.span.upperBound - zone.span.lowerBound
let middle = zone.span.lowerBound + width / 2
Expand Down Expand Up @@ -659,9 +659,11 @@ private enum SceneColor {
}
}

static func outcome(_ outcome: CheckOutcome) -> UIColor {
/// A clearance zone's tint, as `Palette.zone`: where the server's sweep passes, the
/// proposal's blue, not a green that reads as ground confirmed clear.
static func zone(_ outcome: CheckOutcome) -> UIColor {
switch outcome {
case .pass: rgb(0x2FC273)
case .pass: signal
case .unsure: rgb(0xF5B53D)
case .fail: rgb(0xFF5A4E)
}
Expand Down
19 changes: 14 additions & 5 deletions ios/HouseScan/UI/ScanRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,20 @@ struct ScanRootView: View {
/// Haptics for the moments that matter, fired on the same state change the screen animates:
/// a deliberate photo, the meter pinned, a mark placed or refused, a requested view done, the
/// result.
///
/// The result arrives with a light tap, whatever it says. `.success` there celebrated every
/// answer alike, a rejected wall included, and on a possible spot it said "done, it fits" when
/// the scan couldn't confirm the space (B17, B26). Coming back from the AR view taps nothing.
private struct ScanHaptics: ViewModifier {
let state: ScanViewState

// Two steps: the five-modifier chain took 215 ms to type-check on Swift 6.4, and CI's
// Swift 6.2 has failed on slower expressions before.
// Steps of at most three: the five-modifier chain took 215 ms to type-check on Swift 6.4, and
// CI's Swift 6.2 has failed on slower expressions before.
func body(content: Content) -> some View {
let captures = content
.sensoryFeedback(.impact(weight: .light, intensity: 0.7), trigger: deliberateCaptureID) { _, new in new != nil }
.sensoryFeedback(.success, trigger: state.phase, condition: Self.isMilestone)
.sensoryFeedback(.success, trigger: state.phase, condition: Self.meterPinned)
.sensoryFeedback(.impact(weight: .light), trigger: state.phase, condition: Self.resultArrived)
return captures
.sensoryFeedback(.success, trigger: state.gap?.isSatisfied ?? false) { _, new in new }
.sensoryFeedback(.impact(weight: .medium), trigger: state.features.count) { old, new in new > old }
Expand All @@ -116,8 +121,12 @@ private struct ScanHaptics: ViewModifier {
return capture.id
}

private static func isMilestone(_ old: ScanPhase, _ new: ScanPhase) -> Bool {
(old == .findMeter && new == .meterCloseUp) || (old != .resultAR && new == .result)
private static func meterPinned(_ old: ScanPhase, _ new: ScanPhase) -> Bool {
old == .findMeter && new == .meterCloseUp
}

private static func resultArrived(_ old: ScanPhase, _ new: ScanPhase) -> Bool {
old != .resultAR && new == .result
}
}

Expand Down
33 changes: 22 additions & 11 deletions ios/HouseScan/UI/Screens/ResultARScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ struct ResultARScreen: View {
CameraChrome(
instruction: instruction,
isReplay: state.isReplay,
isAutopilot: state.isAutopilot
isAutopilot: state.isAutopilot,
aims: showsSpot
) {
Button("Done") { actions.closeAR() }
.buttonStyle(.primary)
Expand All @@ -53,10 +54,20 @@ struct ResultARScreen: View {
}
}

/// Whether the spot is on the wall in view: the phone tracks and the result has a spot. Then
/// the homeowner is looking at the spot, so at the accessibility text sizes the card folds
/// its detail (the placement, and what the fit still needs) under Details and leaves the
/// camera open (`CameraChrome.aims`). At AX-XXXL the full card covered nearly the whole
/// camera (run 37162669527, resultAR-candidate-settled-AX5). "Point at your meter" while
/// tracking is lost keeps every word in view: it is what to do, and nothing is drawn yet.
private var showsSpot: Bool {
state.tracking == .normal && state.result?.spot != nil
}

private static func overlayLabel(_ result: ResultPresentation) -> String {
guard result.spot != nil else { return "The cable run and clearances, drawn on your wall" }
return result.spotIsClean
? "The battery, drawn on your wall at its spot"
? ScanCopy.proposedSpotOverlay
: "An outline of the spot an installer needs to check, drawn on your wall"
}

Expand All @@ -67,17 +78,17 @@ struct ResultARScreen: View {
guard let result = state.result, result.spot != nil else {
return Instruction(title: "Point at your meter", detail: nil)
}
// Only a pass under approved rules may sound settled; a spot an installer still has to
// check says so, as the result screen does.
let title = result.decision == .pass && result.policyApproved && result.spotIsClean
? "Your battery could go here"
: "The spot an installer needs to check"
// The same answer as the result screen: a possible spot says it's possible and what it
// still needs; any other spot an installer still has to check says so.
let candidate = result.answer == .candidate
let title = candidate ? ScanCopy.headline(.candidate) : "The spot an installer needs to check"
let placement = ScanCopy.placement(result)
guard !result.isSample else {
// A sample spot drawn on the homeowner's real wall must not pass for their result.
return Instruction(title: "Example spot, not your result", detail: ["No server checked this scan.", placement].compactMap { $0 }.joined(separator: " "))
}
return Instruction(title: title, detail: placement)
let onSite = candidate ? "An installer needs to check the fit on site." : nil
return Instruction(title: title, detail: [placement.map { "\($0)." }, onSite].compactMap { $0 }.joined(separator: " "))
}
}

Expand Down Expand Up @@ -107,7 +118,7 @@ private struct SpotDirection: View {
if let chevron = chevronPlacement(in: size) {
ZStack {
TargetMarker(placement: .offScreen(chevron.point, angle: chevron.angle))
Text("Your battery spot is this way")
Text(ScanCopy.proposedSpotThisWay)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Palette.chalk)
.multilineTextAlignment(.center)
Expand All @@ -122,7 +133,7 @@ private struct SpotDirection: View {
}
.frame(width: size.width, height: size.height)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Your battery spot is off screen. Turn the phone toward the arrow.")
.accessibilityLabel(ScanCopy.proposedSpotOffScreen)
.accessibilityIdentifier("ar.spotDirection")
}
}
Expand Down Expand Up @@ -187,7 +198,7 @@ struct BatteryOverlay: View, Animatable {
private func drawClearances(in context: inout GraphicsContext, _ geometry: WallProjection) {
for zone in result.clearances {
guard let quad = geometry.groundQuad(s: zone.span, out: 0...zone.depth, height: 0.01) else { continue }
let color = Palette.outcome(zone.outcome)
let color = Palette.zone(zone.outcome)
context.fill(quad, with: .color(color.opacity(0.28 * rise)))
context.stroke(quad, with: .color(color.opacity(0.9 * rise)), style: StrokeStyle(lineWidth: 2, dash: [8, 6]))
}
Expand Down
Loading
Loading