From 3efc993ada7327c9ca139f0d08dadf10d2e5cf71 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 17:04:02 +0200 Subject: [PATCH 1/5] iPhone: the receipt log, four slots lit on their evidence, on the Live Activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReceiptLog (FTWShared) is the car command's receipt in the terminal layer: SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED. Every slot is always on the line and lights only on its own evidence: the local send, the box's cmd.ack, lane 0 showing the car above 42 W while live once the box has the command, and cmd.result. The box often confirms before the car draws, so the slots light in the order their evidence comes. A refusal takes the last slot in alarm red, padded to the same width, so the line never moves. ReceiptRow (FTWCharts) draws it for the Live Activity and, next, the car view. The Charge now Live Activity shows the receipt in place of its three capsules. LoadpointsModel lights MEASURED from lane 0 when the home has one Live Activity up; lane 0 has one car figure for every charger. The card goes stale two minutes after the box last confirmed it (it was fifteen): the app confirms it once a minute while someone looks, and nothing does while it sleeps. The Control still trusts its record for fifteen. The card sits on FTW's ground with SF Pro figures and never glows. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWCharts/ReceiptRow.swift | 39 +++++++ .../FTWKit/State/LoadpointsModel.swift | 16 ++- .../FTWKit/Sources/FTWShared/ChargeNow.swift | 66 ++++++++--- .../FTWKit/Sources/FTWShared/ReceiptLog.swift | 109 ++++++++++++++++++ .../Resources/sv.lproj/Localizable.strings | 7 ++ .../Tests/FTWKitTests/RemoteTests.swift | 24 ++++ .../Tests/FTWSharedTests/ChargeNowTests.swift | 48 ++++++-- .../FTWSharedTests/ReceiptLogTests.swift | 61 ++++++++++ .../LiveActivity/ChargeNowLiveActivity.swift | 69 ++++------- 9 files changed, 366 insertions(+), 73 deletions(-) create mode 100644 appleApp/FTWCharts/ReceiptRow.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift diff --git a/appleApp/FTWCharts/ReceiptRow.swift b/appleApp/FTWCharts/ReceiptRow.swift new file mode 100644 index 0000000..20130ed --- /dev/null +++ b/appleApp/FTWCharts/ReceiptRow.swift @@ -0,0 +1,39 @@ +import FTWShared +import SwiftUI + +/// The receipt log, `SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED`, in the +/// terminal layer: one line of SF Mono caps with every slot always there, +/// so lighting one never moves another. A lit slot is ink, a waiting one +/// ink3; CONFIRMED is lime, and REFUSED takes its place in alarm red. +/// +/// `dimmed` draws every slot in ink3 and keeps the lit ones a little +/// stronger: a card the phone can no longer vouch for still says how far +/// the command came, without the colour of a state. +struct ReceiptRow: View { + let receipt: ReceiptLog + var dimmed = false + var size: CGFloat = 10.5 + + var body: some View { + receipt.marks.enumerated().reduce(Text(verbatim: "")) { line, item in + let (i, mark) = item + let word = Text(verbatim: mark.text).foregroundStyle(color(mark)) + return i == 0 ? word : Text("\(line)\(arrow)\(word)") + } + .font(.system(size: size, weight: .semibold, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .accessibilityLabel(receipt.line) + } + + private var arrow: Text { Text(verbatim: ReceiptLog.arrow).foregroundStyle(Theme.ink3) } + + private func color(_ mark: ReceiptLog.Mark) -> Color { + switch (mark.state, dimmed) { + case (.waiting, _): return Theme.ink3.opacity(dimmed ? 0.6 : 1) + case (.lit, true), (.refused, true): return Theme.ink3 + case (.lit, false): return mark.slot == .confirmed ? Theme.live : Theme.ink + case (.refused, false): return Theme.alarm + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 0338acc..40c8c9a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -124,7 +124,10 @@ public final class LoadpointsModel: Activatable { guard let self, let stamp = self.stamp else { return } home.chargers = Stamped(Chargers(points: stamp.value, windows: self.windows), asOfMs: stamp.asOfMs, origin: stamp.origin) } - site.observe { [weak self] in self?.takeCloud() } + site.observe { [weak self] in + self?.takeCloud() + self?.noticeDrawing() + } // A Live Activity from another home, or from the demo, has no // business on this phone once this home is open. activityHost?.endAll(except: activityHome) @@ -466,6 +469,17 @@ public final class LoadpointsModel: Activatable { lp.manualActive && !EVText.isPaused(lp) } + /// MEASURED on the receipt: lane 0 shows the car drawing power, live. + /// Lane 0 has one figure for every charger, so it lights the Live + /// Activity only when the home has one up. + private func noticeDrawing() { + guard ReceiptLog.measures(evW: site.session.fields[Contract.FID.evW], live: site.isLive), + let host = activityHost else { return } + let ids = host.loadpointIDs(home: activityHome) + guard ids.count == 1 else { return } + showChargeNow(ids[0], .measured(atMs: site.scheduler.nowMs)) + } + /// The Live Activity's home key: the site, or the demo's own. public var activityHome: String { site.siteId ?? "demo" } diff --git a/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift b/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift index 30a9e2e..fb60f14 100644 --- a/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift +++ b/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift @@ -30,11 +30,15 @@ public enum ChargeNowActivity { public var phase: Phase public var startedAtMs: Double public var confirmedAtMs: Double + /// When lane 0 first showed the car drawing power after this + /// command, on this phone's clock. Nil until it has. + public var measuredAtMs: Double? - public init(phase: Phase = .on, startedAtMs: Double, confirmedAtMs: Double) { + public init(phase: Phase = .on, startedAtMs: Double, confirmedAtMs: Double, measuredAtMs: Double? = nil) { self.phase = phase self.startedAtMs = startedAtMs self.confirmedAtMs = confirmedAtMs + self.measuredAtMs = measuredAtMs } public init(from decoder: Decoder) throws { @@ -42,6 +46,7 @@ public enum ChargeNowActivity { phase = try c.decodeIfPresent(Phase.self, forKey: .phase) ?? .on startedAtMs = try c.decode(Double.self, forKey: .startedAtMs) confirmedAtMs = try c.decode(Double.self, forKey: .confirmedAtMs) + measuredAtMs = try c.decodeIfPresent(Double.self, forKey: .measuredAtMs) } public var startedAt: Date { Date(timeIntervalSince1970: startedAtMs / 1000) } @@ -55,13 +60,25 @@ public enum ChargeNowActivity { public func isStale(atMs nowMs: Double) -> Bool { nowMs >= confirmedAtMs + window } - private var window: Double { phase == .on ? ChargeNowActivity.vouchMs : ChargeNowActivity.answerMs } + private var window: Double { phase == .on ? ChargeNowActivity.staleMs : ChargeNowActivity.answerMs } + + /// The receipt log as far as this card knows it. A card that reads + /// as on has been sent, accepted and confirmed. + public var receipt: ReceiptLog { + ReceiptLog(sent: true, accepted: phase != .sent, measured: measuredAtMs != nil, confirmed: phase == .on) + } } - /// How long one confirmation is shown as current. The app confirms it - /// again on every read of the chargers and whenever it comes to the - /// front; until the box can push to the Live Activity (M7), nothing - /// confirms it while the app sleeps. + /// How long one confirmation is shown as current on the Live Activity. + /// The app confirms it again on every read of the chargers, once a + /// minute while someone looks; until the box can push to the Live + /// Activity (M7), nothing confirms it while the app sleeps, so two + /// minutes after the last read it says when that was. + public static let staleMs: Double = 2 * 60_000 + + /// How long the Control trusts the car record the app left: Charge now + /// on or off as the box last confirmed it. A switch in Control Center + /// is a way in, not a reading, so it stays useful for longer. public static let vouchMs: Double = 15 * 60_000 /// How long a command on its way is shown as on its way. The box has @@ -77,6 +94,8 @@ public enum ChargeNowActivity { /// The box confirmed Charge now: its `cmd.result`, or, when the /// result never came, a read of the charger after this phone asked. case started(atMs: Double) + /// Lane 0 shows the car drawing power. + case measured(atMs: Double) /// The box refused it, or nothing confirmed it. case refused /// The box confirmed Back to the plan or Pause. @@ -109,10 +128,19 @@ public enum ChargeNowActivity { // A second press while it is on changes nothing on the card. return s.phase == .on ? .none : .update(State(phase: .sent, startedAtMs: at, confirmedAtMs: at)) case (let s?, .received(let at)): - return s.phase == .sent ? .update(State(phase: .received, startedAtMs: s.startedAtMs, confirmedAtMs: at)) : .none + return s.phase == .sent ? .update(State(phase: .received, startedAtMs: s.startedAtMs, confirmedAtMs: at, measuredAtMs: s.measuredAtMs)) : .none case (let s?, .started(let at)): - if s.phase == .on { return .update(State(phase: .on, startedAtMs: s.startedAtMs, confirmedAtMs: max(s.confirmedAtMs, at))) } - return .update(State(phase: .on, startedAtMs: at, confirmedAtMs: at)) + if s.phase == .on { return .update(State(phase: .on, startedAtMs: s.startedAtMs, confirmedAtMs: max(s.confirmedAtMs, at), measuredAtMs: s.measuredAtMs)) } + return .update(State(phase: .on, startedAtMs: at, confirmedAtMs: at, measuredAtMs: s.measuredAtMs)) + case (let s?, .measured(let at)): + // Lit once, from the first reading that shows it after the box + // has the command: a car that was already drawing says nothing + // about a command still on its way. A reading confirms nothing + // about Charge now itself. + guard s.measuredAtMs == nil, s.phase != .sent else { return .none } + var next = s + next.measuredAtMs = at + return .update(next) case (let s?, .refused): // A refused second press leaves Charge now as it was. return s.phase == .on ? .none : .end @@ -121,7 +149,7 @@ public enum ChargeNowActivity { case (let s?, .seen(let holding, let at)): // While the command is on its way, its own answer decides. guard s.phase == .on else { return .none } - return holding ? .update(State(phase: .on, startedAtMs: s.startedAtMs, confirmedAtMs: max(s.confirmedAtMs, at))) : .end + return holding ? .update(State(phase: .on, startedAtMs: s.startedAtMs, confirmedAtMs: max(s.confirmedAtMs, at), measuredAtMs: s.measuredAtMs)) : .end } } @@ -144,30 +172,30 @@ public enum ChargeNowActivity { public var headline: String public var subtitle: String public var timer: Timer - /// How many of the band's three steps are done: sent, received, on. - public var steps: Int + /// `SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED`, each lit on its evidence. + public var receipt: ReceiptLog public var dimmed: Bool } public static func face(_ state: State, stale: Bool, zone: TimeZone) -> Face { - let steps = State.Phase.allCases.firstIndex(of: state.phase).map { $0 + 1 } ?? 3 + let receipt = state.receipt switch (state.phase, stale) { case (.on, false): - return Face(headline: since(state, zone: zone), subtitle: noTimeLimit, timer: .running(from: state.startedAt), steps: steps, dimmed: false) + return Face(headline: since(state, zone: zone), subtitle: noTimeLimit, timer: .running(from: state.startedAt), receipt: receipt, dimmed: false) case (.on, true): return Face( headline: confirmed(state, zone: zone), subtitle: ChargeNowWord.mayHaveChanged.text, timer: .stopped(from: state.startedAt, at: state.confirmedAt), - steps: steps, + receipt: receipt, dimmed: true ) case (.sent, false): - return Face(headline: ChargeNowWord.sending.text, subtitle: ChargeNowWord.waitingForFTW.text, timer: .waiting, steps: steps, dimmed: false) + return Face(headline: ChargeNowWord.sending.text, subtitle: ChargeNowWord.waitingForFTW.text, timer: .waiting, receipt: receipt, dimmed: false) case (.received, false): - return Face(headline: ChargeNowWord.received.text, subtitle: ChargeNowWord.waitingForCharger.text, timer: .waiting, steps: steps, dimmed: false) + return Face(headline: ChargeNowWord.received.text, subtitle: ChargeNowWord.waitingForCharger.text, timer: .waiting, receipt: receipt, dimmed: false) case (_, true): - return Face(headline: ChargeNowWord.noAnswer.text, subtitle: ChargeNowWord.mayNotHaveStarted.text, timer: .waiting, steps: steps, dimmed: true) + return Face(headline: ChargeNowWord.noAnswer.text, subtitle: ChargeNowWord.mayNotHaveStarted.text, timer: .waiting, receipt: receipt, dimmed: true) } } @@ -223,7 +251,7 @@ public struct ChargeNowControlState: Equatable, Sendable { } /// The car with a cable, else the first; its hold as the box confirmed - /// it, if the record is no older than a Live Activity would vouch for. + /// it, if the record is no older than `ChargeNowActivity.vouchMs`. public static func from(_ read: CacheRead?, nowMs: Double) -> ChargeNowControlState { guard let read else { return ChargeNowControlState(known: .unknown) } let cars = read.value.cars diff --git a/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift b/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift new file mode 100644 index 0000000..ebf993d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift @@ -0,0 +1,109 @@ +import Foundation + +/// How far the box has confirmed one car command, as the receipt log +/// `SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED`: four reserved slots, each lit +/// only on its own evidence. +/// +/// - SENT: this phone sent the command. +/// - ACCEPTED: the box has it (`cmd.ack`). +/// - MEASURED: once the box has it, lane 0 shows the car drawing more than +/// `drawingW`. +/// - CONFIRMED: the box did it (`cmd.result`). +/// +/// The slots light in the order their evidence comes, not in the order +/// they are written: the box often confirms before the car draws. A refusal +/// takes the last slot, in alarm red, with the box's reason. The car view, +/// the Live Activity and the watch read the same rules. +public struct ReceiptLog: Equatable, Hashable, Codable, Sendable { + public enum Slot: String, CaseIterable, Codable, Sendable { + case sent, accepted, measured, confirmed + } + + public var sent: Bool + public var accepted: Bool + public var measured: Bool + public var confirmed: Bool + /// The box's reason, ready to show. Set, it takes the last slot. + public var refused: String? + + public init(sent: Bool = true, accepted: Bool = false, measured: Bool = false, confirmed: Bool = false, refused: String? = nil) { + self.sent = sent + self.accepted = accepted + self.measured = measured + self.confirmed = confirmed + self.refused = refused + } + + /// Above this the car draws power: the threshold Now uses for a beam. + public static let drawingW: Double = 42 + + /// Whether lane 0's car power is evidence that the car draws. + public static func measures(evW: Double?, live: Bool) -> Bool { + guard live, let evW else { return false } + return evW > drawingW + } + + public func lit(_ slot: Slot) -> Bool { + switch slot { + case .sent: return sent + case .accepted: return accepted + case .measured: return measured + case .confirmed: return confirmed && refused == nil + } + } + + /// The slots as the line shows them, each with its word and whether it + /// is lit. The last slot is REFUSED once the box refused. Every word is + /// padded to the width its slot reserves, so a slot that lights or + /// turns to REFUSED never moves the others. + public var marks: [Mark] { + Slot.allCases.map { slot in + if slot == .confirmed, refused != nil { + return Mark(slot: slot, text: Self.reserved(.confirmed, ReceiptWord.refused.text), state: .refused) + } + return Mark(slot: slot, text: Self.reserved(slot, Self.word(slot)), state: lit(slot) ? .lit : .waiting) + } + } + + public struct Mark: Equatable, Sendable { + public enum State: Equatable, Sendable { case waiting, lit, refused } + public let slot: Slot + public let text: String + public let state: State + } + + /// "SENT", "ACCEPTED", "MEASURED", "CONFIRMED", in the terminal's caps. + public static func word(_ slot: Slot) -> String { + switch slot { + case .sent: return ReceiptWord.sent.text + case .accepted: return ReceiptWord.accepted.text + case .measured: return ReceiptWord.measured.text + case .confirmed: return ReceiptWord.confirmed.text + } + } + + /// The arrow between slots. + public static let arrow = " ▸ " + + /// The whole line as plain text, for VoiceOver and a test. + public var line: String { marks.map { $0.text.trimmingCharacters(in: .whitespaces) }.joined(separator: Self.arrow) } + + /// A word padded with spaces to its slot's width. The last slot holds + /// either CONFIRMED or REFUSED, so it reserves the longer. + static func reserved(_ slot: Slot, _ text: String) -> String { + let width = slot == .confirmed ? max(ReceiptWord.confirmed.text.count, ReceiptWord.refused.text.count) : text.count + return text.padding(toLength: max(width, text.count), withPad: " ", startingAt: 0) + } +} + +/// The receipt's words. The English is the key; the Swedish lives in +/// `Resources/sv.lproj`, and a test checks each one has it. +enum ReceiptWord: String, CaseIterable { + case sent = "SENT" + case accepted = "ACCEPTED" + case measured = "MEASURED" + case confirmed = "CONFIRMED" + case refused = "REFUSED" + + var text: String { Words.text(rawValue) } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 4357b6f..7c07802 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -73,3 +73,10 @@ "Open FTW to check" = "Öppna FTW för att se"; "Open FTW to charge now." = "Öppna FTW för att ladda nu."; "Open FTW, then try again." = "Öppna FTW och försök igen."; + +/* The receipt log under a car command, in the terminal's caps */ +"SENT" = "SKICKAT"; +"ACCEPTED" = "MOTTAGET"; +"MEASURED" = "UPPMÄTT"; +"CONFIRMED" = "BEKRÄFTAT"; +"REFUSED" = "NEKAT"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift index aab596b..5836fd7 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift @@ -104,6 +104,30 @@ import FTWShared } } + /// MEASURED lights once lane 0 shows the car drawing power, which here + /// comes after the box confirmed: the charger ramps for ten seconds. + @Test func lane0LightsMeasuredOnTheLiveActivity() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + // A slow car: the box confirms long before it draws. + rig.box?.house.chargerRampMs = 10_000 + _ = await rig.perform(.chargeNow, loadpoints) + let confirmed = try #require(rig.activities.states["demo/garage"]) + #expect(confirmed.phase == .on) + #expect(confirmed.measuredAtMs == nil) + #expect(confirmed.receipt.marks.map(\.state) == [.lit, .lit, .waiting, .lit]) + await rig.run(8_000) + let measured = try #require(rig.activities.states["demo/garage"]) + let at = try #require(measured.measuredAtMs) + #expect(at > confirmed.confirmedAtMs) + #expect(measured.receipt.marks.allSatisfy { $0.state == .lit }) + // Lit once: later readings do not move it. + await rig.run(3_000) + #expect(rig.activities.states["demo/garage"]?.measuredAtMs == at) + } + } + /// Back to the plan from the Live Activity's button: a clear, and the /// Live Activity ends on the box's result. @Test func backToThePlanClearsTheHoldAndEndsTheLiveActivity() async throws { diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift index 188d82e..f687571 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift @@ -72,17 +72,42 @@ import Testing #expect(ChargeNowActivity.next(on, .seen(holding: false, atMs: Self.t + 1)) == .end) } - /// The phone vouches for 15 minutes after the box last confirmed it; - /// every read confirms it again. - @Test func itGoesStaleFifteenMinutesAfterTheLastConfirmation() { + /// The card is current for two minutes after the box last confirmed + /// it; every read confirms it again. The Control trusts its record for + /// fifteen. + @Test func itGoesStaleTwoMinutesAfterTheLastConfirmation() { let s = ChargeNowActivity.State(startedAtMs: Self.t, confirmedAtMs: Self.t + 600_000) + #expect(ChargeNowActivity.staleMs == 2 * 60_000) #expect(ChargeNowActivity.vouchMs == 15 * 60_000) - #expect(s.staleDate.timeIntervalSince1970 * 1000 == Self.t + 600_000 + 900_000) - #expect(!s.isStale(atMs: Self.t + 600_000 + 899_999)) - #expect(s.isStale(atMs: Self.t + 600_000 + 900_000)) + #expect(s.staleDate.timeIntervalSince1970 * 1000 == Self.t + 600_000 + 120_000) + #expect(!s.isStale(atMs: Self.t + 600_000 + 119_999)) + #expect(s.isStale(atMs: Self.t + 600_000 + 120_000)) #expect(s.startedAt.timeIntervalSince1970 * 1000 == Self.t) } + /// MEASURED lights from the first reading that shows the car drawing + /// after the box has the command, once, and stays through every later + /// step; a new Charge now clears it. A reading alone never starts a + /// card. + @Test func measuredLightsOnceAndStays() { + let t = Self.t + let received = ChargeNowActivity.State(phase: .received, startedAtMs: t, confirmedAtMs: t + 300) + let measured = ChargeNowActivity.State(phase: .received, startedAtMs: t, confirmedAtMs: t + 300, measuredAtMs: t + 900) + #expect(ChargeNowActivity.next(nil, .measured(atMs: t)) == .none) + // Not before the box has the command. + #expect(ChargeNowActivity.next(.init(phase: .sent, startedAtMs: t, confirmedAtMs: t), .measured(atMs: t + 100)) == .none) + #expect(ChargeNowActivity.next(received, .measured(atMs: t + 900)) == .update(measured)) + #expect(ChargeNowActivity.next(measured, .measured(atMs: t + 2_000)) == .none) + #expect(ChargeNowActivity.next(measured, .started(atMs: t + 1_200)) == .update(.init(phase: .on, startedAtMs: t + 1_200, confirmedAtMs: t + 1_200, measuredAtMs: t + 900))) + let on = ChargeNowActivity.State(startedAtMs: t, confirmedAtMs: t, measuredAtMs: t + 900) + #expect(ChargeNowActivity.next(on, .seen(holding: true, atMs: t + 60_000)) == .update(.init(startedAtMs: t, confirmedAtMs: t + 60_000, measuredAtMs: t + 900))) + let again = ChargeNowActivity.State(phase: .sent, startedAtMs: t, confirmedAtMs: t, measuredAtMs: t + 900) + #expect(ChargeNowActivity.next(again, .sent(atMs: t + 5_000)) == .update(.init(phase: .sent, startedAtMs: t + 5_000, confirmedAtMs: t + 5_000))) + // A card from before MEASURED reads back with nothing measured. + let old = try? JSONDecoder().decode(ChargeNowActivity.State.self, from: Data(#"{"phase":"on","startedAtMs":1,"confirmedAtMs":2}"#.utf8)) + #expect(old?.measuredAtMs == nil) + } + /// It names the state, not power, on the household's clock. @Test func theWordsNameTheStateNotThePower() { let s = ChargeNowActivity.State(startedAtMs: Self.t, confirmedAtMs: Self.t + 1_800_000) @@ -136,13 +161,18 @@ import Testing #expect(a.headline == "Sending Charge now…") #expect(a.subtitle == "Waiting for FTW") #expect(a.timer == .waiting) - #expect(a.steps == 1) + #expect(a.receipt == ReceiptLog(sent: true)) let b = ChargeNowActivity.face(.init(phase: .received, startedAtMs: t, confirmedAtMs: t), stale: false, zone: Self.stockholm) #expect(b.headline == "FTW received Charge now") #expect(b.subtitle == "Waiting for the charger") - #expect(b.steps == 2) + #expect(b.receipt == ReceiptLog(sent: true, accepted: true)) let c = ChargeNowActivity.face(.init(startedAtMs: t, confirmedAtMs: t), stale: false, zone: Self.stockholm) - #expect(c.steps == 3) + #expect(c.receipt == ReceiptLog(sent: true, accepted: true, confirmed: true)) + #expect(c.receipt.line == "SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED") + #expect(c.receipt.marks.map(\.state) == [.lit, .lit, .waiting, .lit]) + let d = ChargeNowActivity.face(.init(startedAtMs: t, confirmedAtMs: t, measuredAtMs: t), stale: true, zone: Self.stockholm) + #expect(d.receipt.marks.allSatisfy { $0.state == .lit }) + #expect(d.dimmed) #expect(sent.staleDate.timeIntervalSince1970 * 1000 == t + ChargeNowActivity.answerMs) let lost = ChargeNowActivity.face(sent, stale: true, zone: Self.stockholm) #expect(lost.headline == "No answer from FTW · open FTW") diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift new file mode 100644 index 0000000..bb9f9a3 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The receipt log under a car command: four reserved slots, each lit only +/// on its own evidence, and a refusal in the last. +@Suite struct ReceiptLogTests { + /// Each slot lights on its own evidence, in whatever order it comes: + /// the box often confirms before the car draws. + @Test func eachSlotLightsOnItsOwnEvidence() { + let sent = ReceiptLog() + #expect(sent.marks.map(\.state) == [.lit, .waiting, .waiting, .waiting]) + let confirmedFirst = ReceiptLog(accepted: true, confirmed: true) + #expect(confirmedFirst.marks.map(\.state) == [.lit, .lit, .waiting, .lit]) + let all = ReceiptLog(accepted: true, measured: true, confirmed: true) + #expect(all.marks.map(\.state) == [.lit, .lit, .lit, .lit]) + #expect(all.line == "SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED") + } + + /// A refusal takes the last slot with the box's reason; a confirmation + /// never shows beside it. + @Test func aRefusalTakesTheLastSlot() { + let refused = ReceiptLog(accepted: true, confirmed: true, refused: "Plug the car in first.") + #expect(refused.marks.last?.state == .refused) + #expect(refused.marks.last?.text.trimmingCharacters(in: .whitespaces) == "REFUSED") + #expect(!refused.lit(.confirmed)) + #expect(refused.line == "SENT ▸ ACCEPTED ▸ MEASURED ▸ REFUSED") + } + + /// The last slot is as wide whether it says CONFIRMED or REFUSED, so the + /// line never changes width. + @Test func theLastSlotIsReserved() { + let a = ReceiptLog(confirmed: true).marks.last!.text + let b = ReceiptLog(refused: "no").marks.last!.text + #expect(a.count == b.count) + #expect(ReceiptLog().marks.map(\.text).joined().count == ReceiptLog(refused: "no").marks.map(\.text).joined().count) + } + + /// Lane 0 is evidence only while live and above the beam threshold. + @Test func measuredNeedsALiveReadingAboveTheThreshold() { + #expect(ReceiptLog.drawingW == 42) + #expect(ReceiptLog.measures(evW: 43, live: true)) + #expect(!ReceiptLog.measures(evW: 42, live: true)) + #expect(!ReceiptLog.measures(evW: 7_000, live: false)) + #expect(!ReceiptLog.measures(evW: nil, live: true)) + } + + @Test func everyReceiptWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Localizable", withExtension: "strings", subdirectory: nil, localization: "sv")) + let table = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in ReceiptWord.allCases { + let sv = try #require(table[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(sv != word.rawValue) + #expect(sv == sv.uppercased()) + } + // The watch's receipt says the same words. + #expect(table["SENT"] == "SKICKAT") + #expect(table["ACCEPTED"] == "MOTTAGET") + #expect(table["CONFIRMED"] == "BEKRÄFTAT") + } +} diff --git a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift index 5cb24bd..5b254ba 100644 --- a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift +++ b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift @@ -4,20 +4,23 @@ import SwiftUI import WidgetKit /// Charge now outside the app, in the manner of the best travel apps: one -/// line that says what is happening now, a large time in even digits, and a -/// band of three steps (sent, received, on). On the Lock Screen and in the -/// Dynamic Island, and through `.small` on a paired Apple Watch's Smart -/// Stack and in CarPlay. +/// line that says what is happening now, a large time in even digits, and +/// the receipt log `SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED`, each slot lit +/// on its own evidence. On the Lock Screen and in the Dynamic Island, and +/// through `.small` on a paired Apple Watch's Smart Stack and in CarPlay. /// /// It names the state the box confirmed, not power: the hold can be on -/// while the car draws nothing. Past the stale date it says when the box -/// last confirmed it, the time stops there, and everything dims. Every -/// state has the same layout, so a change fades in place: the island keeps -/// its size and nothing jumps. +/// while the car draws nothing, and MEASURED says when it did. Two minutes +/// after the last confirmation it says when that was, the time stops +/// there, and everything dims. Every state has the same layout, so a +/// change fades in place: the island keeps its size and nothing jumps. It +/// sits on FTW's ground, never glows: a card is not a live reading. struct ChargeNowLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: ChargeNowAttributes.self) { context in ChargeNowBanner(attributes: context.attributes, face: Self.face(context)) + .activityBackgroundTint(Theme.ground) + .activitySystemActionForegroundColor(Theme.ink) } dynamicIsland: { context in let face = Self.face(context) return DynamicIsland { @@ -30,13 +33,13 @@ struct ChargeNowLiveActivity: Widget { } DynamicIslandExpandedRegion(.trailing) { Elapsed(face: face) - .font(.system(.title, design: .rounded, weight: .semibold)) + .font(Theme.figure(28)) .padding(.trailing, 6) } DynamicIslandExpandedRegion(.bottom) { VStack(alignment: .leading, spacing: 6) { Headline(face: face) - StepBand(face: face) + ReceiptRow(receipt: face.receipt, dimmed: face.dimmed) .padding(.vertical, 2) BackToPlan(attributes: context.attributes, face: face) .padding(.top, 4) @@ -53,7 +56,7 @@ struct ChargeNowLiveActivity: Widget { Bolt(face: face) } // The island is always dark. - .keylineTint(face.dimmed ? Color.secondary : Theme.fixed(Theme.car, .dark)) + .keylineTint(Theme.fixed(face.dimmed ? Theme.ink3 : Theme.car, .dark)) } .supplementalActivityFamilies([.small]) } @@ -93,11 +96,11 @@ struct ChargeNowBanner: View { // A stopped time is smaller, so the stale words fit beside // it on a narrow phone. Elapsed(face: face) - .font(.system(face.dimmed ? .title2 : .largeTitle, design: .rounded, weight: .semibold)) + .font(Theme.figure(face.dimmed ? 22 : 34)) .minimumScaleFactor(0.6) .frame(maxWidth: 120, alignment: .trailing) } - StepBand(face: face) + ReceiptRow(receipt: face.receipt, dimmed: face.dimmed) if !dim { BackToPlan(attributes: attributes, face: face) } @@ -119,17 +122,17 @@ struct ChargeNowSmall: View { HStack(spacing: 6) { Bolt(face: face) Elapsed(face: face) - .font(.system(.headline, design: .rounded)) + .font(Theme.figure(17)) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .trailing) } Text(face.headline) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.ink2) .lineLimit(2) .minimumScaleFactor(0.85) .contentTransition(.interpolate) - StepBand(face: face) + ReceiptRow(receipt: face.receipt, dimmed: face.dimmed, size: 9) if !dim { BackToPlan(attributes: attributes, face: face) .font(.caption) @@ -147,7 +150,7 @@ private struct Headline: View { var body: some View { Text(face.headline) .font(.headline) - .foregroundStyle(face.dimmed ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary)) + .foregroundStyle(face.dimmed ? Theme.ink2 : Theme.ink) .lineLimit(face.dimmed ? 2 : 1) .minimumScaleFactor(0.8) .contentTransition(.interpolate) @@ -161,7 +164,7 @@ private struct Subtitle: View { var body: some View { Text(face.subtitle) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.ink2) .lineLimit(1) .minimumScaleFactor(0.8) .contentTransition(.interpolate) @@ -178,7 +181,7 @@ private struct Elapsed: View { timer .monospacedDigit() .multilineTextAlignment(.trailing) - .foregroundStyle(face.dimmed || face.timer == .waiting ? AnyShapeStyle(.secondary) : AnyShapeStyle(Theme.car)) + .foregroundStyle(face.dimmed || face.timer == .waiting ? Theme.ink3 : Theme.car) .lineLimit(1) .contentTransition(.numericText(countsDown: false)) // Plain text has its own width; the stale headline beside it @@ -206,28 +209,6 @@ private struct Elapsed: View { } } -/// The three steps of Charge now, filled as the box answers: sent, -/// received, on. Dimmed with the rest once stale. -private struct StepBand: View { - let face: ChargeNowActivity.Face - - var body: some View { - HStack(spacing: 4) { - ForEach(0..<3, id: \.self) { step in - Capsule() - .fill(fill(step)) - .frame(height: 5) - } - } - .accessibilityHidden(true) - } - - private func fill(_ step: Int) -> AnyShapeStyle { - guard step < face.steps else { return AnyShapeStyle(.quaternary) } - return face.dimmed ? AnyShapeStyle(.secondary) : AnyShapeStyle(Theme.car) - } -} - /// The car with a bolt: filled while Charge now is on or on its way, an /// outline once stale. The change is the symbol's own. private struct Bolt: View { @@ -236,7 +217,7 @@ private struct Bolt: View { var body: some View { Image(systemName: face.dimmed ? "bolt.car" : "bolt.car.fill") - .foregroundStyle(dim || face.dimmed ? AnyShapeStyle(.secondary) : AnyShapeStyle(Theme.car)) + .foregroundStyle(dim || face.dimmed ? Theme.ink3 : Theme.car) .contentTransition(.symbolEffect(.replace)) } } @@ -254,7 +235,7 @@ private struct BackToPlan: View { .frame(maxWidth: .infinity) } .buttonStyle(.bordered) - .tint(Tone.accent) - .opacity(face.steps < 3 ? 0.5 : 1) + .tint(Theme.ink) + .opacity(face.receipt.confirmed ? 1 : 0.5) } } From 5c2a1c686c2eaf860e18b96a6c7da4853687e8d8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 18:21:52 +0200 Subject: [PATCH 2/5] iPhone car: MEASURED for the car view's receipt, on lane 0's evidence Each charge command's step now carries MEASURED: once the box has the command, lane 0 shows the car doing what was asked, live. That is above 42 W for Charge now, at or below for Pause, and for Back to the plan whatever the plan says for this moment. Lane 0 has one car figure for every charger, so it lights only in a home with one charger. EVText.receipt turns a step into the receipt log: ACCEPTED on cmd.ack, CONFIRMED on cmd.result, and the box's reason in the last slot when it refused, or when something else changed it first. A level's receipt has no MEASURED: lane 0 says nothing about a level. LoadpointsModel serves both receipts. A confirmed charge command waits up to a minute for MEASURED, then stays 20 s and fades; before, it faded 20 s after the result, often before the car had started. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Sources/FTWKit/Format/EV.swift | 33 ++++++++- .../FTWKit/State/LoadpointsModel.swift | 74 +++++++++++++++---- .../FTWKit/Sources/FTWShared/ReceiptLog.swift | 8 +- .../FTWKit/Tests/FTWKitTests/CarTests.swift | 72 ++++++++++++++++++ .../FTWSharedTests/ReceiptLogTests.swift | 8 ++ 5 files changed, 179 insertions(+), 16 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 5afc1f3..882b59a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -775,6 +775,16 @@ public enum EVText { } } + /// "from the car · 2 min ago": where the level comes from and how old it + /// is, under the car's level. Minutes, never seconds; a level with no + /// time of its own says only where it comes from. + public static func originAged(_ l: Level, nowMs: Double, zone: TimeZone) -> String { + let from = originShort(l, zone: zone) + guard let at = l.atMs, l.origin == .car || l.origin == .you else { return from } + let age = max(0, nowMs - at) + return from + " · " + PowerFormat.age(age < 60_000 ? 0 : (age / 60_000).rounded(.down) * 60_000) + } + /// The chips, and a step of five from wherever the level stands. public static let levelPresets: [Double] = [20, 40, 60, 80] @@ -927,12 +937,17 @@ public enum EVText { public var ask: Ask /// The box said it has the intent (`cmd.ack`). public var acked: Bool + /// After that, lane 0 showed the car doing what was asked: drawing + /// for Charge now, still for Pause, as the plan says for Back to the + /// plan. Never for a level. + public var measured: Bool /// What the box said happened (`cmd.result`), or why not. public var result: Result? - public init(ask: Ask, acked: Bool = false, result: Result? = nil) { + public init(ask: Ask, acked: Bool = false, measured: Bool = false, result: Result? = nil) { self.ask = ask self.acked = acked + self.measured = measured self.result = result } @@ -980,6 +995,22 @@ public enum EVText { return Outcome(sent: .done, received: received, done: done, text: outcomeText(step, lp), tone: tone) } + /// The receipt log for one command: SENT at once, ACCEPTED on the box's + /// `cmd.ack`, MEASURED once lane 0 shows the car doing what was asked, + /// CONFIRMED on `cmd.result`. A refusal, or a change that came first, + /// takes the last slot with the reason. A level has no MEASURED. + public static func receipt(_ step: Step, _ lp: Loadpoint?) -> ReceiptLog { + var r = ReceiptLog(accepted: step.acked, measured: step.measured) + if case .level = step.ask { r.slots = [.sent, .accepted, .confirmed] } + switch step.result { + case .applied?: r.confirmed = true + case .refused(let why)?: r.refused = why + case .superseded?: r.refused = outcomeText(step, lp) + case .unconfirmed?, nil: break + } + return r + } + static func outcomeText(_ step: Step, _ lp: Loadpoint?) -> String { if case .refused(let why)? = step.result { return why } switch step.ask { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 40c8c9a..e7f7054 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -469,15 +469,41 @@ public final class LoadpointsModel: Activatable { lp.manualActive && !EVText.isPaused(lp) } - /// MEASURED on the receipt: lane 0 shows the car drawing power, live. - /// Lane 0 has one figure for every charger, so it lights the Live - /// Activity only when the home has one up. + /// MEASURED on the receipt: lane 0 shows the car doing what was asked, + /// live, once the box has the command. Lane 0 has one car figure for + /// every charger, so it lights the Live Activity only when the home has + /// one up, and the car view's receipt only for a home with one charger. private func noticeDrawing() { - guard ReceiptLog.measures(evW: site.session.fields[Contract.FID.evW], live: site.isLive), - let host = activityHost else { return } - let ids = host.loadpointIDs(home: activityHome) - guard ids.count == 1 else { return } - showChargeNow(ids[0], .measured(atMs: site.scheduler.nowMs)) + let evW = site.session.fields[Contract.FID.evW] + let live = site.isLive + if ReceiptLog.measures(evW: evW, live: live), let host = activityHost { + let ids = host.loadpointIDs(home: activityHome) + if ids.count == 1 { showChargeNow(ids[0], .measured(atMs: site.scheduler.nowMs)) } + } + guard live, let evW, points.count == 1, let lp = points.first, + let step = holdStep[lp.id], step.acked, !step.measured else { return } + switch step.result { + case .refused?, .superseded?: return + case .applied?, .unconfirmed?, nil: break + } + let planned = (windows[lp.id] ?? lp.planWindows).contains { $0.fromMs <= site.nowMs && site.nowMs < $0.toMs } + guard Self.measures(step.ask, evW: evW, planCharges: planned) else { return } + holdStep[lp.id]?.measured = true + // Its last slot has lit: a success fades a while after this. + if case .applied? = step.result { fadeLater("hold \(lp.id)", \.holdStep, lp.id, afterMs: Self.doneShownMs) } + } + + /// Whether lane 0's car power is what the command asked for: drawing + /// for Charge now, still for Pause, and for Back to the plan whatever + /// the plan says for this moment. + nonisolated static func measures(_ ask: EVText.Step.Ask, evW: Double, planCharges: Bool) -> Bool { + let drawing = evW > ReceiptLog.drawingW + switch ask { + case .chargeNow: return drawing + case .pause: return !drawing + case .resume: return drawing == planCharges + case .level: return false + } } /// The Live Activity's home key: the site, or the demo's own. @@ -543,16 +569,28 @@ public final class LoadpointsModel: Activatable { guard self[keyPath: path][id]?.ask == ask else { return } self[keyPath: path][id]?.result = result // A success fades once the screen shows its effect; a refusal stays - // until the next tap. + // until the next tap. A charge command waits up to a minute for + // MEASURED, which comes when the car has started or stopped. if case .applied = result { - clearing[key] = site.scheduler.after(Self.doneShownMs) { [weak self] in - guard let self, self[keyPath: path][id]?.result == result else { return } - self[keyPath: path][id] = nil - } + let waits = tag == "hold" && self[keyPath: path][id]?.measured == false + fadeLater(key, path, id, afterMs: waits ? Self.measureWaitMs : Self.doneShownMs) + } + } + + /// A settled success leaves the screen after `afterMs`, unless another + /// command took its place. + private func fadeLater(_ key: String, _ path: ReferenceWritableKeyPath, _ id: String, afterMs: Double) { + guard let step = self[keyPath: path][id], case .applied? = step.result else { return } + clearing[key]?.cancel() + clearing[key] = site.scheduler.after(afterMs) { [weak self] in + guard let self, let now = self[keyPath: path][id], now.ask == step.ask, case .applied? = now.result else { return } + self[keyPath: path][id] = nil } } static let doneShownMs: Double = 20_000 + /// How long a confirmed charge command waits for lane 0 to show it. + static let measureWaitMs: Double = 60_000 // MARK: The goal @@ -633,6 +671,16 @@ public final class LoadpointsModel: Activatable { holdStep[lp.id].map { EVText.outcome($0, lp) } } + /// The receipt log for the last level and the last charge command: + /// `SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED`, each lit on its evidence. + public func receipt(level lp: Loadpoint) -> ReceiptLog? { + levelStep[lp.id].map { EVText.receipt($0, lp) } + } + + public func receipt(hold lp: Loadpoint) -> ReceiptLog? { + holdStep[lp.id].map { EVText.receipt($0, lp) } + } + public func holdSending(_ lp: Loadpoint) -> Bool { holdStep[lp.id].map { !$0.settled } ?? false } diff --git a/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift b/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift index ebf993d..ccc5a2a 100644 --- a/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift +++ b/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift @@ -25,13 +25,17 @@ public struct ReceiptLog: Equatable, Hashable, Codable, Sendable { public var confirmed: Bool /// The box's reason, ready to show. Set, it takes the last slot. public var refused: String? + /// The slots this command has. A level is not measured on lane 0, so + /// its receipt has no MEASURED. + public var slots: [Slot] - public init(sent: Bool = true, accepted: Bool = false, measured: Bool = false, confirmed: Bool = false, refused: String? = nil) { + public init(sent: Bool = true, accepted: Bool = false, measured: Bool = false, confirmed: Bool = false, refused: String? = nil, slots: [Slot] = Slot.allCases) { self.sent = sent self.accepted = accepted self.measured = measured self.confirmed = confirmed self.refused = refused + self.slots = slots } /// Above this the car draws power: the threshold Now uses for a beam. @@ -57,7 +61,7 @@ public struct ReceiptLog: Equatable, Hashable, Codable, Sendable { /// padded to the width its slot reserves, so a slot that lights or /// turns to REFUSED never moves the others. public var marks: [Mark] { - Slot.allCases.map { slot in + slots.map { slot in if slot == .confirmed, refused != nil { return Mark(slot: slot, text: Self.reserved(.confirmed, ReceiptWord.refused.text), state: .refused) } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift index 83673f2..da39bbe 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -466,6 +466,78 @@ import FTWDemo } } + /// Charge now walks the receipt log in the order its evidence comes: + /// SENT at the tap, ACCEPTED on the box's ack, CONFIRMED on its result, + /// and MEASURED only once lane 0 shows the car drawing, here four + /// seconds later as the charger ramps. It fades 20 s after its last + /// slot lit. + @Test func chargeNowWalksTheReceiptOnItsEvidence() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.setCarPluggedIn(true) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + #expect(car.pluggedIn) + #expect(box.house.evWatts == 0) + @MainActor func marks() -> [ReceiptLog.Mark.State]? { home.loadpoints.receipt(hold: car)?.marks.map(\.state) } + + let charge = Task { await home.loadpoints.chargeNow(car) } + for _ in 0..<10 { await Task.yield() } + #expect(marks() == [.lit, .waiting, .waiting, .waiting]) + await rig.run(400) + #expect(marks() == [.lit, .lit, .waiting, .waiting]) + await rig.run(1_500) + await charge.value + #expect(marks() == [.lit, .lit, .waiting, .lit]) + await rig.run(5_000, step: 100) + #expect(marks() == [.lit, .lit, .lit, .lit]) + #expect(home.loadpoints.receipt(hold: car)?.line == "SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED") + await rig.run(LoadpointsModel.doneShownMs + 1_500, step: 500) + #expect(home.loadpoints.holdStep[car.id] == nil) + } + } + + /// The box's refusal takes the last slot with its reason, and a level's + /// receipt has no MEASURED. + @Test func aRefusedLevelSaysWhyInTheLastSlot() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.setCarPluggedIn(false) + home.loadpoints.activate() + await rig.run(1_500) + let out = try #require(home.loadpoints.points.first) + let refused = Task { await home.loadpoints.setLevel(out, pct: 60) } + await rig.run(2_000) + await refused.value + let receipt = try #require(home.loadpoints.receipt(level: out)) + #expect(receipt.line == "SENT ▸ ACCEPTED ▸ REFUSED") + #expect(receipt.refused == "Plug the car in first. FTW takes a level only while the car is plugged in.") + #expect(receipt.marks.last?.state == .refused) + } + } + + /// MEASURED means lane 0 shows what was asked: drawing for Charge now, + /// still for Pause, the plan's own for Back to the plan; never a level. + @Test func measuredMeansWhatWasAsked() { + #expect(LoadpointsModel.measures(.chargeNow, evW: 7_400, planCharges: false)) + #expect(!LoadpointsModel.measures(.chargeNow, evW: 42, planCharges: false)) + #expect(LoadpointsModel.measures(.pause, evW: 0, planCharges: true)) + #expect(!LoadpointsModel.measures(.pause, evW: 7_400, planCharges: false)) + #expect(LoadpointsModel.measures(.resume, evW: 0, planCharges: false)) + #expect(LoadpointsModel.measures(.resume, evW: 11_000, planCharges: true)) + #expect(!LoadpointsModel.measures(.resume, evW: 0, planCharges: true)) + #expect(!LoadpointsModel.measures(.level(60), evW: 7_400, planCharges: true)) + let step = EVText.Step(ask: .chargeNow, acked: true, result: .superseded(observed: nil)) + #expect(EVText.receipt(step, nil).refused == "Something else changed charging first. Try again.") + } + /// The demo answers "Is FTW in control?" from the stream alone. @Test func theDemoSaysFTWIsInControl() async throws { let rig = Rig() diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift index bb9f9a3..5a3a3ce 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift @@ -36,6 +36,14 @@ import Testing #expect(ReceiptLog().marks.map(\.text).joined().count == ReceiptLog(refused: "no").marks.map(\.text).joined().count) } + /// A level has no MEASURED: lane 0 says nothing about a car's level. + @Test func aLevelHasThreeSlots() { + let level = ReceiptLog(accepted: true, confirmed: true, slots: [.sent, .accepted, .confirmed]) + #expect(level.line == "SENT ▸ ACCEPTED ▸ CONFIRMED") + #expect(level.marks.map(\.state) == [.lit, .lit, .lit]) + #expect(ReceiptLog(refused: "Plug the car in first.", slots: [.sent, .accepted, .confirmed]).line == "SENT ▸ ACCEPTED ▸ REFUSED") + } + /// Lane 0 is evidence only while live and above the beam threshold. @Test func measuredNeedsALiveReadingAboveTheThreshold() { #expect(ReceiptLog.drawingW == 42) From 603af0f260b23bca389957d136a351b3f2e3d096 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 18:21:56 +0200 Subject: [PATCH 3/5] iPhone car: the node view's header, the car's plan, the glass bar and the receipt log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The car view opens on the car node's hero: the power now at 60 pt with its word in violet, and the car's level at 34 pt over the 10-cell meter, with where the level comes from and how old it is, in minutes. The level is never worked out from power. The meter takes the car's colour without glow, because a level is a fact and not a live reading; the car's node view shows it too. Under the hero sit the status line and what the charger does in words. The car's plan for the hours ahead is the car node's Ahead chart: ghost bars for its charging, the price in eight levels under the same slots, a finger that reads each slot, and the plan in one sentence. A finger on the plan leaves the hero on now. The Ahead chart's right gutter is a little wider, so "15 kW" no longer clips. Plan · Charge now · Pause sit in one GlassEffectContainer at the foot, the chosen one prominent in ink. Under them, in room reserved for it, the receipt log on one SF Mono line, SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED, and the sentence: what the box did, or in alarm red why not. The outcome plays one haptic. No cards: the level, the goal and the demo's cable sit on the ground under mono kickers, with glass chips and buttons. The goal sheet has the ground with raised rows. The old Sent/Received/Done receipt moves to its own file for the mode sheet and the control sheet. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Car/CarView.swift | 488 +++++++++++++++--------------- appleApp/FTW/Car/GoalEditor.swift | 12 +- appleApp/FTW/UI/CellMeter.swift | 6 +- appleApp/FTW/UI/NodeHistory.swift | 2 +- appleApp/FTW/UI/NodeView.swift | 17 +- appleApp/FTW/UI/ReceiptView.swift | 97 ++++++ 6 files changed, 371 insertions(+), 251 deletions(-) create mode 100644 appleApp/FTW/UI/ReceiptView.swift diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index 1a5b53c..166b83a 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -2,16 +2,23 @@ import FTWDemo import FTWKit import SwiftUI -/// The car: how full it is and who says so, the goal and whether it makes -/// it, and Plan, Charge now and Pause in a bar at the foot, where the thumb -/// is and in the same places whatever the car does. Every tap shows "Sent" -/// at once, then the box's receipt, then the outcome; the screen shows -/// intent at once and a result only once the box has confirmed it. +/// The car, in the node view's material: the power now as the hero, with +/// the car's level, its meter and where the level comes from beside it; +/// whether that is live and from which charger; the car's plan for the +/// hours ahead with the price under it; then the level and the goal. Plan, +/// Charge now and Pause sit in a glass bar at the foot, where the thumb is +/// and in the same places whatever the car does, with the receipt log +/// under them: SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED, each slot lit on +/// its own evidence. The screen shows intent at once and a result only +/// once the box has confirmed it. struct CarView: View { let app: AppModel let home: HomeModels let id: String? @State private var editingGoal = false + @State private var scrub = ScrubModel() + @State private var trace = NodeTraceModel() + @State private var text = PeriodText() private var model: LoadpointsModel { home.loadpoints } @@ -20,26 +27,52 @@ struct CarView: View { } var body: some View { - List { - if let lp { - StatusSection(lp: lp, model: model, site: home.site) - // Drawn from what the last launch kept; inert until the box - // has spoken in this session. - LevelSection(lp: lp, model: model, canControl: home.site.canConfigure) - .disabled(!home.site.heardFromBox) - GoalSection(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal) - .disabled(!home.site.heardFromBox) - if app.isDemo { DemoSection(lp: lp, model: model) } - } else if model.loaded { - Section { Text(L("This box has no charger.")) } - .listRowBackground(Theme.surfaceRaised) - } else { - PlaceholderSections() + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let lp { + // The hours ahead are a plan, not a reading: a finger on + // the plan leaves the hero on now. + NodeHero(site: home.site, node: .car, home: home, trace: trace, scrub: scrub, timebase: .ahead, text: text) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .padding(.horizontal, 20) + NodeStatusRow(site: home.site, node: .car) + .padding(.horizontal, 20) + .padding(.top, 16) + StatusLines(lp: lp, model: model, site: home.site) + .padding(.horizontal, 20) + .padding(.top, 6) + CarPlan(lp: lp, model: model, home: home, scrub: scrub, text: text) + .padding(.top, 24) + // Drawn from what the last launch kept; inert until the + // box has spoken in this session. + LevelBlock(lp: lp, model: model, canControl: home.site.canConfigure) + .disabled(!home.site.heardFromBox) + .padding(.horizontal, 20) + .padding(.top, 28) + GoalBlock(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal) + .disabled(!home.site.heardFromBox) + .padding(.horizontal, 20) + .padding(.top, 28) + if app.isDemo { + DemoBlock(lp: lp, model: model) + .padding(.horizontal, 20) + .padding(.top, 28) + } + } else if model.loaded { + Text(L("This box has no charger.")) + .foregroundStyle(Theme.ink2) + .padding(20) + } else { + Placeholder() + .padding(.horizontal, 20) + } } + .padding(.top, 8) + .padding(.bottom, 24) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) } - .listStyle(.insetGrouped) - .scrollContentBackground(.hidden) - .background(Theme.surface) + .background(Theme.ground) .safeAreaBar(edge: .bottom) { if let lp, lp.pluggedIn, home.site.canConfigure { ControlBar(lp: lp, model: model) @@ -54,11 +87,13 @@ struct CarView: View { .onAppear { model.activate() model.watchPlan() + home.prices.activate() LaunchClock.mark("first content") } .onDisappear { model.unwatchPlan() model.deactivate() + home.prices.deactivate() } .sheet(isPresented: $editingGoal) { if let lp { GoalEditor(model: model, lp: lp) } @@ -68,30 +103,34 @@ struct CarView: View { // MARK: What it does now -private struct StatusSection: View { +/// Under the status line: what the charger does in words, this plug-in's +/// energy, and when the box last said so once that is old. +private struct StatusLines: View { let lp: Loadpoint let model: LoadpointsModel let site: SiteModel var body: some View { - Section { - VStack(alignment: .leading, spacing: 4) { - Text(EVText.status(lp, zone: model.zone)) - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.fg) + VStack(alignment: .leading, spacing: 2) { + Text(EVText.status(lp, zone: model.zone)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.ink) + .contentTransition(.opacity) + .fixedSize(horizontal: false, vertical: true) + if let session = EVText.session(lp) { + Text(session) + .font(.subheadline) + .foregroundStyle(Theme.ink2) .contentTransition(.numericText()) + } + if let note { + Text(note.text) + .font(.footnote) + .foregroundStyle(note.warn ? Theme.alarm : Theme.ink2) .fixedSize(horizontal: false, vertical: true) - if let session = EVText.session(lp) { - Hint(session).contentTransition(.numericText()) - } - if let note { - Hint(note.text, tone: note.warn ? Theme.alarm : Theme.fgMuted) - } } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) } - .listRowBackground(Theme.surfaceRaised) + .accessibilityElement(children: .combine) } /// When the box said it: plainly when out of date, quietly when it is @@ -99,14 +138,57 @@ private struct StatusSection: View { private var note: (text: String, warn: Bool)? { guard let at = model.readAtMs else { return model.stale ? (L("Waiting for your box."), true) : nil } if model.stale { return (model.error ?? AppWords.format("Last known %@.", AppWords.clock(at, zone: model.zone)), true) } - if site.nowMs - at > 60_000 { return (AppWords.format("Read at %@", AppWords.clock(at, zone: model.zone)), false) } + if site.minuteMs - at > 60_000 { return (AppWords.format("Read at %@", AppWords.clock(at, zone: model.zone)), false) } return nil } } +// MARK: The plan + +/// The car's plan for the hours ahead, as the car node's Ahead draws it: +/// its charging as ghost bars, the price under the same slots in eight +/// levels, and a finger that reads each slot. Under it, the plan in one +/// sentence, or the slot under the finger. +private struct CarPlan: View { + let lp: Loadpoint + let model: LoadpointsModel + let home: HomeModels + let scrub: ScrubModel + let text: PeriodText + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Kicker(L("Plan")) + .padding(.horizontal, 20) + AheadChartArea(site: home.site, node: .car, home: home, scrub: scrub, text: text) + .frame(height: 170) + .padding(.horizontal, 4) + ZStack(alignment: .topLeading) { + Text(verbatim: "\n") + Text(line).accessibilityIdentifier("car.plan.line") + } + .font(.subheadline) + .foregroundStyle(Theme.ink2) + .lineLimit(typeSize.isAccessibilitySize ? 5 : 2, reservesSpace: true) + .frame(maxWidth: .infinity, alignment: .leading) + .transaction { $0.animation = nil } + .padding(.horizontal, 20) + } + } + + private var line: String { + if let under = text.text(scrub) { return under } + return PlanText.car(model.windows[lp.id] ?? lp.planWindows, nowMs: home.site.minuteMs, zone: model.zone) + } +} + // MARK: How full is it? -private struct LevelSection: View { +/// How full the car is, told by tap. The level itself is the hero's; here +/// is where it comes from, the chips that tell FTW, and the level's +/// receipt. +private struct LevelBlock: View { let lp: Loadpoint let model: LoadpointsModel let canControl: Bool @@ -115,26 +197,20 @@ private struct LevelSection: View { var body: some View { let level = model.level(lp) let assumed = level?.origin == .assumed || level?.origin == .unconfirmed - Section { + VStack(alignment: .leading, spacing: 10) { + Kicker(L("How full is the car?")) if let level { - VStack(alignment: .leading, spacing: 6) { - ViewThatFits(in: .horizontal) { - HStack(alignment: .firstTextBaseline, spacing: 10) { figure(level, assumed: assumed) } - VStack(alignment: .leading, spacing: 6) { figure(level, assumed: assumed) } - } - // The badge says FTW assumes; the line says what to do. - Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) - .font(.subheadline) - .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) + // The hero says FTW assumes; the line says what to do. + Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) + .font(.subheadline) + .foregroundStyle(assumed ? Theme.ink : Theme.ink2) + .fixedSize(horizontal: false, vertical: true) if canControl { if level.origin == .car, !correcting { Button(L("Correct the level")) { withAnimation(Theme.spring) { correcting = true } } + .buttonStyle(.glass) } else { LevelChips(current: model.chosenLevel(lp) ?? level.pct) { pct in Task { await model.setLevel(lp, pct: pct) } @@ -144,42 +220,25 @@ private struct LevelSection: View { } else if !lp.pluggedIn { Text(L("Plug in the car to tell FTW its level.")) .font(.subheadline) - .foregroundStyle(Theme.fg) + .foregroundStyle(Theme.ink) } else { - Text(L("Level unknown")).font(.headline) + Text(L("Level unknown")).font(.headline).foregroundStyle(Theme.ink) } - if let outcome = model.outcome(level: lp) { - ReceiptView(outcome: outcome) + if let receipt = model.receipt(level: lp), let outcome = model.outcome(level: lp) { + CommandReceipt(receipt: receipt, outcome: outcome) + .transition(.opacity) } - } header: { - Text(L("How full is the car?")) - } footer: { if level?.origin == .car, !correcting, canControl { Text(L("Only correct it if the car shows something else.")) + .font(.footnote) + .foregroundStyle(Theme.ink3) } } - .listRowBackground(Theme.surfaceRaised) + .animation(Theme.spring, value: model.receipt(level: lp)) .sensoryFeedback(.selection, trigger: model.chosenLevel(lp)) .sensoryFeedback(trigger: model.outcome(level: lp)?.tone) { _, tone in Self.feedback(tone) } } - @ViewBuilder private func figure(_ level: EVText.Level, assumed: Bool) -> some View { - Text(AppWords.percent(level.pct)) - .font(Theme.percent(.largeTitle, weight: .bold)) - .foregroundStyle(assumed ? Theme.fgMuted : Theme.fg) - .contentTransition(.numericText(value: level.pct)) - .animation(Theme.spring, value: level.pct) - if assumed { - Text(L("FTW assumes")) - .font(.caption.weight(.semibold)) - .foregroundStyle(Theme.onAccent) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Theme.ink2, in: Capsule()) - .fixedSize() - } - } - static func feedback(_ tone: EVText.Outcome.Tone?) -> SensoryFeedback? { switch tone { case .done?: return .success @@ -218,23 +277,22 @@ struct LevelChips: View { @ViewBuilder private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { let title = Text(text) - .font(Theme.percent(.body)) + .font(.body.weight(.semibold).monospacedDigit()) .lineLimit(1) .minimumScaleFactor(0.6) - .padding(.vertical, 6) + .padding(.vertical, 4) .frame(maxWidth: .infinity) + // The chosen one prominent, in ink; the others in plain glass. Group { if selected { - Button(action: action) { title.foregroundStyle(Theme.onAccent) } - .buttonStyle(.borderedProminent) - .tint(Theme.accentFill) + Button(action: action) { title.foregroundStyle(Theme.ground) } + .buttonStyle(.glassProminent) + .tint(Theme.ink) } else { - Button(action: action) { title.foregroundStyle(Theme.fg) } - .buttonStyle(.bordered) - .tint(Theme.fgMuted) + Button(action: action) { title.foregroundStyle(Theme.ink) } + .buttonStyle(.glass) } } - .buttonBorderShape(.roundedRectangle(radius: Theme.radiusSmall)) .accessibilityLabel(label) .accessibilityAddTraits(selected ? .isSelected : []) } @@ -242,7 +300,7 @@ struct LevelChips: View { // MARK: The goal -private struct GoalSection: View { +private struct GoalBlock: View { let lp: Loadpoint let model: LoadpointsModel let site: SiteModel @@ -250,58 +308,55 @@ private struct GoalSection: View { @Binding var editing: Bool var body: some View { - Section { + VStack(alignment: .leading, spacing: 8) { + Kicker(L("Goal")) if model.fromCache { // The cache keeps no goal; say nothing rather than "none". Text(EVText.suggestedGoalText()) .font(.headline) .redacted(reason: .placeholder) } else if let s = lp.schedule { - VStack(alignment: .leading, spacing: 4) { - Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) - .font(.headline) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { - Hint(next) - } + Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.minuteMs / 1000), zone: model.zone)) + .font(.headline) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + if let next = EVText.nextGoal(lp, nowMs: site.minuteMs, zone: model.zone) { + Hint(next, tone: Theme.ink2) } - .padding(.vertical, 2) if lp.pluggedIn, let r = model.readiness(lp) { - Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) - } - if lp.pluggedIn, let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { - Hint(plan) + Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.minuteMs, zone: model.zone)) } if canControl { Button(L("Change")) { editing = true } + .buttonStyle(.glass) } } else { Text(L("No goal yet. Without one, FTW only charges from spare solar.")) .font(.subheadline) - .foregroundStyle(Theme.fg) + .foregroundStyle(Theme.ink) .fixedSize(horizontal: false, vertical: true) if canControl { - Button { - let g = EVText.suggestedGoal - Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } - } label: { - Text(AppWords.format("Use %@", EVText.suggestedGoalText())) - .frame(maxWidth: .infinity) - .foregroundStyle(Theme.onAccent) + HStack(spacing: 8) { + Button { + let g = EVText.suggestedGoal + Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } + } label: { + Text(AppWords.format("Use %@", EVText.suggestedGoalText())) + .lineLimit(2) + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .tint(Theme.ink) + Button(L("Change")) { editing = true } + .buttonStyle(.glass) } - .buttonStyle(.borderedProminent) .controlSize(.large) - Button(L("Change")) { editing = true } } } if let save = model.goalSave[lp.id], !editing { - Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.alarm }; return Theme.fgDim }()) + Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.alarm }; return Theme.ink2 }()) } - } header: { - Text(L("Goal")) } - .listRowBackground(Theme.surfaceRaised) .sensoryFeedback(trigger: model.goalSave[lp.id]) { _, save in switch save { case .saved?, .removed?: return .success @@ -321,11 +376,13 @@ struct Readiness: View { Label { Text(text) .font(.subheadline.weight(.medium)) - .foregroundStyle(Theme.fg) + .foregroundStyle(Theme.ink) .fixedSize(horizontal: false, vertical: true) } icon: { - Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") - .foregroundStyle(short ? Theme.alarm : Theme.live) + // A plan is not a confirmation: in ink when it makes it, alarm + // when it does not. + Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle") + .foregroundStyle(short ? Theme.alarm : Theme.ink2) .contentTransition(.symbolEffect(.replace)) } .accessibilityElement(children: .combine) @@ -339,10 +396,10 @@ struct Readiness: View { // MARK: Charge now, Pause, Back to the plan -/// Plan · Charge now · Pause, always in these places. The one the box -/// reports is chosen; the one asked for turns while it travels; a tap on -/// the chosen one does nothing. Under them, the receipt has a row of its -/// own, of fixed height, so nothing moves when it arrives. +/// Plan · Charge now · Pause, always in these places, in one glass group. +/// The one the box reports is chosen; the one asked for turns while it +/// travels; a tap on the chosen one does nothing. Under them the receipt +/// log has room of its own, reserved, so nothing moves when it arrives. private struct ControlBar: View { let lp: Loadpoint let model: LoadpointsModel @@ -389,25 +446,28 @@ private struct ControlBar: View { var body: some View { let current = self.current let asked = self.asked - VStack(alignment: .leading, spacing: 8) { + let receipt = model.receipt(hold: lp) + VStack(alignment: .leading, spacing: 10) { // Side by side; at the accessibility sizes one under the other, // so no name is cut. let stacked = typeSize.isAccessibilitySize let layout = stacked ? AnyLayout(VStackLayout(spacing: 6)) : AnyLayout(HStackLayout(spacing: 8)) - layout { - ForEach(Choice.allCases) { c in - button(c, chosen: asked == nil && c == current, waiting: asked == c, stacked: stacked) + GlassEffectContainer(spacing: 8) { + layout { + ForEach(Choice.allCases) { c in + button(c, chosen: asked == nil && c == current, waiting: asked == c, stacked: stacked) + } } } ZStack(alignment: .topLeading) { - ReceiptRoom() - if let outcome = model.outcome(hold: lp) { - ReceiptView(outcome: outcome) + ReceiptSpace() + if let receipt, let outcome = model.outcome(hold: lp) { + CommandReceipt(receipt: receipt, outcome: outcome) .transition(.opacity) } else { Text(L("Charge now runs until you go back to the plan or unplug.")) .font(.footnote) - .foregroundStyle(Theme.fgMuted) + .foregroundStyle(Theme.ink2) .fixedSize(horizontal: false, vertical: true) .transition(.opacity) } @@ -417,20 +477,20 @@ private struct ControlBar: View { } .padding(.horizontal, 16) .padding(.vertical, 10) - .animation(Theme.spring, value: model.outcome(hold: lp)) + .animation(Theme.spring, value: receipt) .animation(Theme.spring, value: current) // Commands answer once, when the box says what it did. - .sensoryFeedback(trigger: model.outcome(hold: lp)?.tone) { _, tone in LevelSection.feedback(tone) } + .sensoryFeedback(trigger: model.outcome(hold: lp)?.tone) { _, tone in LevelBlock.feedback(tone) } // A bar of controls: whole words before the largest letters. .dynamicTypeSize(...DynamicTypeSize.accessibility2) } @ViewBuilder private func button(_ c: Choice, chosen: Bool, waiting: Bool, stacked: Bool) -> some View { - // The chosen one in the brand fill; the others in plain glass. + // The chosen one prominent, in ink; the others in plain glass. if chosen { plain(c, chosen: true, waiting: waiting, stacked: stacked) .buttonStyle(.glassProminent) - .tint(Theme.accentFill) + .tint(Theme.ink) } else { plain(c, chosen: false, waiting: waiting, stacked: stacked) .buttonStyle(.glass) @@ -460,7 +520,7 @@ private struct ControlBar: View { .minimumScaleFactor(0.6) } .font(.subheadline.weight(.semibold)) - .foregroundStyle(chosen ? Theme.onAccent : Theme.fg) + .foregroundStyle(chosen ? Theme.ground : Theme.ink) .frame(maxWidth: .infinity, minHeight: 44) .padding(.vertical, 4) } @@ -471,132 +531,78 @@ private struct ControlBar: View { } } -// MARK: The demo's own cable - -private struct DemoSection: View { - let lp: Loadpoint - let model: LoadpointsModel +/// The receipt log and one sentence under it: what the box did, or, in +/// alarm red, why not. +struct CommandReceipt: View { + let receipt: ReceiptLog + let outcome: EVText.Outcome var body: some View { - Section { - Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { - SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) - model.reread() - } - } header: { - Text(L("Demo")) - } footer: { - Text(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) + VStack(alignment: .leading, spacing: 4) { + ReceiptRow(receipt: receipt) + .accessibilityIdentifier("car.receipt") + Text(outcome.text) + .font(.footnote.weight(.medium)) + .foregroundStyle(color) + .contentTransition(.opacity) + .fixedSize(horizontal: false, vertical: true) } - .listRowBackground(Theme.surfaceRaised) + .accessibilityElement(children: .combine) } -} -/// Before the first answer and with nothing cached: the shape of the -/// screen, not a spinner. -private struct PlaceholderSections: View { - var body: some View { - Group { - Section { Text("Plugged in · charges at 01:00").font(.title3.weight(.semibold)) } - Section { Text("20 %").font(Theme.percent(.largeTitle, weight: .bold)) } - Section { Text("80 % by 07:00 on weekdays").font(.headline) } + private var color: Color { + switch outcome.tone { + case .working: return Theme.ink2 + case .done: return Theme.live + case .warning, .failed: return Theme.alarm } - .listRowBackground(Theme.surfaceRaised) - .redacted(reason: .placeholder) } } -/// The room a receipt takes, its marks and two lines of text, laid out as -/// `ReceiptView` lays them out and left empty, so a receipt that arrives -/// moves nothing. -struct ReceiptRoom: View { +/// The room a receipt takes, its line and two lines of text, laid out as +/// `CommandReceipt` lays them out and left empty. +private struct ReceiptSpace: View { var body: some View { - VStack(alignment: .leading, spacing: 6) { - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { marks } - VStack(alignment: .leading, spacing: 4) { marks } - } - Text(verbatim: "\n") - .font(.footnote.weight(.medium)) + VStack(alignment: .leading, spacing: 4) { + ReceiptRow(receipt: ReceiptLog()) + Text(verbatim: "\n").font(.footnote.weight(.medium)) } - .padding(.vertical, 4) .hidden() .accessibilityHidden(true) } - - @ViewBuilder private var marks: some View { - ForEach([L("Sent"), L("Received"), L("Done")], id: \.self) { label in - HStack(spacing: 4) { - Image(systemName: "checkmark.circle.fill") - Text(label).font(.caption.weight(.semibold)) - } - } - } } -/// The receipt under a control: sent, received, done, and one sentence. -struct ReceiptView: View { - let outcome: EVText.Outcome +// MARK: The demo's own cable + +private struct DemoBlock: View { + let lp: Loadpoint + let model: LoadpointsModel var body: some View { - VStack(alignment: .leading, spacing: 6) { - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { marks } - VStack(alignment: .leading, spacing: 4) { marks } + VStack(alignment: .leading, spacing: 8) { + Kicker(L("Demo")) + Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { + SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) + model.reread() } - Text(outcome.text) - .font(.footnote.weight(.medium)) - .foregroundStyle(color) - .contentTransition(.opacity) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - .animation(Theme.spring, value: outcome) - .accessibilityElement(children: .combine) - } - - @ViewBuilder private var marks: some View { - mark(L("Sent"), outcome.sent) - mark(L("Received"), outcome.received) - mark(L("Done"), outcome.done) - } - - private func mark(_ label: String, _ state: EVText.Outcome.Mark) -> some View { - HStack(spacing: 4) { - Image(systemName: symbol(state)) - .foregroundStyle(tint(state)) - .contentTransition(.symbolEffect(.replace)) - .symbolEffect(.pulse, options: .repeating, isActive: state == .active) - Text(label) - .font(.caption.weight(.semibold)) - .foregroundStyle(state == .waiting ? Theme.fgMuted : Theme.fg) - } - } - - private func symbol(_ state: EVText.Outcome.Mark) -> String { - switch state { - case .done: return "checkmark.circle.fill" - case .active: return "circle.dotted.circle" - case .waiting: return "circle" - case .failed: return "xmark.circle.fill" - } - } - - private func tint(_ state: EVText.Outcome.Mark) -> Color { - switch state { - case .done: return Theme.live - case .active: return Theme.accent - case .waiting: return Theme.fgMuted - case .failed: return Theme.alarm + .buttonStyle(.glass) + Text(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) + .font(.footnote) + .foregroundStyle(Theme.ink3) } } +} - private var color: Color { - switch outcome.tone { - case .working: return Theme.fgDim - case .done: return Theme.live - case .warning: return Theme.alarm - case .failed: return Theme.alarm +/// Before the first answer and with nothing cached: the shape of the +/// screen, not a spinner. +private struct Placeholder: View { + var body: some View { + VStack(alignment: .leading, spacing: 24) { + Text("0,0 kW").font(Theme.figure(60)) + Text("Plugged in · charges at 01:00").font(.subheadline) + Text("80 % by 07:00 on weekdays").font(.headline) } + .foregroundStyle(Theme.ink3) + .redacted(reason: .placeholder) } } diff --git a/appleApp/FTW/Car/GoalEditor.swift b/appleApp/FTW/Car/GoalEditor.swift index d8c8795..c6ed00a 100644 --- a/appleApp/FTW/Car/GoalEditor.swift +++ b/appleApp/FTW/Car/GoalEditor.swift @@ -95,9 +95,10 @@ struct GoalEditor: View { Form { Section(L("Charge to")) { Stepper(value: $socPct, in: 10...100, step: 5) { - Text(AppWords.percent(socPct)).font(Theme.percent(.title2)) + Text(AppWords.percent(socPct)).font(Theme.figure(22)) } } + .listRowBackground(Theme.raised) Section(L("Ready by")) { DatePicker(L("Ready by"), selection: $time, displayedComponents: .hourAndMinute) .datePickerStyle(.wheel) @@ -105,6 +106,7 @@ struct GoalEditor: View { .environment(\.timeZone, model.zone) .frame(maxWidth: .infinity) } + .listRowBackground(Theme.raised) Section(L("Repeat")) { Picker(L("Repeat"), selection: $days) { ForEach(choices, id: \.self) { Text($0.label).tag($0) } @@ -112,12 +114,14 @@ struct GoalEditor: View { .pickerStyle(.inline) .labelsHidden() } + .listRowBackground(Theme.raised) Section { - Text(preview).font(.subheadline).foregroundStyle(Theme.fgDim) + Text(preview).font(.subheadline).foregroundStyle(Theme.ink2) if let failure { Text(failure).font(.subheadline).foregroundStyle(Theme.alarm) } } + .listRowBackground(Theme.raised) if lp.schedule != nil { Section { Button(L("Remove goal"), role: .destructive) { @@ -130,8 +134,12 @@ struct GoalEditor: View { } .disabled(saving) } + .listRowBackground(Theme.raised) } } + // FTW's ground under the sheet, its rows raised. + .scrollContentBackground(.hidden) + .background(Theme.ground) .navigationTitle(L("Goal")) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/appleApp/FTW/UI/CellMeter.swift b/appleApp/FTW/UI/CellMeter.swift index 831c522..3f436f9 100644 --- a/appleApp/FTW/UI/CellMeter.swift +++ b/appleApp/FTW/UI/CellMeter.swift @@ -2,11 +2,13 @@ import SwiftUI /// A level in tenths, one of the six TUI details: ten cells with 1.2 pt /// gaps, as many lit as the level has tenths, readable at 4 pt. Lit cells -/// glow only while live, and only in the dark. +/// glow only while live, and only in the dark. A level that is a fact but +/// not a live reading, such as the car's, takes its colour without glow. struct CellMeter: View { let pct: Double let tint: Color let live: Bool + var glows = true @Environment(\.colorScheme) private var scheme @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @@ -18,7 +20,7 @@ struct CellMeter: View { for i in 0..<10 { let cell = CGRect(x: CGFloat(i) * (w + gap), y: 0, width: w, height: size.height) if i < lit { - if live && scheme == .dark && !reduceTransparency { + if live && glows && scheme == .dark && !reduceTransparency { g.fill(Path(roundedRect: cell.insetBy(dx: -1, dy: -1.5), cornerRadius: 1.5), with: .color(tint.opacity(0.25))) } g.fill(Path(roundedRect: cell, cornerRadius: 0.75), with: .color(live ? tint : Theme.ink3)) diff --git a/appleApp/FTW/UI/NodeHistory.swift b/appleApp/FTW/UI/NodeHistory.swift index 482b5a5..d02fb13 100644 --- a/appleApp/FTW/UI/NodeHistory.swift +++ b/appleApp/FTW/UI/NodeHistory.swift @@ -306,7 +306,7 @@ struct AheadChartArea: View { var body: some View { let strip: CGFloat = 18 - let frame = ChartFrame(top: tag * 1.35 + 8, bottom: 6 + strip + axis * 1.4 + 4, trailing: axis * 3.2) + let frame = ChartFrame(top: tag * 1.35 + 8, bottom: 6 + strip + axis * 1.4 + 4, trailing: axis * 3.8) let made = build(frame.plot(in: size).size) ZStack { Color.clear diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 5fec267..fe27e43 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -275,7 +275,8 @@ final class NodeTraceModel { /// "1,8 kW" at 60 pt, the way it flows under it in its colour, and the /// node's second fact on the right. Under a finger on the live trace it /// gives the reading there and its time, and comes back with the spring. -private struct NodeHero: View { +/// The car view opens on it too. +struct NodeHero: View { let site: SiteModel let node: EnergyNode let home: HomeModels @@ -367,7 +368,7 @@ private struct NodeHero: View { /// The fact beside the figure that only this node has: the battery's level /// as the 10-cell meter, the car's level and where it comes from, what the /// grid costs an hour, the sun against its forecast, or the home's sources. -private struct SecondFact: View { +struct SecondFact: View { let site: SiteModel let node: EnergyNode let home: HomeModels @@ -388,11 +389,17 @@ private struct SecondFact: View { .frame(width: 74, height: 4) } case .car: + // Never worked out from power: the level as the car, this + // phone or the box last gave it, with where from and how old. if let lp = home.loadpoints.points.first, let level = home.loadpoints.level(lp) { + let guessed = level.origin == .assumed || level.origin == .unconfirmed Text(AppWords.percent(level.pct)) .font(Theme.figure(levelSize)) - .foregroundStyle(level.origin == .assumed || level.origin == .unconfirmed ? Theme.ink3 : Theme.ink) - Text(EVText.originShort(level, zone: home.loadpoints.zone)) + .foregroundStyle(guessed ? Theme.ink3 : Theme.ink) + .contentTransition(.numericText(value: level.pct)) + CellMeter(pct: level.pct, tint: Theme.car, live: !guessed, glows: false) + .frame(width: 74, height: 4) + Text(EVText.originAged(level, nowMs: site.minuteMs, zone: home.loadpoints.zone)) .font(.footnote) .foregroundStyle(Theme.ink2) } @@ -460,7 +467,7 @@ private struct ShareBar: View { /// Over the chart: whether this node's reading is live, and from which /// device. `● LIVE · SUNGROW`, or `○ AS OF 14:05`: a clock time, never a /// count of seconds. -private struct NodeStatusRow: View { +struct NodeStatusRow: View { let site: SiteModel let node: EnergyNode @ScaledMetric(relativeTo: .caption2) private var size: CGFloat = 10.5 diff --git a/appleApp/FTW/UI/ReceiptView.swift b/appleApp/FTW/UI/ReceiptView.swift new file mode 100644 index 0000000..cf582f3 --- /dev/null +++ b/appleApp/FTW/UI/ReceiptView.swift @@ -0,0 +1,97 @@ +import FTWKit +import SwiftUI + +/// The room a receipt takes, its marks and two lines of text, laid out as +/// `ReceiptView` lays them out and left empty, so a receipt that arrives +/// moves nothing. +struct ReceiptRoom: View { + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { marks } + VStack(alignment: .leading, spacing: 4) { marks } + } + Text(verbatim: "\n") + .font(.footnote.weight(.medium)) + } + .padding(.vertical, 4) + .hidden() + .accessibilityHidden(true) + } + + @ViewBuilder private var marks: some View { + ForEach([L("Sent"), L("Received"), L("Done")], id: \.self) { label in + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + Text(label).font(.caption.weight(.semibold)) + } + } + } +} + +/// The receipt under a control: sent, received, done, and one sentence. +struct ReceiptView: View { + let outcome: EVText.Outcome + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { marks } + VStack(alignment: .leading, spacing: 4) { marks } + } + Text(outcome.text) + .font(.footnote.weight(.medium)) + .foregroundStyle(color) + .contentTransition(.opacity) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .animation(Theme.spring, value: outcome) + .accessibilityElement(children: .combine) + } + + @ViewBuilder private var marks: some View { + mark(L("Sent"), outcome.sent) + mark(L("Received"), outcome.received) + mark(L("Done"), outcome.done) + } + + private func mark(_ label: String, _ state: EVText.Outcome.Mark) -> some View { + HStack(spacing: 4) { + Image(systemName: symbol(state)) + .foregroundStyle(tint(state)) + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.pulse, options: .repeating, isActive: state == .active) + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(state == .waiting ? Theme.fgMuted : Theme.fg) + } + } + + private func symbol(_ state: EVText.Outcome.Mark) -> String { + switch state { + case .done: return "checkmark.circle.fill" + case .active: return "circle.dotted.circle" + case .waiting: return "circle" + case .failed: return "xmark.circle.fill" + } + } + + private func tint(_ state: EVText.Outcome.Mark) -> Color { + switch state { + case .done: return Theme.live + case .active: return Theme.accent + case .waiting: return Theme.fgMuted + case .failed: return Theme.alarm + } + } + + private var color: Color { + switch outcome.tone { + case .working: return Theme.fgDim + case .done: return Theme.live + case .warning: return Theme.alarm + case .failed: return Theme.alarm + } + } +} From 85118039da65aa091394c0e48ea28ac4d04dee56 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 18:44:13 +0200 Subject: [PATCH 4/5] iPhone car: cap the status line and the plan's labels at the node view's sizes A scaled metric reads the text size where its view is placed, so the cap sits at the car view's placement, as in the node view. At the largest accessibility size the status line was growing past its line. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Car/CarView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index 166b83a..e14010c 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -35,7 +35,10 @@ struct CarView: View { NodeHero(site: home.site, node: .car, home: home, trace: trace, scrub: scrub, timebase: .ahead, text: text) .dynamicTypeSize(...DynamicTypeSize.accessibility1) .padding(.horizontal, 20) + // A scaled metric reads the size where it is placed: + // the caps sit here, as in the node view. NodeStatusRow(site: home.site, node: .car) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .padding(.horizontal, 20) .padding(.top, 16) StatusLines(lp: lp, model: model, site: home.site) @@ -162,6 +165,7 @@ private struct CarPlan: View { Kicker(L("Plan")) .padding(.horizontal, 20) AheadChartArea(site: home.site, node: .car, home: home, scrub: scrub, text: text) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) .frame(height: 170) .padding(.horizontal, 4) ZStack(alignment: .topLeading) { From a9cb0dcdd6c5ac4b52a57d0add043333b7dfda39 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 19:05:41 +0200 Subject: [PATCH 5/5] iPhone car: the hint and the receipt fade in place, never swap The bar's hint and its receipt were swapped with an insertion and a removal. A receipt that changes four times in a few seconds left the hint half removed under the receipt line in the simulator. Both now stay in the bar's reserved room and only fade, and the sentence under the receipt cross-fades when it changes. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Car/CarView.swift | 36 ++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index e14010c..886f31d 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -229,7 +229,7 @@ private struct LevelBlock: View { Text(L("Level unknown")).font(.headline).foregroundStyle(Theme.ink) } if let receipt = model.receipt(level: lp), let outcome = model.outcome(level: lp) { - CommandReceipt(receipt: receipt, outcome: outcome) + CommandReceipt(receipt: receipt, text: outcome.text, tone: outcome.tone) .transition(.opacity) } if level?.origin == .car, !correcting, canControl { @@ -451,6 +451,8 @@ private struct ControlBar: View { let current = self.current let asked = self.asked let receipt = model.receipt(hold: lp) + let outcome = model.outcome(hold: lp) + let shown = receipt != nil && outcome != nil VStack(alignment: .leading, spacing: 10) { // Side by side; at the accessibility sizes one under the other, // so no name is cut. @@ -463,25 +465,27 @@ private struct ControlBar: View { } } } + // Both stay in place and only fade, so a receipt that changes + // four times in a few seconds never leaves a half-removed hint + // behind it. ZStack(alignment: .topLeading) { ReceiptSpace() - if let receipt, let outcome = model.outcome(hold: lp) { - CommandReceipt(receipt: receipt, outcome: outcome) - .transition(.opacity) - } else { - Text(L("Charge now runs until you go back to the plan or unplug.")) - .font(.footnote) - .foregroundStyle(Theme.ink2) - .fixedSize(horizontal: false, vertical: true) - .transition(.opacity) - } + Text(L("Charge now runs until you go back to the plan or unplug.")) + .font(.footnote) + .foregroundStyle(Theme.ink2) + .fixedSize(horizontal: false, vertical: true) + .opacity(shown ? 0 : 1) + .accessibilityHidden(shown) + CommandReceipt(receipt: receipt ?? ReceiptLog(), text: outcome?.text ?? " ", tone: outcome?.tone ?? .working) + .opacity(shown ? 1 : 0) + .accessibilityHidden(!shown) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) + .animation(Theme.spring, value: shown) } .padding(.horizontal, 16) .padding(.vertical, 10) - .animation(Theme.spring, value: receipt) .animation(Theme.spring, value: current) // Commands answer once, when the box says what it did. .sensoryFeedback(trigger: model.outcome(hold: lp)?.tone) { _, tone in LevelBlock.feedback(tone) } @@ -539,23 +543,25 @@ private struct ControlBar: View { /// alarm red, why not. struct CommandReceipt: View { let receipt: ReceiptLog - let outcome: EVText.Outcome + let text: String + let tone: EVText.Outcome.Tone var body: some View { VStack(alignment: .leading, spacing: 4) { ReceiptRow(receipt: receipt) .accessibilityIdentifier("car.receipt") - Text(outcome.text) + Text(text) .font(.footnote.weight(.medium)) .foregroundStyle(color) .contentTransition(.opacity) .fixedSize(horizontal: false, vertical: true) + .animation(.easeInOut(duration: 0.2), value: text) } .accessibilityElement(children: .combine) } private var color: Color { - switch outcome.tone { + switch tone { case .working: return Theme.ink2 case .done: return Theme.live case .warning, .failed: return Theme.alarm