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. 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/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/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/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/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/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 4357b6f..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"; @@ -73,3 +74,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/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/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/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/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/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/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) + } +} 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) } } 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 -}