From 3efc993ada7327c9ca139f0d08dadf10d2e5cf71 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 17:04:02 +0200 Subject: [PATCH 1/4] 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 9c9f8f08f22e10be46234c4534e13225a93b6d08 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 17:36:15 +0200 Subject: [PATCH 2/4] iPhone widgets: Grid today in the app's grammar, one signed line with its body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid today drew the day as bars for what it bought and a thin curve below zero for what it sold. It now draws what the grid's Today draws in the app (research decision D): each quarter's net power as one signed line, buying above zero and selling below, magenta both ways. The geometry is the app's own ChartSeries.history: one point per slot of at least 6 pt (30 minutes on the medium widget, 60 on the small), each the exact mean of its quarters, through a Steffen curve that never overshoots, with its body, and on the medium the band of the quarters in each slot. A hole the box has no record for breaks the line and shows a dotted baseline; nothing is drawn after the read. Still paths, a 2 pt line, no glow. The arrow beside net becomes a word in the grid's colour, "köpt netto" or "sålt netto", next to both sides. The chart says ↑ KÖPER and ↓ SÄLJER beside the scale's ends, in the corner the hours still to come leave empty, where that side has room. The kWh, the cost from the box and "kl hh:mm" stay. Figures are SF Pro with even digits; the kicker and the time are the terminal layer. Tinted and clear draw the line in primary at 0.7, marked for the accent, and the body fainter. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Sources/FTWShared/GridGlance.swift | 178 ++++------- .../FTWShared/Resources/en.lproj/Grid.strings | 5 + .../FTWShared/Resources/sv.lproj/Grid.strings | 5 + .../FTWKitTests/GridTodayShareTests.swift | 15 +- .../FTWSharedTests/GridGlanceTests.swift | 137 +++++---- .../FTWWidgets/GridToday/GridTodayViews.swift | 282 ++++++++++-------- appleApp/FTWWidgets/Kicker.swift | 16 + 7 files changed, 326 insertions(+), 312 deletions(-) create mode 100644 appleApp/FTWWidgets/Kicker.swift diff --git a/appleApp/FTWKit/Sources/FTWShared/GridGlance.swift b/appleApp/FTWKit/Sources/FTWShared/GridGlance.swift index d34e915..ffa840d 100644 --- a/appleApp/FTWKit/Sources/FTWShared/GridGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/GridGlance.swift @@ -1,8 +1,11 @@ +import CoreGraphics import Foundation /// The grid widget at one moment, worked out from a `GridTodayEntry` alone: /// today's net energy with its direction and both sides of it, today's cost -/// as the box priced it, and the day as a chart on a fixed 00–24 axis. +/// as the box priced it, and the day as a chart on a fixed 00–24 axis, in +/// the app's grammar: one signed line with its body, buying above zero and +/// selling below, magenta both ways. /// /// Net alone is never shown as if it were the bill: the line under it /// always holds what was bought and what was sold. After local midnight a @@ -14,10 +17,9 @@ public enum GridGlance { public static let symbol = "bolt.fill" /// Below this share of the day priced, the cost is marked partial. public static let fullCoverage = 0.95 - /// Below this a quarter's mean is sensor noise, as on Now. - static let noiseW = PowerFormat.noiseW - /// A side of the chart narrower than this share of it has no label. - static let labelRoom = 0.2 + /// The chart's scale never shrinks below a kilowatt each way it has, + /// so a quiet day's noise stays small. + public static let floorW: Double = 1_000 public enum State: Equatable, Sendable { /// The app has left nothing: not paired, or signed out. @@ -37,42 +39,6 @@ public enum GridGlance { case toGrid /// Even, to the tenth of a kWh. case even - - public var arrow: String { - switch self { - case .fromGrid: return "↓" - case .toGrid: return "↑" - case .even: return "" - } - } - - /// The SF Symbol for the arrow. Nil when even. - public var symbol: String? { - switch self { - case .fromGrid: return "arrow.down" - case .toGrid: return "arrow.up" - case .even: return nil - } - } - } - - /// One import bar: a quarter hour of mean power bought. - public struct Bar: Equatable, Sendable, Identifiable { - public let startMs: Double - public let endMs: Double - public let watts: Double - public var id: Double { startMs } - } - - /// One point of the export curve, at the middle of its quarter, in - /// chart units: negative watts, so it sits below zero. `run` breaks the - /// curve where the box has no record. - public struct CurvePoint: Equatable, Sendable, Identifiable { - public let t: Double - public let watts: Double - public let run: Int - /// Two runs can share the zero between them. - public var id: String { "\(run)/\(Int64(t))" } } /// A label on an axis, at a value in chart units. @@ -88,9 +54,11 @@ public enum GridGlance { public let readAtMs: Double? public let direction: Direction - /// "1,8": net, never signed; the arrow says which way. + /// "1,8": net, never signed; the word under it says which way. public let netText: String? public let unit: String + /// "köpt netto", "sålt netto": which way the day leans, in words. + public let netWord: String? /// "in 6,5 · ut 8,3 kWh" public let inOutText: String? /// "12,40" or "−4,20". Nil when the box had no prices. @@ -111,13 +79,10 @@ public enum GridGlance { /// The fixed axis: local midnight to the next. public let dayStartMs: Double public let dayEndMs: Double - public let bars: [Bar] - public let curve: [CurvePoint] - /// The chart's value range: top above zero, bottom below. - public let yTop: Double - public let yBottom: Double - /// 0 and at most one round value each way. - public let yTicks: [Tick] + /// The grid's net power each quarter the box has, signed as the + /// site's watts: positive bought, negative sold. Nil where the box + /// has no record. Nothing after the read: it has not happened. + public let samples: [ChartSample] /// 00, 06, 12, 18, 24 on the household's clock. public let xTicks: [Tick] public let timeZone: TimeZone @@ -125,9 +90,34 @@ public enum GridGlance { public let spoken: String public var isGrey: Bool { state != .today } - public var hasChart: Bool { !bars.isEmpty || !curve.isEmpty } + public var hasChart: Bool { samples.contains { $0.value != nil } } + + /// The day's geometry on a plot of `size`, as the app draws a + /// node's Today: one point per slot of at least 6 pt (60 minutes on + /// the small widget, 30 on the medium), each the exact mean of its + /// quarters, through a Steffen curve that never overshoots, with + /// its body and the band of the quarters it holds. A hole the box + /// has no record for stays a break. + public func series(size: CGSize) -> ChartSeries { + ChartSeries.history( + samples, + sampleMs: GridTodayEntry.quarterMs, + fromMs: dayStartMs, + toMs: dayEndMs, + nowMs: readAtMs.map { min($0, dayEndMs) }, + size: size, + floor: GridGlance.floorW, + clock: ZoneClock.of(timeZone), + words: ReadoutWords(value: { _ in "" }, range: { _, _ in "" }, none: "") + ) + } } + /// "↑ BUYING" over zero and "↓ SELLING" under it, where the chart has + /// room on that side. + public static func upWord(_ language: GridLanguage = .current) -> String { "↑ " + language.text(.buying) } + public static func downWord(_ language: GridLanguage = .current) -> String { "↓ " + language.text(.selling) } + // MARK: The timeline /// One moment now and one at the next local midnight, where today's @@ -165,7 +155,6 @@ public enum GridGlance { let note: String? = e.costMinor == nil ? language.text(.noPrices) : (partial ? language.text(.partial) : nil) let unit = currencyUnit(e.currency) - let chart = chart(e.quarters, dayStartMs: e.dayStartMs, dayEndMs: e.dayEndMs) let time = language.format(.at, clockText(read.record.readAtMs, from: t, calendar: calendar, language: language)) var spoken = [String]() @@ -181,13 +170,20 @@ public enum GridGlance { if let note { spoken.append(note) } spoken.append(time) + let word: GridWord + switch direction { + case .fromGrid: word = .boughtNet + case .toGrid: word = .soldNet + case .even: word = .evenNet + } + return Moment( atMs: t, state: .today, readAtMs: read.record.readAtMs, - direction: direction, netText: netText, unit: "kWh", inOutText: inOut, + direction: direction, netText: netText, unit: "kWh", netWord: language.text(word), inOutText: inOut, costText: cost?.shown, costUnit: unit, earned: cost?.isNegative ?? false, partial: partial, costNote: note, timeText: time, emptyText: nil, dayStartMs: e.dayStartMs, dayEndMs: e.dayEndMs, - bars: chart.bars, curve: chart.curve, yTop: chart.top, yBottom: chart.bottom, yTicks: chart.ticks, + samples: samples(e.quarters, dayStartMs: e.dayStartMs, dayEndMs: e.dayEndMs), xTicks: xTicks(dayStartMs: e.dayStartMs, dayEndMs: e.dayEndMs, calendar: calendar), timeZone: calendar.timeZone, spoken: spoken.joined(separator: ". ") ) @@ -195,9 +191,9 @@ public enum GridGlance { private static func empty(atMs t: Double, state: State, readAtMs: Double?, text: String, time: String?, day: (Double, Double), calendar: Calendar, language: GridLanguage) -> Moment { Moment( - atMs: t, state: state, readAtMs: readAtMs, direction: .even, netText: nil, unit: "kWh", inOutText: nil, + atMs: t, state: state, readAtMs: readAtMs, direction: .even, netText: nil, unit: "kWh", netWord: nil, inOutText: nil, costText: nil, costUnit: "", earned: false, partial: false, costNote: nil, timeText: time, emptyText: text, - dayStartMs: day.0, dayEndMs: day.1, bars: [], curve: [], yTop: 1, yBottom: -1, yTicks: [], + dayStartMs: day.0, dayEndMs: day.1, samples: [], xTicks: xTicks(dayStartMs: day.0, dayEndMs: day.1, calendar: calendar), timeZone: calendar.timeZone, spoken: [text, time].compactMap { $0 }.joined(separator: ". ") ) @@ -211,68 +207,13 @@ public enum GridGlance { // MARK: The chart - /// Bars for what was bought, a curve below zero for what was sold. A - /// quarter without a record breaks the curve and has no bar; the curve - /// is drawn only around export, resting on zero at each end of a run. - static func chart(_ quarters: [GridTodayEntry.Quarter], dayStartMs: Double, dayEndMs: Double) -> (bars: [Bar], curve: [CurvePoint], top: Double, bottom: Double, ticks: [Tick]) { - let q = GridTodayEntry.quarterMs - let inDay = quarters.filter { $0.startMs >= dayStartMs && $0.startMs < dayEndMs } - let bars = inDay.compactMap { r -> Bar? in - guard let w = r.importW, w >= noiseW else { return nil } - return Bar(startMs: r.startMs, endMs: min(dayEndMs, r.startMs + q), watts: w) + /// Each quarter's net power, site-signed: what it bought less what it + /// sold, both means over the part of the quarter the box recorded. A + /// quarter without a record stays empty, so the line breaks there. + static func samples(_ quarters: [GridTodayEntry.Quarter], dayStartMs: Double, dayEndMs: Double) -> [ChartSample] { + quarters.filter { $0.startMs >= dayStartMs && $0.startMs < dayEndMs }.map { q in + ChartSample(startMs: q.startMs, value: q.measured ? (q.importW ?? 0) - (q.exportW ?? 0) : nil) } - - var curve = [CurvePoint]() - var run = 0 - var open = false - var previous: GridTodayEntry.Quarter? - for r in inDay { - // The quarter before has a record and touches this one. - let joined = previous.map { $0.measured && $0.startMs + q == r.startMs } ?? false - if !r.measured || !joined, open { - // A hole: the run ends where the record does, off zero. - open = false - run += 1 - } - if r.measured, (r.exportW ?? 0) >= noiseW { - // A run leaves zero from the quarter before, when the box - // has a record for it. - if !open, joined, let p = previous { curve.append(CurvePoint(t: p.startMs + q / 2, watts: 0, run: run)) } - open = true - curve.append(CurvePoint(t: r.startMs + q / 2, watts: -(r.exportW ?? 0), run: run)) - } else if open { - // and lands on zero at the first quarter that sold nothing. - curve.append(CurvePoint(t: r.startMs + q / 2, watts: 0, run: run)) - open = false - run += 1 - } - previous = r - } - - let peakIn = bars.map(\.watts).max() ?? 0 - let peakOut = curve.map { -$0.watts }.max() ?? 0 - let top = peakIn > 0 ? roundUp(peakIn) : 0 - let bottom = peakOut > 0 ? -roundUp(peakOut) : 0 - // A side with nothing on it keeps a sliver, so zero is not the edge. - let span = max(top, -bottom, 1_000) - let high = top > 0 ? top : span * 0.12 - let low = bottom < 0 ? bottom : -span * 0.12 - // A side too thin for its own label would print it on top of zero's. - let room = labelRoom * (high - low) - var ticks = [Tick]() - if top > 0, top >= room { ticks.append(Tick(value: top, label: PowerFormat.scale(top))) } - ticks.append(Tick(value: 0, label: "0")) - if bottom < 0, -bottom >= room { ticks.append(Tick(value: bottom, label: PowerFormat.scale(bottom))) } - return (bars, curve, high, low, ticks) - } - - /// The next round value up: half a kilowatt below one, whole kilowatts - /// to ten, then fives. - static func roundUp(_ w: Double) -> Double { - let kw = w / 1000 - if kw <= 0.5 { return 500 } - if kw <= 10 { return kw.rounded(.up) * 1000 } - return (kw / 5).rounded(.up) * 5000 } /// 00, 06, 12, 18 and 24, on the household's clock, so a day that @@ -391,6 +332,11 @@ enum GridWord: String, CaseIterable { case noPrices = "no prices" case openToSee = "Open FTW to see today's grid" case newDay = "Today's figures aren't here yet · open FTW" + case boughtNet = "bought, net" + case soldNet = "sold, net" + case evenNet = "even, net" + case buying = "BUYING" + case selling = "SELLING" case spokenFrom = "%@ kWh net from the grid today" case spokenTo = "%@ kWh net to the grid today" case spokenEven = "%@ kWh net with the grid today" diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Grid.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Grid.strings index 0f1db56..24e5a48 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Grid.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Grid.strings @@ -13,3 +13,8 @@ "%@ kWh net with the grid today" = "%@ kWh net with the grid today"; "cost %@" = "cost %@"; "earned %@" = "earned %@"; +"bought, net" = "bought, net"; +"sold, net" = "sold, net"; +"even, net" = "even, net"; +"BUYING" = "BUYING"; +"SELLING" = "SELLING"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Grid.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Grid.strings index 780d63d..7342327 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Grid.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Grid.strings @@ -13,3 +13,8 @@ "%@ kWh net with the grid today" = "%@ kWh netto mot nätet i dag"; "cost %@" = "kostnad %@"; "earned %@" = "tjänat %@"; +"bought, net" = "köpt netto"; +"sold, net" = "sålt netto"; +"even, net" = "jämnt netto"; +"BUYING" = "KÖPER"; +"SELLING" = "SÄLJER"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift index 617fff8..2a728fd 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift @@ -82,8 +82,8 @@ import FTWShared #expect(e.quarters.allSatisfy { $0.measured }) #expect((e.quarters.last?.startMs ?? 0) <= s.nowMs) - // The totals and the chart are one day: the bars and the curve add - // up to what the box counted, within the quarter still filling. + // The totals and the chart are one day: the quarters add up to what + // the box counted, within the quarter still filling. let bought = e.quarters.reduce(0) { $0 + ($1.importW ?? 0) * 0.25 } let sold = e.quarters.reduce(0) { $0 + ($1.exportW ?? 0) * 0.25 } #expect(abs(bought - e.importWh) / e.importWh < 0.03, "\(bought) vs \(e.importWh)") @@ -94,7 +94,8 @@ import FTWShared #expect(m.state == .today) #expect(m.netText != nil && m.inOutText != nil && m.costText != nil) #expect(m.costNote == nil) - #expect(!m.bars.isEmpty) + #expect(m.hasChart) + #expect(m.samples.contains { ($0.value ?? 0) > 0 }) #expect(m.timeText?.hasPrefix("kl ") == true) // An October evening buys more than it sells. #expect(m.direction == .fromGrid) @@ -102,7 +103,7 @@ import FTWShared } /// `-FTWDemoSunny YES`: a long clear day that sells more than it buys, - /// so the arrow points up and the cost is money earned. + /// so the word says sold and the cost is money earned. @Test func aSunnyDemoDayIsANetExportDay() async throws { let files = shared() _ = await demo(sunny: true, files: files) @@ -112,8 +113,10 @@ import FTWShared #expect(m.direction == .toGrid) #expect(m.earned) #expect(m.costText?.hasPrefix("−") == true) - #expect(!m.curve.isEmpty) - #expect(m.yTicks.count == 3) + #expect(m.netWord == "sålt netto") + // Selling under zero and buying over it: the scale has both sides. + let series = m.series(size: CGSize(width: 300, height: 80)) + #expect(series.domain.lower < 0 && series.domain.upper > 0) } @Test func aPartlyPricedDemoDayIsMarked() async throws { diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/GridGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/GridGlanceTests.swift index ae70163..edf44e5 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/GridGlanceTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/GridGlanceTests.swift @@ -68,25 +68,23 @@ import AppKit #expect(big.inOutText == "in 14,0 · ut 2,0 kWh") } - /// Net is a magnitude with an arrow, never a signed figure: ↓ when the - /// home took more than it gave, ↑ when it gave more. - @Test func theArrowSaysWhichWayAndTheFigureNeverCarriesASign() { + /// Net is a magnitude with a word, never a signed figure: bought when + /// the home took more than it gave, sold when it gave more. + @Test func theWordSaysWhichWayAndTheFigureNeverCarriesASign() { let selling = moment(entry(importWh: 6_500, exportWh: 8_300), .sv) #expect(selling.direction == .toGrid) - #expect(selling.direction.arrow == "↑") - #expect(selling.direction.symbol == "arrow.up") + #expect(selling.netWord == "sålt netto") let buying = moment(entry(importWh: 8_300, exportWh: 6_500, costMinor: 1_240), .sv) #expect(buying.direction == .fromGrid) - #expect(buying.direction.arrow == "↓") - #expect(buying.direction.symbol == "arrow.down") + #expect(buying.netWord == "köpt netto") #expect(buying.netText == "1,8") + #expect(moment(entry(importWh: 8_300, exportWh: 6_500), .en).netWord == "bought, net") - // Even to the tenth: no arrow at all. + // Even to the tenth. let even = moment(entry(importWh: 5_000, exportWh: 5_040), .en) #expect(even.direction == .even) - #expect(even.direction.arrow.isEmpty) - #expect(even.direction.symbol == nil) + #expect(even.netWord == "even, net") #expect(even.netText == "0.0") for m in [selling, buying, even] { @@ -94,6 +92,8 @@ import AppKit // Both sides are always there beside the net. #expect(m.inOutText != nil) } + #expect(GridGlance.upWord(.sv) == "↑ KÖPER") + #expect(GridGlance.downWord(.en) == "↓ SELLING") } /// The box's `actual_cost_ore / 100`. Money earned carries a minus sign, @@ -205,80 +205,79 @@ import AppKit // MARK: The chart - @Test func importIsBarsAndExportACurveBelowZeroThatBreaksAtAHole() { + /// Subpaths in a path: one per run of data. + func runs(_ p: ChartPath) -> Int { + p.elements.filter { if case .move = $0 { return true } else { return false } }.count + } + + /// One signed line, as the app draws the grid's Today: each quarter's + /// net power, bought above zero and sold below, in slots of 30 minutes + /// on the medium widget and 60 on the small one. The hole from 10:00 + /// breaks it, and nothing is drawn after the read. + @Test func theDayIsOneSignedLineThatBreaksAtAHole() { let m = moment(entry(), .sv) - // Every quarter that bought: the night and from 16:00, not the hole, - // not noon. - #expect(m.bars.count == 40 + 2 + 8) - #expect(m.bars.allSatisfy { $0.watts >= 400 && $0.endMs - $0.startMs == Self.quarter }) - #expect(!m.bars.contains { $0.startMs == at(13, 10) }) - #expect(!m.bars.contains { $0.startMs == at(13, 13) }) - - // One run: off zero at 10:45, down to 3 kW, back on zero at 15:00. - let curve = m.curve - #expect(Set(curve.map(\.run)).count == 1) - #expect(curve.first?.watts == 0) - #expect(curve.first?.t == at(13, 10, 45) + Self.quarter / 2) - #expect(curve.last?.watts == 0) - #expect(curve.last?.t == at(13, 15) + Self.quarter / 2) - #expect(curve.map(\.watts).min() == -3_000) - #expect(curve.allSatisfy { $0.watts <= 0 }) - - // A quiet axis: one round value each way and zero, never signed. - #expect(m.yTicks.map(\.label) == ["1 kW", "0", "3 kW"]) - #expect(m.yTop == 1_000 && m.yBottom == -3_000) + #expect(m.samples.count == 68) + #expect(m.samples[0].value == 400) + #expect(m.samples[40].value == nil && m.samples[41].value == nil) + #expect(m.samples[52].value == -3_000) + #expect(m.samples[60].value == 900) + #expect(m.hasChart) + + let medium = m.series(size: CGSize(width: 300, height: 80)) + #expect(medium.grid.stepMs == 1_800_000) + #expect(medium.grid.count == 48) + #expect(runs(medium.line) == 2) + #expect(medium.missing.map(\.lowerBound) == [20]) + // Buying above zero, selling below, on a scale with room for both. + #expect(medium.domain.upper >= GridGlance.floorW) + #expect(medium.domain.lower <= -3_000) + #expect(medium.points[1]!.y < YMap(domain: medium.domain, height: 80).baseline) + #expect(medium.points[26]!.y > YMap(domain: medium.domain, height: 80).baseline) + // Nothing after 16:55: the slot from 17:00 on has not happened, and + // it is not a hole. + #expect(medium.points[34] == nil) + #expect(medium.lastData == 33) + #expect(!medium.missing.contains { $0.contains(34) }) + + let small = m.series(size: CGSize(width: 130, height: 50)) + #expect(small.grid.stepMs == 3_600_000) + #expect(small.grid.count == 24) #expect(m.xTicks.map(\.label) == ["00", "06", "12", "18", "24"]) } - @Test func aHoleWhileSellingEndsTheCurveOffZero() { + /// A hole while selling breaks the line in two; neither run lands on + /// zero to close the gap. + @Test func aHoleWhileSellingBreaksTheLine() { var e = entry() // The box lost the meter at 12:00 for half an hour. for i in e.quarters.indices where e.quarters[i].startMs >= at(13, 12) && e.quarters[i].startMs < at(13, 12, 30) { e.quarters[i] = .init(startMs: e.quarters[i].startMs, importW: nil, exportW: nil) } - let curve = moment(e, .sv).curve - let runs = Dictionary(grouping: curve, by: \.run) - #expect(runs.count == 2) - // The first run stops at its last record, not on zero; the second - // picks up at its first, and only lands on zero at the end. - let first = runs.keys.sorted().map { runs[$0]! } - #expect(first[0].last?.watts != 0) - #expect(first[0].last?.t == at(13, 11, 45) + Self.quarter / 2) - #expect(first[1].first?.watts != 0) - #expect(first[1].first?.t == at(13, 12, 30) + Self.quarter / 2) - #expect(first[1].last?.watts == 0) + let series = moment(e, .sv).series(size: CGSize(width: 300, height: 80)) + #expect(runs(series.line) == 3) + #expect(series.missing.map(\.lowerBound) == [20, 24]) + #expect(series.points[23]!.y > YMap(domain: series.domain, height: 80).baseline) + #expect(series.points[25]!.y > YMap(domain: series.domain, height: 80).baseline) } - /// A cloud for one quarter: the curve lands on zero and leaves it again - /// from the same point, and each point keeps an identity of its own. - @Test func twoRunsCanShareTheZeroBetweenThem() { + /// A quarter that bought some and sold some is drawn as its net. + @Test func aQuarterThatBoughtAndSoldIsItsNet() { var e = entry() - e.quarters[50] = .init(startMs: e.quarters[50].startMs, importW: 0, exportW: 0) - let curve = moment(e, .sv).curve - #expect(Set(curve.map(\.run)).count == 2) - #expect(Set(curve.map(\.id)).count == curve.count) - #expect(curve.filter { $0.t == at(13, 12, 30) + Self.quarter / 2 }.map(\.watts) == [0, 0]) + e.quarters[10] = .init(startMs: e.quarters[10].startMs, importW: 1_200, exportW: 300) + #expect(moment(e, .en).samples[10].value == 900) } - @Test func aDayThatOnlyBoughtHasNoLowerValue() { + /// A day that only bought has nothing under zero: the scale ends there. + @Test func aDayThatOnlyBoughtHasNoLowerSide() { var e = entry() e.quarters = e.quarters.map { .init(startMs: $0.startMs, importW: $0.importW.map { max($0, 2_300) }, exportW: $0.exportW.map { _ in 0 }) } - let m = moment(e, .en) - #expect(m.curve.isEmpty) - #expect(m.yTicks.map(\.label) == ["3 kW", "0"]) - // Zero sits just off the bottom edge. - #expect(m.yBottom < 0 && m.yBottom > -1_000) - // A sliver of export under a night of car charging keeps its - // curve but not its label, which would sit on zero's. - var car = entry() - car.quarters[4] = .init(startMs: car.quarters[4].startMs, importW: 11_400, exportW: 0) - let crowded = moment(car, .en) - #expect(crowded.yTicks.map(\.label) == ["15 kW", "0"]) - #expect(crowded.yBottom == -3_000) - #expect(!crowded.curve.isEmpty) - #expect(GridGlance.roundUp(300) == 500) - #expect(GridGlance.roundUp(9_100) == 10_000) - #expect(GridGlance.roundUp(11_000) == 15_000) + let series = moment(e, .en).series(size: CGSize(width: 300, height: 80)) + #expect(series.domain.lower == 0) + #expect(series.domain.upper >= 2_300) + // A quiet day keeps the floor, so its noise stays small. + var quiet = entry() + quiet.quarters = quiet.quarters.map { .init(startMs: $0.startMs, importW: $0.importW.map { _ in 60 }, exportW: $0.exportW.map { _ in 0 }) } + #expect(moment(quiet, .en).series(size: CGSize(width: 300, height: 80)).domain.upper == GridGlance.floorW) } // MARK: Quarters from history @@ -351,7 +350,7 @@ import AppKit /// The symbols the widget draws exist. macOS shares iOS's names for /// these; the iOS 26 runtime lists them in `name_availability.plist`. @Test func theSymbolsExist() { - for name in [GridGlance.symbol, "arrow.up", "arrow.down"] { + for name in [GridGlance.symbol] { #expect(NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil, "\(name)") } } diff --git a/appleApp/FTWWidgets/GridToday/GridTodayViews.swift b/appleApp/FTWWidgets/GridToday/GridTodayViews.swift index f15161c..04e1d5a 100644 --- a/appleApp/FTWWidgets/GridToday/GridTodayViews.swift +++ b/appleApp/FTWWidgets/GridToday/GridTodayViews.swift @@ -1,4 +1,3 @@ -import Charts import FTWShared import SwiftUI import WidgetKit @@ -20,7 +19,7 @@ struct GridTodayView: View { private var background: AnyShapeStyle { switch family { case .accessoryRectangular, .accessoryInline, .accessoryCircular: return AnyShapeStyle(.clear) - default: return AnyShapeStyle(moment.isGrey ? Tone.empty : Tone.surface) + default: return AnyShapeStyle(moment.isGrey ? Theme.hairline : Theme.ground) } } @@ -33,27 +32,27 @@ struct GridTodayView: View { } } -/// Net on the left and the cost on the right, the line under each, and -/// the day below them. +/// Net and its word on the left, the cost on the right, and the day under +/// them with its hours and scale. struct MediumGrid: View { let moment: GridGlance.Moment var body: some View { if moment.state == .today { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline) { - NetFigure(moment: moment, size: 30) + NetFigure(moment: moment, size: 28) Spacer(minLength: 8) - CostFigure(moment: moment, size: 22) + CostFigure(moment: moment, size: 20) } HStack(alignment: .firstTextBaseline) { - InOutLine(moment: moment) + NetLine(moment: moment) Spacer(minLength: 8) TimeLine(moment: moment, withNote: true) } .accessibilityHidden(true) GridDayChart(moment: moment, labels: true) - .padding(.top, 2) + .padding(.top, 4) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .accessibilityElement(children: .ignore) @@ -70,14 +69,14 @@ struct SmallGrid: View { var body: some View { if moment.state == .today { - VStack(alignment: .leading, spacing: 2) { - NetFigure(moment: moment, size: 26) - InOutLine(moment: moment) + VStack(alignment: .leading, spacing: 1) { + NetFigure(moment: moment, size: 24) + NetLine(moment: moment) GridDayChart(moment: moment, labels: false) - .padding(.vertical, 3) + .padding(.vertical, 4) // The time has a line of its own, so a long cost never // cuts it short. - CostFigure(moment: moment, size: 17) + CostFigure(moment: moment, size: 16) TimeLine(moment: moment, withNote: true) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -89,8 +88,8 @@ struct SmallGrid: View { } } -/// The lock screen's rectangle: net and cost on one line, both sides of -/// net under it, and the time. +/// The lock screen's rectangle: net and cost on one line, which way and +/// both sides under it, and the time. struct RectangularGrid: View { let moment: GridGlance.Moment @@ -98,7 +97,7 @@ struct RectangularGrid: View { VStack(alignment: .leading, spacing: 0) { if moment.state == .today, let net = moment.netText { HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("\(arrow)\(Text(net).font(.headline.monospacedDigit())) \(Text(moment.unit).font(.caption))") + Text("\(Text(net).font(.headline.monospacedDigit())) \(Text(moment.unit).font(.caption))") .widgetAccentable() Spacer(minLength: 2) if let cost = moment.costText { @@ -107,11 +106,12 @@ struct RectangularGrid: View { } .lineLimit(1) .minimumScaleFactor(0.7) - if let inOut = moment.inOutText { - Text(inOut).font(.caption).lineLimit(1).minimumScaleFactor(0.7) - } + Text([moment.netWord, moment.inOutText].compactMap { $0 }.joined(separator: " · ")) + .font(.caption) + .lineLimit(1) + .minimumScaleFactor(0.7) Text([moment.costNote, moment.timeText].compactMap { $0 }.joined(separator: " · ")) - .font(.caption2) + .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) } else { @@ -120,7 +120,7 @@ struct RectangularGrid: View { .widgetAccentable() Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(2) if let time = moment.timeText { - Text(time).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + Text(time).font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary).lineLimit(1) } } } @@ -128,60 +128,47 @@ struct RectangularGrid: View { .accessibilityElement(children: .ignore) .accessibilityLabel(moment.spoken) } - - private var arrow: Text { - guard let symbol = moment.direction.symbol else { return Text("") } - return Text("\(Image(systemName: symbol)) ").font(.caption.weight(.bold)) - } } // MARK: Parts -/// "↓ 1,8 kWh": the arrow says which way, the figure never carries a sign. +/// "1,8 kWh": a magnitude, never signed; the word under it says which way. struct NetFigure: View { let moment: GridGlance.Moment let size: CGFloat var body: some View { HStack(alignment: .firstTextBaseline, spacing: 3) { - if let symbol = moment.direction.symbol { - Image(systemName: symbol) - .font(.system(size: size * 0.62, weight: .bold)) - .foregroundStyle(Theme.grid) - .widgetAccentable() - } Text(moment.netText ?? "") - .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .font(Theme.figure(size)) + .tracking(-0.5) + .foregroundStyle(Theme.ink) .contentTransition(.numericText()) .widgetAccentable() Text(moment.unit) - .font(.caption) - .foregroundStyle(.secondary) + .font(.system(size: max(12, size * 0.5), weight: .medium)) + .foregroundStyle(Theme.ink2) } .lineLimit(1) .minimumScaleFactor(0.6) } } -/// "⚡ 12,40 kr", or "−4,20 kr" in the grid's colour when the grid paid it. +/// "−4,20 kr", in the grid's colour when the grid paid it. struct CostFigure: View { let moment: GridGlance.Moment let size: CGFloat var body: some View { HStack(alignment: .firstTextBaseline, spacing: 3) { - Image(systemName: GridGlance.symbol) - .font(.system(size: size * 0.6, weight: .semibold)) - .foregroundStyle(Tone.accent) - .widgetAccentable() Text(moment.costText ?? "–") - .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) - .foregroundStyle(moment.earned ? AnyShapeStyle(Theme.grid) : AnyShapeStyle(.primary)) + .font(Theme.figure(size)) + .foregroundStyle(moment.earned ? Theme.grid : Theme.ink) .contentTransition(.numericText()) if moment.costText != nil { Text(moment.costUnit) - .font(.caption) - .foregroundStyle(.secondary) + .font(.system(size: max(11, size * 0.6), weight: .medium)) + .foregroundStyle(Theme.ink2) } } .lineLimit(1) @@ -189,29 +176,35 @@ struct CostFigure: View { } } -/// "in 6,5 · ut 8,3 kWh": both sides, always, so net is never read as the -/// bill. -struct InOutLine: View { +/// "sålt netto · in 6,5 · ut 8,3 kWh": which way in the grid's colour, +/// then both sides, always, so net is never read as the bill. +struct NetLine: View { let moment: GridGlance.Moment var body: some View { - Text(moment.inOutText ?? "") - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.75) + Group { + if let word = moment.netWord, let inOut = moment.inOutText { + Text("\(Text(word).font(.caption.weight(.semibold)).foregroundStyle(Theme.grid))\(Text(verbatim: " · ").foregroundStyle(Theme.ink3))\(Text(inOut).foregroundStyle(Theme.ink2))") + } else { + Text(moment.inOutText ?? "").foregroundStyle(Theme.ink2) + } + } + .font(.caption2) + .lineLimit(1) + .minimumScaleFactor(0.7) } } -/// "delvis · kl 16:55": what time the figures are from. +/// "delvis · kl 16:55": what time the figures are from, in the terminal +/// layer. A widget always says when, and never glows. struct TimeLine: View { let moment: GridGlance.Moment let withNote: Bool var body: some View { Text((withNote ? [moment.costNote, moment.timeText] : [moment.timeText]).compactMap { $0 }.joined(separator: " · ")) - .font(.caption2) - .foregroundStyle(.tertiary) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Theme.ink3) .lineLimit(1) .minimumScaleFactor(0.8) } @@ -223,22 +216,15 @@ struct GridEmpty: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 3) { - Image(systemName: GridGlance.symbol) - .imageScale(.small) - .foregroundStyle(Tone.accent) - .widgetAccentable() - Text(GridGlance.title()).foregroundStyle(.secondary) - } - .font(.caption) + Kicker(text: GridGlance.title()) Spacer(minLength: 4) Text(moment.emptyText ?? "") .font(.footnote.weight(.medium)) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.ink2) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) if let time = moment.timeText { - Text(time).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + Text(time).font(.system(size: 10, design: .monospaced)).foregroundStyle(Theme.ink3).lineLimit(1) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -246,74 +232,128 @@ struct GridEmpty: View { } } -/// The day on a fixed 00–24 axis: a bar for each quarter hour that bought, -/// a curve with a soft fill below zero for what was sold, nothing for the -/// hours still to come, and a break wherever the box has no record. Both -/// are the grid's magenta; the side of zero says which way. +/// The day on a fixed 00–24 axis, as the app draws the grid's Today: one +/// signed line through the slot means with its body, buying above zero and +/// selling below, both in the grid's magenta, and on the medium widget the +/// band of the quarters each slot holds. The line stops where the read +/// does; a hole the box has no record for breaks it and shows as a dotted +/// baseline. Still paths, no glow: a widget is never live. /// -/// Tinted and clear home screens draw in one colour: there the bars are -/// solid and the curve and its fill fainter, and zero tells them apart. +/// Tinted and clear home screens draw in one colour: the line in primary at +/// 0.7, the body fainter, no band. struct GridDayChart: View { let moment: GridGlance.Moment - /// The hour and power labels; the small widget has no room for them. + /// The hours, the scale and the side words; the small widget has no + /// room for them. let labels: Bool @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.colorScheme) private var scheme + @Environment(\.displayScale) private var displayScale var body: some View { - let full = renderingMode == .fullColor - let inset = GridTodayEntry.quarterMs * 0.12 - Chart { - RuleMark(y: .value("Zero", 0)) - .foregroundStyle(Color.secondary.opacity(0.5)) - .lineStyle(StrokeStyle(lineWidth: 0.5)) - ForEach(moment.bars) { bar in - RectangleMark( - xStart: .value("From", date(bar.startMs + inset)), - xEnd: .value("To", date(bar.endMs - inset)), - yStart: .value("Zero", 0.0), - yEnd: .value("Bought", bar.watts) - ) - .foregroundStyle(full ? AnyShapeStyle(Theme.grid) : AnyShapeStyle(.primary)) - .cornerRadius(1) - } - ForEach(moment.curve) { p in - AreaMark(x: .value("Time", date(p.t)), yStart: .value("Sold", p.watts), yEnd: .value("Zero", 0.0), series: .value("Run", "\(p.run)-")) - .interpolationMethod(.monotone) - .foregroundStyle(full ? AnyShapeStyle(Theme.grid.opacity(0.22)) : AnyShapeStyle(.primary.opacity(0.18))) - LineMark(x: .value("Time", date(p.t)), y: .value("Sold", p.watts), series: .value("Run", "\(p.run)")) - .interpolationMethod(.monotone) - .foregroundStyle(full ? AnyShapeStyle(Theme.grid) : AnyShapeStyle(.primary.opacity(0.7))) - .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)) - } - } - .chartXScale(domain: date(moment.dayStartMs)...date(moment.dayEndMs)) - .chartYScale(domain: moment.yBottom...moment.yTop) - .chartXAxis { - AxisMarks(values: labels ? moment.xTicks.map { date($0.value) } : []) { value in - AxisValueLabel(anchor: anchor(value.index)) { - Text(moment.xTicks[value.index].label) + GeometryReader { geo in + let frame = labels ? ChartFrame(top: 0, bottom: 14, trailing: 30) : ChartFrame(top: 0, bottom: 0, trailing: 0) + let plot = frame.plot(in: geo.size) + let series = moment.series(size: plot.size) + let base = YMap(domain: series.domain, height: plot.height).baseline + let px = 1 / max(displayScale, 1) + let full = renderingMode == .fullColor + let color: Color = full ? Theme.grid : .primary + let dark = scheme == .dark + let shift = CGAffineTransform(translationX: plot.minX, y: plot.minY) + let above = Path(CGRect(x: plot.minX, y: plot.minY - 4, width: plot.width, height: base + 4)) + let below = Path(CGRect(x: plot.minX, y: plot.minY + base, width: plot.width, height: plot.height - base + 4)) + let body = Path(series.body.cgPath).applying(shift) + let peak = full ? (dark ? 0.22 : 0.16) : 0.18 + + ZStack(alignment: .topLeading) { + // The body: nothing at zero, `peak` at the scale's edge, on + // each side. + body.fill(LinearGradient(colors: [color.opacity(0), color.opacity(peak)], startPoint: UnitPoint(x: 0, y: base / geo.size.height), endPoint: UnitPoint(x: 0, y: plot.minY / geo.size.height))) + .clipShape(above) + body.fill(LinearGradient(colors: [color.opacity(0), color.opacity(peak)], startPoint: UnitPoint(x: 0, y: (plot.minY + base) / geo.size.height), endPoint: UnitPoint(x: 0, y: plot.maxY / geo.size.height))) + .clipShape(below) + if full, labels { + Path(series.band.cgPath).applying(shift).fill(color.opacity(dark ? 0.14 : 0.10)) } - .font(.system(size: 9).monospacedDigit()) - .foregroundStyle(Color.secondary) - } - } - .chartYAxis { - AxisMarks(position: .trailing, values: labels ? moment.yTicks.map(\.value) : []) { value in - AxisValueLabel { - Text(moment.yTicks[value.index].label) + zero(plot: plot, base: base, px: px) + .stroke(Theme.ink.opacity(full ? 0.35 : 0.3), style: StrokeStyle(lineWidth: px)) + missing(series, plot: plot, base: base) + .stroke(Theme.ink.opacity(0.35), style: StrokeStyle(lineWidth: px, dash: [1, 3])) + Path(series.line.cgPath).applying(shift) + .stroke(color.opacity(full ? 1 : 0.7), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .widgetAccentable() + if labels { + Axes(moment: moment, series: series, plot: plot, size: geo.size, base: base, full: full) } - .font(.system(size: 9).monospacedDigit()) - .foregroundStyle(Color.secondary) } } - .widgetAccentable() .accessibilityHidden(true) } - /// The first and last hour labels sit inside the plot, not off its ends. - private func anchor(_ index: Int) -> UnitPoint { - index == 0 ? .topLeading : (index == moment.xTicks.count - 1 ? .topTrailing : .top) + private func zero(plot: CGRect, base: CGFloat, px: CGFloat) -> Path { + let y = plot.minY + (base / px).rounded() * px + px / 2 + var p = Path() + p.move(to: CGPoint(x: plot.minX, y: y)) + p.addLine(to: CGPoint(x: plot.maxX, y: y)) + return p + } + + /// A dotted baseline across each hole the box has no record for. + private func missing(_ series: ChartSeries, plot: CGRect, base: CGFloat) -> Path { + var p = Path() + for r in series.missing { + p.move(to: CGPoint(x: plot.minX + series.grid.x(atMs: series.grid.startMs(r.lowerBound), width: plot.width), y: plot.minY + base)) + p.addLine(to: CGPoint(x: plot.minX + series.grid.x(atMs: series.grid.endMs(r.upperBound), width: plot.width), y: plot.minY + base)) + } + return p } +} - private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } +/// The chart's words in SF Mono 9 ink3, as the app's axes: the hours under +/// it, the scale's ends in the right gutter as magnitudes, and the side +/// words where the scale has that side. +private struct Axes: View { + let moment: GridGlance.Moment + let series: ChartSeries + let plot: CGRect + let size: CGSize + let base: CGFloat + let full: Bool + + var body: some View { + let map = YMap(domain: series.domain, height: plot.height) + ZStack(alignment: .topLeading) { + ForEach(Array(moment.xTicks.enumerated()), id: \.offset) { i, tick in + label(tick.label) + .position(x: min(max(plot.minX + series.grid.x(atMs: tick.value, width: plot.width), plot.minX + 6), plot.maxX - 6), y: size.height - 5) + } + ForEach([series.domain.upper, series.domain.lower].filter { $0 != 0 }, id: \.self) { v in + label(PowerFormat.scale(abs(v))) + .position(x: plot.maxX + 16, y: plot.minY + map.y(v) + (v > 0 ? 5 : -5)) + } + // Each side's word beside its end of the scale, in the corner + // the hours still to come leave empty, where that side has room. + if series.domain.upper > 0, base >= Self.wordRoom { + word(GridGlance.upWord()).offset(x: plot.minX, y: plot.minY) + } + if series.domain.lower < 0, plot.height - base >= Self.wordRoom { + word(GridGlance.downWord()).offset(x: plot.minX, y: plot.maxY - Self.wordRoom + 1) + } + } + } + + /// A side shorter than this has no word: it would sit on zero. + static let wordRoom: CGFloat = 12 + + private func label(_ text: String) -> some View { + Text(verbatim: text) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(full ? AnyShapeStyle(Theme.ink3) : AnyShapeStyle(.secondary)) + .lineLimit(1) + } + + private func word(_ text: String) -> some View { + label(text).frame(width: max(0, plot.width - 3), alignment: .trailing) + } } diff --git a/appleApp/FTWWidgets/Kicker.swift b/appleApp/FTWWidgets/Kicker.swift new file mode 100644 index 0000000..15f9bcc --- /dev/null +++ b/appleApp/FTWWidgets/Kicker.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// A widget's kicker, `PRIS NU` or `NÄTET I DAG`: SF Mono caps in ink2, +/// the terminal layer's label over a figure. +struct Kicker: View { + let text: String + + var body: some View { + Text(text.uppercased()) + .font(.system(size: 10.5, weight: .medium, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(Theme.ink2) + .lineLimit(1) + .minimumScaleFactor(0.8) + } +} From 29ef0e893c1dc35fcaefbcae68dd6c00b0d7bbcb Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 17:36:15 +0200 Subject: [PATCH 3/4] iPhone widgets: the price as blocks, the plan as Now's strip, and AS OF on every one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One palette: the widgets' own Tone table is gone, and every widget, the Live Activity and the Control read the roles in FTWCharts/Theme.xcassets through Theme, as the app does. Backgrounds are the ground, grey (hairline) when there is nothing for the moment. The price: the small widget, StandBy's, adds the next twelve hours as the block-glyph scale, one bar an hour in eight levels by thirds of ink, the hour that holds now in full ink. The lock screen's rectangle writes the same hours in SF Mono, ▁▂▃▄▅▆▇█, under the price, with CHEAPEST and AS OF on one mono line. PriceBlocks (FTWShared) works the hours out for both, from the household's clock. The medium keeps the app's price chart as still paths. Figures are SF Pro with even digits; the kicker, CHEAPEST and AS OF are the terminal layer. The plan: the medium draws the next twelve hours as Now's strip does, in still paths in place of Swift Charts: the car and the battery as 6 pt lanes of runs (violet with a tick at the car's goal, cyan storing, gold giving), an idle lane as a faint hairline, the price as bars in eight levels by thirds, mono hours and a now rule. Every plan widget ends on PLANNED 17:21, or PLAN FROM 14:05 once old. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWShared/PriceBlocks.swift | 59 ++++ .../Sources/FTWShared/PriceGlance.swift | 18 +- .../Resources/sv.lproj/Localizable.strings | 1 + .../FTWSharedTests/PriceGlanceTests.swift | 41 +++ appleApp/FTWWidgets/Plan/PlanViews.swift | 278 +++++++++++------- appleApp/FTWWidgets/PriceViews.swift | 183 ++++++++---- 6 files changed, 401 insertions(+), 179 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWShared/PriceBlocks.swift diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceBlocks.swift b/appleApp/FTWKit/Sources/FTWShared/PriceBlocks.swift new file mode 100644 index 0000000..5a5fa8c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceBlocks.swift @@ -0,0 +1,59 @@ +import Foundation + +/// The block-glyph scale, ▁▂▃▄▅▆▇█: a price as one of eight levels between +/// the lowest and the highest in view, and its ink by thirds of that range. +/// The small price widget draws it as bars and the lock screen writes it in +/// SF Mono, from the same hours, so the two always agree. +public enum PriceBlocks { + public static let glyphs: [Character] = Array("▁▂▃▄▅▆▇█") + public static let hourMs: Double = 3_600_000 + + /// One hour of the strip: its mean price, its level and its third. + public struct Hour: Equatable, Sendable { + public let startMs: Double + /// The mean of the slots that fall in this hour, minor units per kWh. + public let minor: Double + /// 1 to 8. + public let level: Int + public let third: PriceChart.Third + /// The hour that holds now. + public let current: Bool + } + + /// The next `count` hours on the household's clock, from the one that + /// holds `nowMs`, each the mean of the slots in it. An hour with no slot + /// ends the strip: nothing is drawn where the market has not priced. + public static func hours(_ bars: [PriceStrip.Bar], nowMs: Double, count: Int, clock: ZoneClock) -> [Hour] { + let first = clock.alignDown(nowMs, stepMs: hourMs) + var sums = [Double](repeating: 0, count: count), held = [Int](repeating: 0, count: count) + for b in bars where b.endMs > nowMs { + let k = Int(((max(b.startMs, first) - first) / hourMs).rounded(.down)) + guard k >= 0, k < count else { continue } + sums[k] += b.minor + held[k] += 1 + } + let n = held.firstIndex(of: 0) ?? count + let means = (0.. [Int] { + let lo = values.min() ?? 0, hi = values.max() ?? 0 + return values.map { v in hi > lo ? 1 + min(7, Int(((v - lo) / (hi - lo) * 8).rounded(.down))) : 4 } + } + + /// The cheap, middle or dear third of the range; middle when flat. + public static func thirds(_ values: [Double]) -> [PriceChart.Third] { + let lo = values.min() ?? 0, hi = values.max() ?? 0 + let third = (hi - lo) / 3 + return values.map { v in hi > lo ? (v < lo + third ? .cheap : v >= lo + 2 * third ? .dear : .middle) : .middle } + } + + /// "▃▃▂▁▁▂▅▇█▆▅▄": one glyph per hour. + public static func text(_ hours: [Hour]) -> String { + String(hours.map { glyphs[max(0, min(glyphs.count - 1, $0.level - 1))] }) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift index 233fae3..e25442e 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift @@ -11,6 +11,8 @@ import Foundation public enum PriceGlance { /// How far ahead the medium widget's curve reaches. public static let curveMs: Double = 12 * 3_600_000 + /// The block strip's hours, on the small widget and the lock screen. + public static let stripHours = 12 /// What the box's total holds besides the market price. The wire has /// spot and total but not the settings between them, so this is read off @@ -50,6 +52,9 @@ public enum PriceGlance { public let cheapest: PriceStrip.Block? /// Slots from the current one to `curveMs` ahead. public let curve: [PriceStrip.Bar] + /// The next twelve hours as the block-glyph scale, from the hour + /// that holds this moment. + public let hours: [PriceBlocks.Hour] public let meanMinor: Double? public let included: Included @@ -69,12 +74,18 @@ public enum PriceGlance { public let emptyText: String? /// "Fetched 13:05" public let fetchedText: String? + /// "AS OF 13:05": when the app fetched these prices, in the + /// terminal's caps. A widget always says it. + public let asOfText: String? /// The zone the clock in this moment is read in. public let timeZone: TimeZone /// Whether the widget should go grey: no price for this moment. public var isEmpty: Bool { state != .price } + /// "▃▃▂▁▁▂▅▇█▆▅▄" + public var glyphs: String { PriceBlocks.text(hours) } + /// One line for the lock screen's inline slot, which shares its /// width with the date. Without a price it only sends the reader to /// the app; the other families say when the prices ended. @@ -108,9 +119,9 @@ public enum PriceGlance { guard let read else { return Moment( atMs: t, state: .nothing, carrier: .none, srcState: .never, readAtMs: nil, - currency: "", current: nil, cheapest: nil, curve: [], meanMinor: nil, included: .spotOnly, + currency: "", current: nil, cheapest: nil, curve: [], hours: [], meanMinor: nil, included: .spotOnly, priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, cheapestStartText: nil, - emptyText: GlanceWord.openToSee.text, fetchedText: nil, timeZone: calendar.timeZone + emptyText: GlanceWord.openToSee.text, fetchedText: nil, asOfText: nil, timeZone: calendar.timeZone ) } let slots = usable(read.value.prices) @@ -161,6 +172,7 @@ public enum PriceGlance { current: current, cheapest: cheapest, curve: curve, + hours: PriceBlocks.hours(curve, nowMs: t, count: stripHours, clock: ZoneClock.of(calendar.timeZone)), meanMinor: mean, included: included, priceText: current.map { Words.decimal(PriceStrip.text($0.totalMinor, prices.currency)) }, @@ -171,6 +183,7 @@ public enum PriceGlance { cheapestStartText: cheapest.map { clock.cheapestStart($0, from: t) }, emptyText: emptyText, fetchedText: GlanceWord.fetched.format(clock.at(read.record.readAtMs, asEnd: false, from: t)), + asOfText: GlanceWord.asOf.format(clock.at(read.record.readAtMs, asEnd: false, from: t).uppercased()), timeZone: calendar.timeZone ) } @@ -237,6 +250,7 @@ enum GlanceWord: String, CaseIterable { case openToSee = "Open FTW to see prices" case noPricesShort = "No prices · open FTW" case fetched = "Fetched %@" + case asOf = "AS OF %@" case inclGridFeeAndVAT = "incl. grid fee and VAT" case inclVAT = "incl. VAT" case inclGridFee = "incl. grid fee" diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 7c07802..aff8804 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -47,6 +47,7 @@ "Open FTW to see prices" = "Öppna FTW för att se priser"; "No prices · open FTW" = "Inga priser · öppna FTW"; "Fetched %@" = "Hämtat %@"; +"AS OF %@" = "PER %@"; "incl. grid fee and VAT" = "inkl. nätavgift och moms"; "incl. VAT" = "inkl. moms"; "incl. grid fee" = "inkl. nätavgift"; diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift index df2cb40..7b5a72b 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift @@ -258,6 +258,47 @@ import Testing /// Every sentence a widget shows has a Swedish line with the same /// placeholders. + // MARK: The block strip + + /// Twelve hours from the one that holds now, each the mean of its + /// slots, as one of eight levels and a third of the range: the small + /// widget's bars and the lock screen's glyphs. + @Test func theStripIsTwelveHoursOfBlocks() { + let quarter = Self.hourMs / 4 + // Quarters: hour h costs 10 × h, so the strip climbs one step a hour. + let r = read(slots(from: at(12, 0), count: 96, dtMs: quarter) { i in Double(10 * (i / 4)) }, readAtMs: at(12, 13, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 6, 40), calendar: calendar) + #expect(m.hours.count == 12) + #expect(m.hours.first?.startMs == at(12, 6)) + #expect(m.hours.first?.current == true) + #expect(m.hours.dropFirst().allSatisfy { !$0.current }) + #expect(m.hours.map(\.minor) == (6..<18).map { Double(10 * $0) }) + #expect(m.hours.first?.level == 1 && m.hours.last?.level == 8) + #expect(m.hours.first?.third == .cheap && m.hours.last?.third == .dear) + #expect(m.glyphs.count == 12) + #expect(m.glyphs.first == "▁" && m.glyphs.last == "█") + // Where the market has not priced, the strip ends. + let late = PriceGlance.moment(r, atMs: at(12, 17), calendar: calendar) + #expect(late.hours.count == 7) + #expect(late.glyphs.count == 7) + } + + /// A flat day is the middle of the scale, not its floor. + @Test func aFlatDayIsTheMiddleBlock() { + #expect(PriceBlocks.levels([50, 50, 50]) == [4, 4, 4]) + #expect(PriceBlocks.thirds([50, 50]) == [.middle, .middle]) + #expect(PriceBlocks.levels([0, 100]) == [1, 8]) + #expect(PriceBlocks.levels([-10, 0, 10]) == [1, 5, 8]) + } + + /// Every widget says when the app fetched what it shows. + @Test func everyMomentSaysAsOf() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).asOfText == "AS OF 13:05") + #expect(PriceGlance.moment(r, atMs: at(13, 9), calendar: calendar).asOfText == "AS OF MON 13:05") + #expect(PriceGlance.moment(nil, atMs: at(12, 14), calendar: calendar).asOfText == nil) + } + @Test func everyWidgetWordHasSwedish() 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]) diff --git a/appleApp/FTWWidgets/Plan/PlanViews.swift b/appleApp/FTWWidgets/Plan/PlanViews.swift index a5ebe95..54941c9 100644 --- a/appleApp/FTWWidgets/Plan/PlanViews.swift +++ b/appleApp/FTWWidgets/Plan/PlanViews.swift @@ -1,4 +1,3 @@ -import Charts import FTWShared import SwiftUI import WidgetKit @@ -18,7 +17,7 @@ struct PlanWidgetView: View { private var background: AnyShapeStyle { switch family { case .accessoryRectangular, .accessoryInline, .accessoryCircular: return AnyShapeStyle(.clear) - default: return AnyShapeStyle(moment.isGrey ? Tone.empty : Tone.surface) + default: return AnyShapeStyle(moment.isGrey ? Theme.hairline : Theme.ground) } } @@ -37,16 +36,16 @@ struct SmallPlan: View { var body: some View { VStack(alignment: .leading, spacing: 2) { - PlanHeader() + Kicker(text: PlanGlance.title) if let when = moment.nextWhen, let what = moment.nextWhat { - NextEvent(when: when, what: what, until: moment.nextUntil, kind: moment.next?.kind) + NextEvent(when: when, what: what, until: moment.nextUntil, kind: moment.next?.kind, grey: moment.isGrey) Spacer(minLength: 2) CheapLine(moment: moment) } else { Spacer(minLength: 4) Text(moment.summary) .font(.footnote.weight(.medium)) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.ink2) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) } @@ -57,33 +56,34 @@ struct SmallPlan: View { } } -/// The next thing on the left, the next twelve hours on the right. +/// The next thing on the left, the next twelve hours on the right, as Now's +/// strip draws them. struct MediumPlan: View { let moment: PlanGlance.Moment var body: some View { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: 14) { VStack(alignment: .leading, spacing: 2) { - PlanHeader() + Kicker(text: PlanGlance.title) if let when = moment.nextWhen, let what = moment.nextWhat { - NextEvent(when: when, what: what, until: moment.nextUntil, kind: moment.next?.kind) + NextEvent(when: when, what: what, until: moment.nextUntil, kind: moment.next?.kind, grey: moment.isGrey) } else { Text(moment.summary) .font(.footnote.weight(.medium)) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.ink2) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 2) Age(moment: moment) } - .frame(width: 128, alignment: .leading) + .frame(width: 124, alignment: .leading) .accessibilityElement(children: .combine) - VStack(alignment: .leading, spacing: 4) { - WidgetLanes(moment: moment) + VStack(alignment: .leading, spacing: 6) { + PlanLanes(moment: moment) if let later = moment.laterText { Text(later) .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.ink2) .lineLimit(2) .minimumScaleFactor(0.85) } @@ -93,15 +93,16 @@ struct MediumPlan: View { } } -/// The lock screen's rectangle: what happens next, until when, and the -/// cheap hours. +/// The lock screen's rectangle: what happens next, until when, the cheap +/// hours, and when the box planned it. struct RectangularPlan: View { + static let symbol = "calendar.day.timeline.left" let moment: PlanGlance.Moment var body: some View { VStack(alignment: .leading, spacing: 0) { if let when = moment.nextWhen, let what = moment.nextWhat { - Text("\(Image(systemName: PlanHeader.symbol)) \(Text(when).font(.headline)) \(what)") + Text("\(Text(when).font(.headline.monospacedDigit())) \(what)") .font(.caption.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.75) @@ -110,13 +111,18 @@ struct RectangularPlan: View { .font(.caption) .lineLimit(1) .minimumScaleFactor(0.75) - Age(moment: moment) } else { - Text("\(Image(systemName: PlanHeader.symbol)) \(PlanGlance.title)") + Text("\(Image(systemName: Self.symbol)) \(PlanGlance.title)") .font(.caption.weight(.semibold)) .widgetAccentable() Text(moment.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2) - Age(moment: moment) + } + if let age = moment.ageText { + Text(age.uppercased()) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -126,159 +132,203 @@ struct RectangularPlan: View { // MARK: Parts -struct PlanHeader: View { - static let symbol = "calendar.day.timeline.left" - - var body: some View { - HStack(spacing: 3) { - Image(systemName: Self.symbol) - .imageScale(.small) - .foregroundStyle(Tone.accent) - .widgetAccentable() - Text(PlanGlance.title).foregroundStyle(.secondary) - } - .font(.caption) - } -} - -/// "Now" or "02:00", large; what happens; until when. +/// "Now" or "02:00", large, in the colour of what happens; what; until +/// when. An old plan says it in ink: it is no longer a plan for now. struct NextEvent: View { let when: String let what: String let until: String? let kind: PlanGlance.Kind? + var grey = false var body: some View { Text(when) - .font(.system(size: 28, weight: .semibold, design: .rounded).monospacedDigit()) - .foregroundStyle(kind.map { Tone.of($0) } ?? Tone.accent) + .font(Theme.figure(28)) + .tracking(-0.5) + .foregroundStyle(grey ? Theme.ink2 : kind.map(Self.color) ?? Theme.ink) .lineLimit(1) .minimumScaleFactor(0.6) .widgetAccentable() Text(what) .font(.subheadline.weight(.semibold)) + .foregroundStyle(Theme.ink) .lineLimit(2) .minimumScaleFactor(0.8) .fixedSize(horizontal: false, vertical: true) if let until { - Text(until).font(.caption).foregroundStyle(.secondary).lineLimit(1) + Text(until).font(.caption).foregroundStyle(Theme.ink2).lineLimit(1) + } + } + + static func color(_ kind: PlanGlance.Kind) -> Color { + switch kind { + case .car: return Theme.car + case .charge: return Theme.stores + case .discharge: return Theme.gives + case .cheap: return Theme.ink } } } -/// "cheapest 02–04", when the hours ahead have a cheap stretch. +/// "billigast 02–04", when the hours ahead have a cheap stretch. struct CheapLine: View { let moment: PlanGlance.Moment var body: some View { if let text = moment.cheapText { - Text(text) - .font(.caption.weight(.semibold)) - .foregroundStyle(Tone.cheap) + Text(text.uppercased()) + .font(.system(size: 10.5, weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.ink2) .lineLimit(1) .minimumScaleFactor(0.75) } } } -/// When the box made the plan; once it is old, that it is. +/// `PLANERAD 14:05`, or once old `PLAN FRÅN 14:05 · ÖPPNA FTW`: a widget +/// always says when, in the terminal layer, and never glows. struct Age: View { let moment: PlanGlance.Moment var body: some View { if let text = moment.ageText { - Text(text) - .font(.caption2) - .foregroundStyle(moment.state == .old ? .secondary : .tertiary) + Text(text.uppercased()) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(moment.state == .old ? Theme.ink2 : Theme.ink3) .lineLimit(1) - .minimumScaleFactor(0.8) + .minimumScaleFactor(0.7) } } } -/// The next twelve hours as lanes: the car on top, the battery under it, -/// the cheapest hours as a thin mark, and the prices as a low strip along -/// the bottom. On tinted and -/// clear home screens it draws in one colour, the lanes solid and the -/// prices faint. -struct WidgetLanes: View { +/// The next twelve hours as Now's strip draws them, in still paths: the car +/// and the battery as 6 pt lanes of merged runs (violet for the car with a +/// tick at its goal, cyan where the battery stores and gold where it +/// gives), an idle lane as a faint hairline, the price as bars in eight +/// levels by thirds, the hours under it, and now as a rule at the left. +/// +/// Tinted and clear home screens draw in one colour: the runs solid and +/// marked for the accent, the prices faint. +struct PlanLanes: View { let moment: PlanGlance.Moment @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.displayScale) private var displayScale + + static let lane: CGFloat = 6 + static let laneGap: CGFloat = 5 + static let strip: CGFloat = 20 var body: some View { - let from = moment.atMs - let to = from + PlanGlance.horizonMs - let top = max(1, moment.backdrop.map(\.minor).max() ?? 1) - let full = renderingMode == .fullColor - Chart { - ForEach(moment.backdrop) { bar in - RectangleMark( - xStart: .value("From", date(max(from, bar.startMs))), - xEnd: .value("To", date(min(to, bar.endMs))), - yStart: .value("Bottom", 0.0), - yEnd: .value("Price", Self.priceFloor + Self.priceRise * max(0, bar.minor) / top) - ) - .foregroundStyle(full ? AnyShapeStyle(moment.isGrey ? Tone.flat : Tone.of(bar.tone)) : AnyShapeStyle(.primary)) - .opacity(0.4) - } - ForEach(moment.events) { e in - let lane = Self.lane(e.kind) - RectangleMark( - xStart: .value("From", date(max(from, e.startMs))), - xEnd: .value("To", date(min(to, e.endMs))), - yStart: .value("Low", lane.lowerBound), - yEnd: .value("High", lane.upperBound) - ) - .foregroundStyle(full ? AnyShapeStyle(moment.isGrey ? Tone.flat : Tone.of(e.kind)) : AnyShapeStyle(.primary)) - .opacity(moment.isGrey ? 0.45 : 1) - .cornerRadius(4) - } - } - .chartXScale(domain: date(from)...date(to)) - .chartYScale(domain: 0...1) - .chartYAxis(.hidden) - .chartXAxis { - AxisMarks(preset: .aligned, values: .stride(by: .hour, count: 3)) { value in - AxisValueLabel { - if let d = value.as(Date.self) { - // The household's hour, as the rest of the widget reads it. - Text(String(format: "%02d", Self.hour(d, in: moment.timeZone))) - } + GeometryReader { geo in + let full = renderingMode == .fullColor + let fade = moment.isGrey ? 0.45 : 1.0 + let px = 1 / max(displayScale, 1) + let w = geo.size.width + let from = moment.atMs, span = PlanGlance.horizonMs + let x: (Double) -> CGFloat = { CGFloat((min(max($0, from), from + span) - from) / span) * w } + let carY: CGFloat = 0 + let batteryY = Self.lane + Self.laneGap + let stripTop = batteryY + Self.lane + 8 + let ink: Color = full ? Theme.ink : .primary + + ZStack(alignment: .topLeading) { + Self.idle(y: carY + Self.lane / 2, width: w, px: px).stroke(ink.opacity(0.08), lineWidth: px) + Self.idle(y: batteryY + Self.lane / 2, width: w, px: px).stroke(ink.opacity(0.08), lineWidth: px) + ForEach(lanes(x: x, carY: carY, batteryY: batteryY)) { run in + run.path.fill(full ? run.color : Color.primary) + .opacity(fade) + .widgetAccentable() + } + prices(x: x, top: stripTop, width: w, px: px, full: full) + .opacity(fade) + Rectangle() + .fill(ink) + .frame(width: px, height: stripTop + Self.strip) + ForEach(hours(x: x), id: \.ms) { tick in + Text(verbatim: tick.label) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(full ? AnyShapeStyle(Theme.ink3) : AnyShapeStyle(.secondary)) + .fixedSize() + .position(x: min(max(tick.x, 8), w - 8), y: stripTop + Self.strip + 9) } } } + .frame(height: Self.lane * 2 + Self.laneGap + 8 + Self.strip + 16) .accessibilityHidden(true) } - static func hour(_ d: Date, in zone: TimeZone) -> Int { - var c = Calendar.current - c.timeZone = zone - return c.component(.hour, from: d) + struct Run: Identifiable { + let id: String + let path: Path + let color: Color } - static let priceFloor = 0.03 - static let priceRise = 0.15 - - static func lane(_ kind: PlanGlance.Kind) -> ClosedRange { - switch kind { - case .car: return 0.68...0.96 - case .charge, .discharge: return 0.34...0.62 - case .cheap: return 0.23...0.27 + /// One capsule per event, radius min(3, w/2); the car's goal as a 2 pt + /// tick at its end. + private func lanes(x: (Double) -> CGFloat, carY: CGFloat, batteryY: CGFloat) -> [Run] { + moment.events.compactMap { e in + let y: CGFloat + let color: Color + switch e.kind { + case .car: (y, color) = (carY, Theme.car) + case .charge: (y, color) = (batteryY, Theme.stores) + case .discharge: (y, color) = (batteryY, Theme.gives) + case .cheap: return nil + } + let x0 = x(e.startMs), x1 = x(e.endMs) + guard x1 - x0 > 0.5 else { return nil } + let rect = CGRect(x: x0, y: y, width: x1 - x0, height: Self.lane) + var path = Path(roundedRect: rect, cornerRadius: min(3, rect.width / 2), style: .continuous) + if e.kind == .car, e.targetPct != nil { + path.addRect(CGRect(x: max(x0, x1 - 2), y: y - 2, width: 2, height: Self.lane + 4)) + } + return Run(id: e.id, path: path, color: color) } } - private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } -} + /// The prices under the plan as Now's strip draws them: a bar per slot, + /// slot width less 1 px, its height one of eight levels and its ink by + /// thirds of the window. No red and no green. + private func prices(x: (Double) -> CGFloat, top: CGFloat, width: CGFloat, px: CGFloat, full: Bool) -> some View { + let bars = moment.backdrop + let minor = bars.map(\.minor) + let levels = PriceBlocks.levels(minor), thirds = PriceBlocks.thirds(minor) + var paths: [PriceChart.Third: Path] = [.cheap: Path(), .middle: Path(), .dear: Path()] + for (i, b) in bars.enumerated() { + let x0 = x(b.startMs), x1 = x(b.endMs) + guard x1 - x0 > px else { continue } + let h = Self.strip * CGFloat(levels[i]) / 8 + paths[thirds[i]]?.addRect(CGRect(x: x0 + px, y: top + Self.strip - h, width: max(px, x1 - x0 - px), height: h)) + } + let ink: Color = full ? Theme.ink : .primary + return ZStack(alignment: .topLeading) { + paths[.cheap]!.fill(ink.opacity(full ? 0.32 : 0.2)) + paths[.middle]!.fill(ink.opacity(full ? 0.5 : 0.3)) + paths[.dear]!.fill(ink.opacity(full ? 0.8 : 0.45)) + } + } -extension Tone { - static func of(_ kind: PlanGlance.Kind) -> Color { - switch kind { - case .car: return Theme.car - case .charge: return Theme.stores - case .discharge: return Theme.gives - case .cheap: return Theme.ink2 + /// Local hours on three-hour boundaries, kept clear of now. + private func hours(x: (Double) -> CGFloat) -> [(ms: Double, x: CGFloat, label: String)] { + let clock = ZoneClock.of(moment.timeZone) + let hour = PriceBlocks.hourMs + var out: [(ms: Double, x: CGFloat, label: String)] = [] + var t = clock.alignDown(moment.atMs, stepMs: hour) + hour + while t < moment.atMs + PlanGlance.horizonMs - hour / 2 { + let minute = clock.minuteOfDay(t) + if minute % 180 == 0, t - moment.atMs >= hour { + out.append((t, x(t), String(clock.label(t).prefix(2)))) + } + t += hour } + return out } - static func of(_ tone: PriceStrip.Tone) -> Color { Theme.price(tone) } + private static func idle(y: CGFloat, width: CGFloat, px: CGFloat) -> Path { + var p = Path() + let snapped = (y / px).rounded() * px + px / 2 + p.move(to: CGPoint(x: 0, y: snapped)) + p.addLine(to: CGPoint(x: width, y: snapped)) + return p + } } diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift index 3c627c6..8a32de1 100644 --- a/appleApp/FTWWidgets/PriceViews.swift +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -18,7 +18,7 @@ struct PriceWidgetView: View { private var background: AnyShapeStyle { switch family { case .accessoryRectangular, .accessoryInline, .accessoryCircular: return AnyShapeStyle(.clear) - default: return AnyShapeStyle(moment.isEmpty ? Tone.empty : Tone.surface) + default: return AnyShapeStyle(moment.isEmpty ? Theme.hairline : Theme.ground) } } @@ -26,26 +26,29 @@ struct PriceWidgetView: View { switch family { case .systemMedium: MediumPrice(moment: moment) case .accessoryRectangular: RectangularPrice(moment: moment) - case .accessoryInline: Label(moment.inlineText, systemImage: Header.symbol) + case .accessoryInline: Label(moment.inlineText, systemImage: RectangularPrice.symbol) default: SmallPrice(moment: moment) } } } -/// The home screen's small widget, and StandBy's. +/// The home screen's small widget, and StandBy's: the price now, the next +/// twelve hours as blocks, the cheapest two hours and when it was fetched. struct SmallPrice: View { let moment: PriceGlance.Moment var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 1) { if let price = moment.priceText { - Header(text: PriceGlance.priceNow) - PriceFigure(price: price, size: 38) - Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) - Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.8) + Kicker(text: PriceGlance.priceNow) + PriceFigure(price: price, size: 36) + Units(moment: moment, lines: 1) Spacer(minLength: 4) + BlockStrip(hours: moment.hours) + .frame(height: 18) + .padding(.bottom, 4) Cheapest(moment: moment) - Fetched(moment: moment) + AsOf(moment: moment) } else { Empty(moment: moment) } @@ -55,23 +58,23 @@ struct SmallPrice: View { } } -/// The figures on the left and the next twelve hours on the right. +/// The figures on the left and the next twelve hours on the right, as the +/// app's price chart draws them. struct MediumPrice: View { let moment: PriceGlance.Moment var body: some View { if let price = moment.priceText { HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Header(text: PriceGlance.priceNow) + VStack(alignment: .leading, spacing: 1) { + Kicker(text: PriceGlance.priceNow) PriceFigure(price: price, size: 34) - Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) - Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(2).minimumScaleFactor(0.8) + Units(moment: moment, lines: 2) Spacer(minLength: 4) Cheapest(moment: moment) - Fetched(moment: moment) + AsOf(moment: moment) } - .frame(width: 128, alignment: .leading) + .frame(width: 124, alignment: .leading) .accessibilityElement(children: .combine) Curve(moment: moment) } @@ -83,20 +86,31 @@ struct MediumPrice: View { } } -/// The lock screen's rectangle: price, cheapest, what the price holds. +/// The lock screen's rectangle, in the terminal layer: the price, the next +/// twelve hours as block glyphs (▁▂▃▄▅▆▇█), one per hour from now, and the +/// cheapest two hours with when it was fetched. struct RectangularPrice: View { + static let symbol = "bolt.fill" let moment: PriceGlance.Moment var body: some View { VStack(alignment: .leading, spacing: 0) { if let price = moment.priceText { - Text("\(Image(systemName: Header.symbol)) \(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + Text("\(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + .lineLimit(1) + .minimumScaleFactor(0.75) .contentTransition(.numericText()) .widgetAccentable() - if let cheapest = moment.cheapestText { - Text(cheapest).font(.caption).lineLimit(1).minimumScaleFactor(0.75) - } - Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.7) + Text(verbatim: moment.glyphs) + .font(.system(size: 15, weight: .regular, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .accessibilityHidden(true) + Text([moment.cheapestText, moment.asOfText].compactMap { $0?.uppercased() }.joined(separator: " · ")) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) } else { Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(3) } @@ -108,30 +122,16 @@ struct RectangularPrice: View { // MARK: Parts -/// "⚡ Pris nu": the symbol takes the accent colour, the words stay quiet. -struct Header: View { - static let symbol = "bolt.fill" - let text: String - - var body: some View { - HStack(spacing: 3) { - Image(systemName: Self.symbol) - .imageScale(.small) - .foregroundStyle(Tone.accent) - .widgetAccentable() - Text(text).foregroundStyle(.secondary) - } - .font(.caption) - } -} - +/// "42,3": SF Pro with even digits, as every figure in the app. struct PriceFigure: View { let price: String let size: CGFloat var body: some View { Text(price) - .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .font(Theme.figure(size)) + .tracking(-0.5) + .foregroundStyle(Theme.ink) .contentTransition(.numericText()) .minimumScaleFactor(0.5) .lineLimit(1) @@ -139,14 +139,29 @@ struct PriceFigure: View { } } +/// "öre/kWh · incl. grid fee and VAT", in ink2. +struct Units: View { + let moment: PriceGlance.Moment + let lines: Int + + var body: some View { + Text("\(moment.unitText) · \(moment.includedText)") + .font(.caption2) + .foregroundStyle(Theme.ink2) + .lineLimit(lines) + .minimumScaleFactor(0.75) + } +} + +/// `CHEAPEST 02–04`, in the terminal layer. struct Cheapest: View { let moment: PriceGlance.Moment var body: some View { if let text = moment.cheapestText { - Text(text) - .font(.caption.weight(.semibold)) - .foregroundStyle(Tone.cheap) + Text(text.uppercased()) + .font(.system(size: 10.5, weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.ink) .lineLimit(1) .minimumScaleFactor(0.7) .widgetAccentable() @@ -154,13 +169,18 @@ struct Cheapest: View { } } -/// When the app fetched these prices. A widget only ever shows the cache. -struct Fetched: View { +/// `AS OF 13:05`: when the app fetched these prices. A widget only ever +/// shows the cache, so it always says when, and never glows. +struct AsOf: View { let moment: PriceGlance.Moment var body: some View { - if let text = moment.fetchedText { - Text(text).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + if let text = moment.asOfText { + Text(text) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Theme.ink3) + .lineLimit(1) + .minimumScaleFactor(0.8) } } } @@ -170,14 +190,64 @@ struct Empty: View { let moment: PriceGlance.Moment var body: some View { - Header(text: PriceGlance.title) + Kicker(text: PriceGlance.title) Spacer(minLength: 4) Text(moment.emptyText ?? "") .font(.footnote.weight(.medium)) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.ink2) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) - Fetched(moment: moment) + AsOf(moment: moment) + } +} + +/// The next twelve hours as the block-glyph scale in still bars: one per +/// hour, its height one of eight levels, in ink by thirds, the hour that +/// holds now in full ink. Tinted and clear home screens draw them in one +/// colour; the now bar takes the accent. +struct BlockStrip: View { + let hours: [PriceBlocks.Hour] + @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.displayScale) private var displayScale + + var body: some View { + GeometryReader { geo in + let full = renderingMode == .fullColor + let ink: Color = full ? Theme.ink : .primary + let paths = Self.paths(hours, size: geo.size, scale: displayScale) + ZStack(alignment: .topLeading) { + paths.cheap.fill(ink.opacity(0.32)) + paths.middle.fill(ink.opacity(0.5)) + paths.dear.fill(ink.opacity(0.8)) + paths.now.fill(ink).widgetAccentable() + } + } + .accessibilityHidden(true) + } + + /// Twelve slots across the width with a 2 pt gap, each bar's height a + /// level of eight steps, pixel-snapped. + static func paths(_ hours: [PriceBlocks.Hour], size: CGSize, scale: CGFloat) -> (cheap: Path, middle: Path, dear: Path, now: Path) { + let scale = max(scale, 1) + func snap(_ v: CGFloat) -> CGFloat { (v * scale).rounded() / scale } + var cheap = Path(), middle = Path(), dear = Path(), now = Path() + let slots = CGFloat(PriceGlance.stripHours), gap: CGFloat = 2 + let w = (size.width - gap * (slots - 1)) / slots + for (i, h) in hours.enumerated() { + let height = snap(size.height * CGFloat(h.level) / 8) + let rect = CGRect(x: snap(CGFloat(i) * (w + gap)), y: size.height - height, width: snap(w), height: height) + let bar = Path(roundedRect: rect, cornerRadii: RectangleCornerRadii(topLeading: 1.25, bottomLeading: 0, bottomTrailing: 0, topTrailing: 1.25)) + if h.current { + now.addPath(bar) + } else { + switch h.third { + case .cheap: cheap.addPath(bar) + case .middle: middle.addPath(bar) + case .dear: dear.addPath(bar) + } + } + } + return (cheap, middle, dear, now) } } @@ -199,16 +269,3 @@ struct Curve: View { .accessibilityHidden(true) } } - -/// The roles a widget needs, from the app's own palette -/// (FTWCharts/Theme.xcassets), so a widget never drifts from the app. -enum Tone { - static let surface = Theme.ground - /// Behind a widget with nothing to show. - static let empty = Theme.hairline - static let flat = Theme.ink3 - /// Text and glyphs: the interface is ink. - static let accent = Theme.ink - /// "Cheapest 02–04": ink, as every price is. - static let cheap = Theme.ink -} From 97639e5414ea6e20f157dd44721f326cd85b5c0b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 17:36:22 +0200 Subject: [PATCH 4/4] CLAUDE.md: the widgets' material and the receipt log Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4ea3105..8d19ce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,6 +238,19 @@ The rules below put these into practice. Transparency drops the glow. watchOS compiles no dark appearance and a watch is always dark, so each set also has a `watch` variant that equals its dark one; `ThemeTests` holds them equal. +- **Widgets and the Live Activity** are the app's material, held still. + They read the same roles and draw the same geometry as still paths, + never a Canvas or Swift Charts. They never glow and always say when: + `AS OF 13:05`, `PLANNED 16:45`, `kl 16:55`. Grid today is one signed + line with its body, as the grid's Today. The lock screen writes the price + as block glyphs from `PriceBlocks`. Tinted and clear home screens draw in + `.primary`, with the line or the now bar marked `.widgetAccentable()`. +- **The receipt log** (`ReceiptLog`, drawn by `ReceiptRow`) keeps four + slots on one line and lights each on its own evidence: SENT on the local + send, ACCEPTED on `cmd.ack`, MEASURED once the box has the command and + lane 0 shows what it asked for while live (the car above 42 W for + Charge now), CONFIRMED on `cmd.result`. A refusal takes the last slot in + alarm red, with the box's reason. - The 0.3 names in `FTW/UI/Theme.swift` (`fg`, `surface`, `accent` and the rest) point at these roles until each screen is rebuilt. New code reads the roles.