Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions appleApp/FTWCharts/ReceiptRow.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
16 changes: 15 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ public final class LoadpointsModel: Activatable {
guard let self, let stamp = self.stamp else { return }
home.chargers = Stamped<Chargers>(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)
Expand Down Expand Up @@ -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))
Comment on lines +478 to +480

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Don't attribute aggregate EV power to one charger

When a home has multiple chargers but only one active Live Activity, Contract.FID.evW is the site-wide car load, while ids.count == 1 only proves that one activity is open. If another charger is already drawing under its plan, any subsequent site observation will therefore mark the active command's MEASURED slot even when its own charger has not started; use per-loadpoint power or require a single charger, not merely a single activity.

Useful? React with 👍 / 👎.

}

/// The Live Activity's home key: the site, or the demo's own.
public var activityHome: String { site.siteId ?? "demo" }

Expand Down
66 changes: 47 additions & 19 deletions appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,23 @@ 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 {
let c = try decoder.container(keyedBy: CodingKeys.self)
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) }
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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<CarEntry>?, nowMs: Double) -> ChargeNowControlState {
guard let read else { return ChargeNowControlState(known: .unknown) }
let cars = read.value.cars
Expand Down
Loading
Loading