From d5033d69d47b7f56e41d938ca84930b6c5162ac3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 16:45:43 +0200 Subject: [PATCH 1/3] iPhone plan dial: today and tomorrow round the sun's path The plan as a round dial, from Fredrik's film (srcfl/sourceful-films, product/ftw-confirmed), in the app's own tokens: - FTWShared/PlanDial: the sun's path (midnight at the bottom, noon at the top, up on the left), a quarter per bar from today's midnight to the end of tomorrow, the battery's action per quarter, the car's quarters, the sky from the box's own solar forecast, the finger's turn across midnight and VoiceOver's hour stops. Worked out once per plan; tested. - FTWCharts/PlanDialView: the drawing, shared by the app and the widgets (later the Watch). Canvas layers with a stable id per quarter: the bars redraw when the plan or the quarter changes, the hand by the minute, the mark as the finger moves. New bars grow in with the charts' spring; still under Reduce Motion and in Low Power Mode. Its own dark glass. - Plan view: the dial at the top in place of the 12-hour lanes. A finger dragged round it scrubs the plan, .selection per quarter, .impact(light) at now and the cheapest window's edges; the line under it says what is planned there. VoiceOver: one adjustable element, an hour a step. - Widget energy.ftw.plan-dial: systemSmall (home screen and StandBy) and accessoryCircular (lock screen: today's ticks, the hand, the battery). - PlanEntry carries the forecast's daylight and a live battery level, both optional (old records still read); the widget's record now holds today and tomorrow. PriceModel says when it is fetching. - The demo plans as far as its prices reach, so tomorrow fills at 13:00. - CLAUDE.md: "Plan dial" under Design references. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 33 ++ appleApp/FTW/UI/ChartMotion.swift | 36 -- appleApp/FTW/UI/PlanDialCard.swift | 235 ++++++++ appleApp/FTW/UI/PlanView.swift | 86 +-- appleApp/FTWCharts/ChartSpring.swift | 38 ++ appleApp/FTWCharts/PlanDialView.swift | 522 ++++++++++++++++++ .../Sources/FTWDemo/SimulatedHistory.swift | 10 +- .../Sources/FTWKit/State/ChartWords.swift | 20 + .../Sources/FTWKit/State/NodeWords.swift | 13 + .../Sources/FTWKit/State/NowModels.swift | 5 + .../Sources/FTWKit/State/PlanShare.swift | 44 +- .../FTWKit/Sources/FTWShared/PlanDial.swift | 328 +++++++++++ .../FTWKit/Sources/FTWShared/PlanGlance.swift | 69 ++- .../FTWShared/Resources/en.lproj/Plan.strings | 7 + .../FTWShared/Resources/sv.lproj/Plan.strings | 7 + .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 79 ++- .../Tests/FTWSharedTests/PlanDialTests.swift | 243 ++++++++ appleApp/FTWUITests/LockScreenTour.swift | 12 +- appleApp/FTWUITests/PlanDialTests.swift | 34 ++ appleApp/FTWUITests/PlanDialTour.swift | 57 ++ appleApp/FTWUITests/WidgetTour.swift | 25 + appleApp/FTWWidgets/Plan/PlanDialWidget.swift | 275 +++++++++ appleApp/FTWWidgets/PriceWidget.swift | 1 + 23 files changed, 2063 insertions(+), 116 deletions(-) create mode 100644 appleApp/FTW/UI/PlanDialCard.swift create mode 100644 appleApp/FTWCharts/ChartSpring.swift create mode 100644 appleApp/FTWCharts/PlanDialView.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/PlanDial.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift create mode 100644 appleApp/FTWUITests/PlanDialTests.swift create mode 100644 appleApp/FTWUITests/PlanDialTour.swift create mode 100644 appleApp/FTWUITests/WidgetTour.swift create mode 100644 appleApp/FTWWidgets/Plan/PlanDialWidget.swift diff --git a/CLAUDE.md b/CLAUDE.md index 8d11e7c..0c1b26a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,39 @@ Borrow patterns from the best iOS apps, never their graphics or brands. | Calm lists, one prominent action, a quiet second one | Things, Apple Home | Pairing, mode sheet | | Liquid Glass only where the system puts it (bars, sheets, buttons) | iOS 26 | Toolbars, sheets, glass buttons; no custom glass panels | +### Plan dial + +FTW's own form, from Fredrik's film (srcfl/sourceful-films, +`product/ftw-confirmed`): the plan for today and tomorrow round one dial. +His rules (2026-10-13): + +1. The day runs like the sun's path: midnight at the bottom, noon at the + top, up on the left and down on the right. It can never read as a + 12-hour clock. +2. One bar per quarter hour, as tall as the price. Its colour is what the + plan does with the battery: `Theme.storage` stores, `Theme.fg` (white on + the glass) uses, `Theme.fgMuted` waits. A dot in `Theme.mobility` marks + a quarter when the car charges. Gold is the sun's colour, never the + battery's. +3. In the middle: the battery as a ring, then the time and the price in + mono figures. +4. A hand marks now, and what has passed dims. +5. Tomorrow is the outer turn, with bars only once the box has tomorrow's + prices. Until then the turn stays empty under a quiet "prices come + around 13:00". Never a bar without a price. A gap being fetched follows + the gap rule above. +6. The sky band on the rim comes from the box's own solar forecast (day + where the PV forecast is above zero), never from astronomy or the + phone's location. +7. A dark glass behind the dial, so it reads on any background, StandBy + and the lock screen too. + +`FTWShared/PlanDial.swift` holds the geometry, the quarters and the rules +for a finger; `FTWCharts/PlanDialView.swift` draws. The plan view puts the +dial at its top, and a finger dragged round it scrubs the plan. The +`energy.ftw.plan-dial` widget shows it small, in StandBy and as a circle on +the lock screen. Now keeps the straight plan and never scrolls. + ## Non-negotiable invariants - **Never fake live.** Every reading carries its age. diff --git a/appleApp/FTW/UI/ChartMotion.swift b/appleApp/FTW/UI/ChartMotion.swift index 3e67a7b..062547b 100644 --- a/appleApp/FTW/UI/ChartMotion.swift +++ b/appleApp/FTW/UI/ChartMotion.swift @@ -1,42 +1,6 @@ import FTWKit import SwiftUI -/// How a chart's points move when its data changes: each point that -/// changed springs from where it was drawn to its new value, a few -/// milliseconds after the point before it, so a curve grows into shape -/// rather than jumping. An estimate the data replaces, or a hole the box -/// confirms, fades as the line settles. Under Reduce Motion the new data -/// shows at once and only the fade remains. -/// -/// The points are computed once per change of data; the motion only -/// interpolates between two lists it already has. -enum ChartMotion { - /// The spring Fredrik's brief names: response 0.55 s, damping 0.72. - static let response = 0.55 - static let damping = 0.72 - /// Between one changed point and the next. - static let stagger = 0.008 - /// The whole stagger never takes longer than this, however many points. - static let staggerCap = 0.35 - /// How long a fading estimate takes to go. - static let fade = 0.3 - - /// A damped spring's step from 0 to 1 at `t` seconds, as - /// `.spring(response:dampingFraction:)` moves a value. It overshoots a - /// little and settles. - static func spring(_ t: Double) -> Double { - guard t > 0 else { return 0 } - let omega = 2 * Double.pi / response - let zeta = damping - let wd = omega * (1 - zeta * zeta).squareRoot() - let decay = exp(-zeta * omega * t) - return 1 - decay * (cos(wd * t) + zeta * omega / wd * sin(wd * t)) - } - - /// When a spring has settled to within a hair of its value. - static var settle: Double { response * 2 } -} - /// One change of a chart's points, followed while it moves. struct ChartChange: Equatable { /// Where each changed point was drawn when the change began, by id. diff --git a/appleApp/FTW/UI/PlanDialCard.swift b/appleApp/FTW/UI/PlanDialCard.swift new file mode 100644 index 0000000..358fcfc --- /dev/null +++ b/appleApp/FTW/UI/PlanDialCard.swift @@ -0,0 +1,235 @@ +import FTWKit +import SwiftUI + +/// The plan dial at the top of the plan view. A finger dragged round it +/// scrubs the plan a quarter at a time, onto tomorrow past midnight and +/// back; the line under it says what is planned there and springs back to +/// the plan's own sentence when the finger lifts. VoiceOver steps it an +/// hour at a time. +struct PlanDialCard: View { + let entry: PlanEntry + /// The minute: the dial marks it, not the second. + let nowMs: Double + let batteryPct: Double? + /// The battery's reading is not live. + let batteryOld: Bool + /// Prices are on their way from the box. + let fetching: Bool + /// The line under the dial while no finger is on it. + let summary: String + + @State private var face: PlanDial.Face? + @State private var size: CGSize = .zero + /// The finger, in turns along the spiral, and the quarter under it. + @State private var progress: Double? + @State private var selected: Int? + /// Where VoiceOver has stepped to. + @State private var spoken: Int? + @State private var ticks = 0 + @State private var bumps = 0 + @State private var lastTick = Date.distantPast + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let face = self.face ?? PlanDial.face(entry, nowMs: nowMs) + let marked = selected ?? spoken + VStack(alignment: .leading, spacing: 8) { + PlanDialView( + face: face, + nowMs: nowMs, + palette: .app, + batteryPct: batteryPct, + batteryOld: batteryOld, + selected: marked, + still: reduceMotion || PowerState.shared.lowPower, + fetching: fetching, + note: note(face) + ) + // The dial carries its own dark glass in both schemes. + .environment(\.colorScheme, .dark) + .frame(maxWidth: 380) + .frame(maxWidth: .infinity) + .onGeometryChange(for: CGSize.self, of: { $0.size }) { size = $0 } + .contentShape(DialRing(), eoFill: true) + .gesture(scrub(face)) + .accessibilityLabel(L("Plan dial")) + .accessibilityValue(spokenValue(face, at: spoken)) + .accessibilityHint(L("Swipe up or down to move an hour.")) + .accessibilityAdjustableAction { step(face, $0) } + .accessibilityIdentifier("plan.dial") + ReadoutLine(readout: marked.flatMap { Readouts.dial(face, $0, nowMs: nowMs) }, otherwise: summary) + .accessibilityHidden(marked != nil) + DialLegend(car: face.quarters.contains(where: \.car)) + } + .sensoryFeedback(.selection, trigger: ticks) + .sensoryFeedback(.impact(weight: .light), trigger: bumps) + .onChange(of: FaceKey(entry: entry, quarter: Int(nowMs / PlanDial.quarterMs)), initial: true) { + self.face = PlanDial.face(entry, nowMs: nowMs) + } + } + + /// The prices for tomorrow: on their way, or when they come. + private func note(_ face: PlanDial.Face) -> String? { + if fetching, !face.tomorrowPriced { return L("fetching…") } + return PlanDial.tomorrowNote(face, nowMs: nowMs) + } + + // MARK: A finger + + private func scrub(_ face: PlanDial.Face) -> some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { drag in + guard let reach = face.reach, size.width > 0 else { return } + let radius = min(size.width, size.height) / 2 / 1.14 + let x = (drag.location.x - size.width / 2) / radius + let y = (drag.location.y - size.height / 2) / radius + // Past either end the finger winds no further, so turning + // back answers at once. + let lo = face.quarters[reach.lowerBound].progress, hi = face.quarters[reach.upperBound].progress + let p = min(hi + 0.01, max(lo - 0.01, PlanDial.progress(x: x, y: y, from: progress))) + progress = p + guard let i = face.index(atProgress: p), i != selected else { return } + selected = i + if i == face.index(at: nowMs) || face.edgesCheapest(i) { + bumps += 1 + } else if Date().timeIntervalSince(lastTick) >= 0.04 { + lastTick = Date() + ticks += 1 + } + } + .onEnded { _ in + progress = nil + selected = nil + } + } + + // MARK: VoiceOver + + /// An hour a step, from now to the end of the prices and back to + /// midnight. + private func step(_ face: PlanDial.Face, _ direction: AccessibilityAdjustmentDirection) { + let forward: Bool + switch direction { + case .increment: forward = true + case .decrement: forward = false + @unknown default: return + } + spoken = face.step(from: spoken, forward: forward, nowMs: nowMs) ?? spoken + } + + /// "14:00–14:15, battery stores · spare solar · 65,5 öre/kWh", and the + /// battery's level at now. + private func spokenValue(_ face: PlanDial.Face, at index: Int?) -> String { + let i = index ?? face.index(at: nowMs) + guard let i, let readout = Readouts.dial(face, i, nowMs: nowMs) else { return summary } + var text = "\(readout.when), \(readout.what)" + if i == face.index(at: nowMs), let batteryPct { + text = "\(L("now")), \(text), \(NodeWords.format("battery %@", PlanDial.percent(batteryPct)))" + } + return text + } +} + +/// What makes the face out of date: the plan, and the quarter, since the +/// cheapest stretch is reckoned from now. +private struct FaceKey: Equatable { + let entry: PlanEntry + let quarter: Int +} + +/// Where a finger scrubs: the spiral and its rim, not the middle, so a +/// drag that starts on the battery's ring scrolls the page. +private struct DialRing: Shape { + func path(in rect: CGRect) -> Path { + let side = min(rect.width, rect.height) + let inner = side / 2 / 1.14 * (PlanDial.Layout.battery + PlanDial.Layout.batteryWidth) + var path = Path() + path.addEllipse(in: CGRect(x: rect.midX - side / 2, y: rect.midY - side / 2, width: side, height: side)) + path.addEllipse(in: CGRect(x: rect.midX - inner, y: rect.midY - inner, width: inner * 2, height: inner * 2)) + return path + } +} + +/// What each mark on the dial means. +private struct DialLegend: View { + let car: Bool + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { items } + VStack(alignment: .leading, spacing: 4) { items } + } + .font(.caption2) + .foregroundStyle(Theme.fgDim) + .accessibilityHidden(true) + } + + @ViewBuilder private var items: some View { + item(.store, L("stores")) + item(.use, L("used")) + item(.wait, L("waits")) + if car { item(.car, L("car charges")) } + } + + private func item(_ mark: DialSwatch.Mark, _ text: String) -> some View { + HStack(spacing: 5) { + DialSwatch(mark: mark) + Text(text).lineLimit(1) + } + } +} + +/// A mark as the dial draws it, on a dot of its dark glass, so white reads +/// as white on a light page too. +struct DialSwatch: View { + enum Mark { case store, use, wait, car, cheap } + let mark: Mark + + init(mark: Mark) { self.mark = mark } + + /// A plan event's mark: the battery's runs as on the dial. + init(kind: PlanGlance.Kind) { + switch kind { + case .charge: mark = .store + case .discharge: mark = .use + case .car: mark = .car + case .cheap: mark = .cheap + } + } + + var body: some View { + Group { + switch mark { + case .store: bar(Theme.storage) + case .use: bar(Theme.fg) + case .wait: bar(Theme.fgMuted) + case .cheap: bar(Theme.exporting) + case .car: Circle().fill(Theme.mobility).frame(width: 4, height: 4) + } + } + .frame(width: 14, height: 14) + .background(Theme.surface.opacity(0.92), in: Circle()) + .environment(\.colorScheme, .dark) + } + + private func bar(_ color: Color) -> some View { + RoundedRectangle(cornerRadius: 1).fill(color).frame(width: 3, height: 8) + } +} + +extension PlanDialPalette { + /// The app's tokens. The dial sets the dark scheme on itself, so they + /// resolve as they read on its dark glass: cyan stores, white is used, + /// grey waits, gold is the sun. + static let app = PlanDialPalette( + store: Theme.storage, + use: Theme.fg, + wait: Theme.fgMuted, + car: Theme.mobility, + sun: Theme.generation, + hand: Theme.fg, + battery: Theme.storage, + quiet: Theme.fgDim, + glass: Theme.surface + ) +} diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index cf5a383..1e8b26c 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -1,9 +1,9 @@ import FTWKit import SwiftUI -/// The plan, opened from Now: the next twelve hours as lanes over the -/// prices, then each thing the box means to do, when, and why, in the -/// box's own reasons. One screen, like Now. +/// The plan, opened from Now: today and tomorrow round the plan dial, then +/// each thing the box means to do, when, and why, in the box's own +/// reasons. A finger on the dial scrubs the plan. struct PlanView: View { let app: AppModel let home: HomeModels @@ -12,19 +12,21 @@ struct PlanView: View { private var models: NodeModels { home.nodes } var body: some View { - FitScreen(spacing: 10) { fill in - PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, fill: fill, chooseMode: { choosingMode = true }) + FitScreen(spacing: 10) { _ in + PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, chooseMode: { choosingMode = true }) } .navigationTitle(PlanGlance.title) .navigationBarTitleDisplayMode(.inline) .onAppear { - models.plan.activate() + // The forecast gives the dial its sky: the same read the sun's + // and the home's views make. + models.plan.activate(forecast: true) models.mode.activate() home.prices.activate() LaunchClock.mark("first content") } .onDisappear { - models.plan.deactivate() + models.plan.deactivate(forecast: true) models.mode.deactivate() home.prices.deactivate() } @@ -39,32 +41,25 @@ private struct PlanBody: View { let models: NodeModels let loadpoints: LoadpointsModel let prices: PriceModel - let fill: Bool let chooseMode: () -> Void var body: some View { // The plan marks the minute: it redraws once a minute, not a second. let minute = (site.nowMs / 60_000).rounded(.down) * 60_000 - let moment = PlanGlance.live(models.planShare.entry, nowMs: minute) - VStack(alignment: .leading, spacing: 4) { - Text(moment.summary) - .font(.headline) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - Text(stateLine(moment)) - .font(.footnote) - .foregroundStyle(moment.state == .boxStale ? Theme.generation : Theme.fgDim) - } - VStack(alignment: .leading, spacing: 6) { - PlanLanes(moment: moment) - .equatable() - .frame(minHeight: fill ? 84 : 140, idealHeight: fill ? 84 : 140, maxHeight: fill ? 200 : 140) - Legend(kinds: Set(moment.events.map(\.kind))) - } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + let entry = models.planShare.entry + let moment = PlanGlance.live(entry, nowMs: minute) + PlanDialCard( + entry: entry, + nowMs: minute, + batteryPct: site.socPercent, + batteryOld: !site.isLive, + fetching: prices.fetching, + summary: moment.summary + ) + Text(stateLine(moment)) + .font(.footnote) + .foregroundStyle(moment.state == .boxStale ? Theme.generation : Theme.fgDim) + .fixedSize(horizontal: false, vertical: true) PriceRow(prices: prices, minute: minute) Events(moment: moment, entry: models.planShare.entry, loadpoints: loadpoints) CompactMode(mode: models.mode, choose: chooseMode) @@ -78,35 +73,6 @@ private struct PlanBody: View { } } -/// What each colour on the lanes means. -private struct Legend: View { - let kinds: Set - - var body: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { items } - VStack(alignment: .leading, spacing: 4) { items } - } - .font(.caption2) - .foregroundStyle(Theme.fgDim) - } - - /// Only what the lanes hold, in short words: the rows below say the rest. - @ViewBuilder private var items: some View { - if kinds.contains(.car) { item(.car, L("Car")) } - if kinds.contains(.charge) { item(.charge, L("charging")) } - if kinds.contains(.discharge) { item(.discharge, L("discharging")) } - if kinds.contains(.cheap) { item(.cheap, L("Cheapest")) } - } - - private func item(_ kind: PlanGlance.Kind, _ text: String) -> some View { - HStack(spacing: 4) { - RoundedRectangle(cornerRadius: 2).fill(PlanLanes.color(kind)).frame(width: 10, height: 6) - Text(text).lineLimit(1) - } - } -} - /// Each thing the box means to do, when, and why. private struct Events: View { let moment: PlanGlance.Moment @@ -143,10 +109,8 @@ private struct Events: View { private func row(_ e: PlanGlance.Event) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { - RoundedRectangle(cornerRadius: 2) - .fill(PlanLanes.color(e.kind)) - .frame(width: 10, height: 6) - .alignmentGuide(.firstTextBaseline) { $0[.bottom] } + DialSwatch(kind: e.kind) + .alignmentGuide(.firstTextBaseline) { $0[.bottom] - 2 } VStack(alignment: .leading, spacing: 1) { Text(PlanGlance.what(e)) .font(.subheadline.weight(.semibold)) diff --git a/appleApp/FTWCharts/ChartSpring.swift b/appleApp/FTWCharts/ChartSpring.swift new file mode 100644 index 0000000..ebf8b56 --- /dev/null +++ b/appleApp/FTWCharts/ChartSpring.swift @@ -0,0 +1,38 @@ +import Foundation + +/// How a chart's points move when its data changes: each point that +/// changed springs from where it was drawn to its new value, a few +/// milliseconds after the point before it, so a curve grows into shape +/// rather than jumping. An estimate the data replaces, or a hole the box +/// confirms, fades as the line settles. Under Reduce Motion the new data +/// shows at once and only the fade remains. +/// +/// The points are computed once per change of data; the motion only +/// interpolates between two lists it already has. The app's charts and the +/// plan dial share this spring. +enum ChartMotion { + /// The spring Fredrik's brief names: response 0.55 s, damping 0.72. + static let response = 0.55 + static let damping = 0.72 + /// Between one changed point and the next. + static let stagger = 0.008 + /// The whole stagger never takes longer than this, however many points. + static let staggerCap = 0.35 + /// How long a fading estimate takes to go. + static let fade = 0.3 + + /// A damped spring's step from 0 to 1 at `t` seconds, as + /// `.spring(response:dampingFraction:)` moves a value. It overshoots a + /// little and settles. + static func spring(_ t: Double) -> Double { + guard t > 0 else { return 0 } + let omega = 2 * Double.pi / response + let zeta = damping + let wd = omega * (1 - zeta * zeta).squareRoot() + let decay = exp(-zeta * omega * t) + return 1 - decay * (cos(wd * t) + zeta * omega / wd * sin(wd * t)) + } + + /// When a spring has settled to within a hair of its value. + static var settle: Double { response * 2 } +} diff --git a/appleApp/FTWCharts/PlanDialView.swift b/appleApp/FTWCharts/PlanDialView.swift new file mode 100644 index 0000000..ac0f4ce --- /dev/null +++ b/appleApp/FTWCharts/PlanDialView.swift @@ -0,0 +1,522 @@ +import FTWShared +import SwiftUI + +/// The colours the plan dial draws with. The dial always sits on its own +/// dark glass, so these are the dark scheme's: the app hands over its +/// tokens resolved dark, the widget its copies of them. +struct PlanDialPalette: Equatable { + /// The battery stores. + var store: Color + /// The battery is used. + var use: Color + /// The battery waits. + var wait: Color + /// A quarter when the car charges. + var car: Color + /// Day on the rim, where the box's forecast expects sun. + var sun: Color + /// The hand and the figures. + var hand: Color + /// The battery's ring. + var battery: Color + /// Ticks, hour labels, the spiral's guide, small words. + var quiet: Color + /// The glass behind it all. + var glass: Color +} + +/// FTW's plan as a round dial: today the inner turn of a spiral, tomorrow +/// the outer, a bar per quarter hour as tall as the price and coloured by +/// what the plan does with the battery. Midnight at the bottom, noon at the +/// top, up on the left and down on the right, like the sun. A hand marks +/// now and what has passed dims. In the middle, the battery as a ring and +/// the time and the price. +/// +/// The app, the widgets and later the Watch draw this same view. It draws; +/// a finger or the Crown belongs to whoever holds it, and hands it the +/// quarter to mark as `selected`. Each layer redraws only when what it +/// shows changes: the bars when the plan or the quarter does, the hand by +/// the minute, the mark as the finger moves. +struct PlanDialView: View { + let face: PlanDial.Face + let nowMs: Double + let palette: PlanDialPalette + /// The battery's level, whole percent. Nil when there is no reading. + var batteryPct: Double? + /// The reading is old: the ring shows it quietly. + var batteryOld = false + /// The quarter under a finger or the Crown. + var selected: Int? + /// Hour labels inside the rim. The app has room for them. + var labels = true + /// The middle without the price's unit, for a widget's small dial. + var compact = false + /// The plan is old or the box could not make one: grey, never coloured. + var grey = false + /// One colour, for tinted widgets and StandBy at night. + var monochrome = false + /// No motion: widgets, Reduce Motion, Low Power Mode. + var still = false + /// Prices are on their way: the stretch without them is drawn faint. + var fetching = false + /// A quiet line in the empty outer turn. + var note: String? + /// A line under the price, such as how old the plan is. + var caption: String? + + var body: some View { + GeometryReader { geo in + let side = min(geo.size.width, geo.size.height) + // The labels sit inside the glass, on its rim. + let radius = labels ? side / 2 / 1.14 : side / 2 + ZStack { + Circle() + .fill(RadialGradient(colors: [palette.glass.opacity(0.97), palette.glass.opacity(0.9)], center: .center, startRadius: 0, endRadius: side / 2)) + .overlay(Circle().strokeBorder(palette.quiet.opacity(0.18), lineWidth: 0.5)) + DialBars( + face: face, + nowIndex: nowIndex, + sky: Sky(face.sky(nowMs: quarterStart)), + radius: radius, + palette: palette, + labels: labels, + grey: grey, + monochrome: monochrome, + still: still, + fetching: fetching + ) + .equatable() + if let selected, face.quarters.indices.contains(selected) { + DialMark(face: face, index: selected, nowIndex: nowIndex, radius: radius, palette: palette, grey: grey, monochrome: monochrome) + } + DialHand(progress: face.progress(at: minute), radius: radius, color: palette.hand) + DialCentre( + centre: PlanDial.centre(face, nowMs: minute, selected: selected), + radius: radius, + palette: palette, + batteryPct: batteryPct, + batteryOld: batteryOld || grey, + monochrome: monochrome, + compact: compact, + still: still, + caption: caption + ) + if let note { + Text(note) + .font(.system(size: max(9, radius * 0.06), weight: .medium)) + .foregroundStyle(palette.quiet) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.8) + .frame(width: radius * (compact ? 1.1 : 0.9)) + // The top of the outer turn, where tomorrow's noon would stand. + .offset(y: -radius * (PlanDial.Layout.spiral(1.5) + PlanDial.Layout.bar * 0.42)) + } + } + .frame(width: side, height: side) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .aspectRatio(1, contentMode: .fit) + .accessibilityElement(children: .ignore) + } + + /// The dial marks the minute. + private var minute: Double { (nowMs / 60_000).rounded(.down) * 60_000 } + + private var quarterStart: Double { (nowMs / PlanDial.quarterMs).rounded(.down) * PlanDial.quarterMs } + + /// The quarter now, or past either end of the face. + private var nowIndex: Int { + face.index(at: nowMs) ?? (nowMs < face.dayStartMs ? -1 : face.quarters.count) + } +} + +/// The sky band's arcs, as shares of the day. +private struct Sky: Equatable { + var covered: [ClosedRange] + var sun: [ClosedRange] + + init(_ arcs: (covered: [ClosedRange], sun: [ClosedRange])) { + covered = arcs.covered + sun = arcs.sun + } +} + +/// One change of the bars, followed while it moves: each bar that changed +/// springs from the length it was drawn at to its new one, a few +/// milliseconds after the bar before it, so a new plan grows into shape +/// round the spiral instead of jumping. +private struct DialMotion { + var from: [Double: Double] = [:] + var order: [Double: Int] = [:] + var began = Date.distantPast + var stagger = ChartMotion.stagger + + var duration: Double { order.isEmpty ? 0 : ChartMotion.settle + Double(order.count) * stagger } + + func length(_ id: Double, _ target: Double, at date: Date) -> Double { + guard let i = order[id], let start = from[id] else { return target } + let f = ChartMotion.spring(date.timeIntervalSince(began) - Double(i) * stagger) + return start + (target - start) * f + } + + /// From the lengths as drawn to `new`, in the order of `ids`. A bar that + /// is new grows from nothing. + static func between(_ drawn: [Double: Double], _ new: [Double: Double], ids: [Double], at date: Date, still: Bool) -> DialMotion { + var m = DialMotion(began: date) + guard !still else { return m } + var k = 0 + for id in ids { + guard let target = new[id] else { continue } + let was = drawn[id] ?? 0 + guard abs(was - target) > 0.004 else { continue } + m.from[id] = was + m.order[id] = k + k += 1 + } + if k > 0 { m.stagger = min(ChartMotion.stagger, ChartMotion.staggerCap / Double(k)) } + return m + } +} + +/// The face: the sky, the ticks, the spiral and its bars. Redrawn when the +/// plan changes or a quarter passes, and at the display's rate only while +/// a new plan grows in. +private struct DialBars: View, Equatable { + let face: PlanDial.Face + let nowIndex: Int + let sky: Sky + let radius: CGFloat + let palette: PlanDialPalette + let labels: Bool + let grey: Bool + let monochrome: Bool + let still: Bool + let fetching: Bool + + @State private var shown: [Double: Double] = [:] + @State private var motion = DialMotion() + @State private var moving = false + + nonisolated static func == (a: Self, b: Self) -> Bool { + a.face == b.face && a.nowIndex == b.nowIndex && a.sky == b.sky && a.radius == b.radius && a.palette == b.palette + && a.labels == b.labels && a.grey == b.grey && a.monochrome == b.monochrome && a.still == b.still && a.fetching == b.fetching + } + + var body: some View { + if still { + // A widget, or no motion: the bars as they are, nothing to follow. + let lengths = Self.targets(face) + Canvas { ctx, size in draw(&ctx, size: size, lengths: lengths, at: .distantFuture) } + } else { + TimelineView(.animation(paused: !moving)) { context in + let at = moving ? context.date : .distantFuture + Canvas { ctx, size in draw(&ctx, size: size, lengths: shown, at: at) } + } + .onAppear { begin() } + .onChange(of: face) { begin() } + } + } + + /// Each bar's length as a share of the longest, signed as its price. + private static func targets(_ face: PlanDial.Face) -> [Double: Double] { + var out = [Double: Double]() + for q in face.quarters { + if let p = q.priceMinor { out[q.id] = p / face.scale } + } + return out + } + + private func begin() { + let new = Self.targets(face) + let now = Date() + var drawn = [Double: Double]() + for (id, target) in shown { drawn[id] = motion.length(id, target, at: now) } + motion = DialMotion.between(drawn, new, ids: face.quarters.map(\.id), at: now, still: still) + shown = new + guard motion.duration > 0 else { + moving = false + return + } + moving = true + let began = motion.began + let length = motion.duration + Task { @MainActor in + try? await Task.sleep(for: .seconds(length + 0.05)) + if motion.began == began { moving = false } + } + } + + // MARK: Drawing + + private func draw(_ ctx: inout GraphicsContext, size: CGSize, lengths: [Double: Double], at date: Date) { + let c = CGPoint(x: size.width / 2, y: size.height / 2) + let r = Double(radius) + func point(_ f: Double, _ rr: Double) -> CGPoint { + let u = PlanDial.unit(f) + return CGPoint(x: c.x + u.x * rr, y: c.y + u.y * rr) + } + func arc(_ range: ClosedRange, _ rr: Double) -> Path { + var path = Path() + let steps = max(2, Int((range.upperBound - range.lowerBound) * 240)) + for k in 0...steps { + let f = range.lowerBound + (range.upperBound - range.lowerBound) * Double(k) / Double(steps) + k == 0 ? path.move(to: point(f, rr)) : path.addLine(to: point(f, rr)) + } + return path + } + func spiral(_ from: Double, _ to: Double, lift: Double) -> Path { + var path = Path() + let steps = max(2, Int((to - from) * 192)) + for k in 0...steps { + let p = from + (to - from) * Double(k) / Double(steps) + let pt = point(p.truncatingRemainder(dividingBy: 1), r * (PlanDial.Layout.spiral(p) + lift)) + k == 0 ? path.move(to: pt) : path.addLine(to: pt) + } + return path + } + let L = PlanDial.Layout.self + let quiet = monochrome ? palette.hand : palette.quiet + + // The sky on the rim, from the box's own forecast: where it speaks, + // night is a faint line and day is the sun's colour. + for a in sky.covered { + ctx.stroke(arc(a, r * L.rim), with: .color(quiet.opacity(0.22)), lineWidth: r * L.band) + } + for a in sky.sun { + ctx.stroke(arc(a, r * L.rim), with: .color((monochrome ? palette.hand : palette.sun).opacity(0.9)), lineWidth: r * L.band) + } + + // Ticks on the band, and the four hours that name the day. + for h in 0..<24 { + let major = h % 6 == 0 + guard major || labels else { continue } + let f = Double(h) / 24 + let inner = r * (L.rim - L.band / 2 - (major ? 0.018 : 0.008)) + let outer = r * (L.rim + (major ? L.band / 2 + 0.012 : -L.band / 2)) + var tick = Path() + tick.move(to: point(f, inner)) + tick.addLine(to: point(f, outer)) + ctx.stroke(tick, with: .color(quiet.opacity(major ? 0.7 : 0.35)), lineWidth: major ? max(1, r * 0.008) : max(0.5, r * 0.004)) + } + if labels { + for h in [0, 6, 12, 18] { + let label = Text(String(format: "%02d", h)) + .font(.system(size: max(8, r * 0.07), weight: .semibold, design: .monospaced)) + .foregroundStyle(quiet.opacity(0.85)) + ctx.draw(label, at: point(Double(h) / 24, r * 1.075)) + } + } + + // The spiral itself: today firm, tomorrow fainter, and dotted while + // the box has no prices for it. + ctx.stroke(spiral(0, 1, lift: 0), with: .color(quiet.opacity(0.3)), lineWidth: max(0.5, r * 0.005)) + ctx.stroke(spiral(1, 2, lift: 0), with: .color(quiet.opacity(face.tomorrowPriced ? 0.22 : 0.3)), + style: StrokeStyle(lineWidth: max(0.5, r * 0.005), dash: face.tomorrowPriced ? [] : [1, max(2, r * 0.02)])) + + // A stretch being fetched: a faint band where its bars will grow. + if fetching { + var i = max(0, nowIndex) + while i < face.quarters.count { + guard face.quarters[i].priceMinor == nil else { + i += 1 + continue + } + var j = i + while j + 1 < face.quarters.count, face.quarters[j + 1].priceMinor == nil { j += 1 } + let from = Double(face.quarters[i].turn) + Double(face.quarters[i].minuteOfDay) / 1440 + let to = face.quarters[j].progress + 7.5 / 1440 + ctx.stroke(spiral(from, to, lift: L.bar / 2), with: .color(quiet.opacity(0.08)), lineWidth: r * L.bar) + i = j + 1 + } + } + + // The bars, and the car's dots inside the spiral. + for (i, q) in face.quarters.enumerated() { + let past = i < nowIndex + let f = q.dayFraction + let base = L.spiral(q.progress) + if q.car { + let dot = point(f, r * (base - 0.03)) + let d = max(1.6, r * 0.022) + ctx.fill(Path(ellipseIn: CGRect(x: dot.x - d / 2, y: dot.y - d / 2, width: d, height: d)), + with: .color((monochrome ? palette.hand : palette.car).opacity(past ? 0.35 : 1))) + } + guard let target = lengths[q.id] else { continue } + let length = motion.length(q.id, target, at: date) + let (color, alpha) = style(q, past: past) + let foot = r * (base + 0.008) + let reach = length >= 0 ? max(0.012, length * L.bar) : -min(L.barInward, -length * L.bar) + var bar = Path() + bar.move(to: point(f, foot)) + bar.addLine(to: point(f, foot + r * reach)) + let width = max(1, 2 * Double.pi * foot / 96 * 0.56) + ctx.stroke(bar, with: .color(color.opacity(alpha)), lineWidth: width) + } + } + + /// A bar's colour and strength: what the plan does, dimmed once past. + private func style(_ q: PlanDial.Quarter, past: Bool) -> (Color, Double) { + var color: Color + var alpha: Double + if monochrome { + color = palette.hand + switch grey ? .wait : q.action { + case .store: alpha = 1 + case .use: alpha = 0.7 + case .wait: alpha = 0.38 + case .unplanned: alpha = 0.26 + } + } else if grey { + color = palette.wait + alpha = 0.6 + } else { + switch q.action { + case .store: (color, alpha) = (palette.store, 1) + case .use: (color, alpha) = (palette.use, 1) + case .wait: (color, alpha) = (palette.wait, 1) + case .unplanned: (color, alpha) = (palette.wait, 0.5) + } + } + return (color, past ? alpha * 0.36 : alpha) + } +} + +/// The quarter under a finger: a fine rule across the spiral, and its bar +/// drawn bright over the rest. +private struct DialMark: View { + let face: PlanDial.Face + let index: Int + let nowIndex: Int + let radius: CGFloat + let palette: PlanDialPalette + let grey: Bool + let monochrome: Bool + + var body: some View { + Canvas { ctx, size in + let c = CGPoint(x: size.width / 2, y: size.height / 2) + let r = Double(radius) + let q = face.quarters[index] + let u = PlanDial.unit(q.dayFraction) + func point(_ rr: Double) -> CGPoint { CGPoint(x: c.x + u.x * rr, y: c.y + u.y * rr) } + let base = PlanDial.Layout.spiral(q.progress) + var rule = Path() + rule.move(to: point(r * (base - 0.06))) + rule.addLine(to: point(r * (base + PlanDial.Layout.bar + 0.04))) + ctx.stroke(rule, with: .color(palette.hand.opacity(0.55)), lineWidth: 1) + if let price = q.priceMinor { + let foot = r * (base + 0.008) + let length = price / face.scale + let reach = length >= 0 ? max(0.012, length * PlanDial.Layout.bar) : -min(PlanDial.Layout.barInward, -length * PlanDial.Layout.bar) + var bar = Path() + bar.move(to: point(foot)) + bar.addLine(to: point(foot + r * reach)) + let color: Color + if monochrome || grey { + color = palette.hand + } else { + switch q.action { + case .store: color = palette.store + case .use, .unplanned: color = palette.hand + case .wait: color = palette.hand.opacity(0.8) + } + } + ctx.stroke(bar, with: .color(color), style: StrokeStyle(lineWidth: max(2, 2 * Double.pi * foot / 96 * 0.9), lineCap: .round)) + } + } + .allowsHitTesting(false) + } +} + +/// Now: a hand from the battery's ring to the rim, and a dot where it +/// crosses the spiral. +private struct DialHand: View { + let progress: Double + let radius: CGFloat + let color: Color + + var body: some View { + Canvas { ctx, size in + let c = CGPoint(x: size.width / 2, y: size.height / 2) + let r = Double(radius) + let L = PlanDial.Layout.self + let u = PlanDial.unit(progress.truncatingRemainder(dividingBy: 1)) + func point(_ rr: Double) -> CGPoint { CGPoint(x: c.x + u.x * rr, y: c.y + u.y * rr) } + var hand = Path() + hand.move(to: point(r * (L.battery + L.batteryWidth / 2 + 0.025))) + hand.addLine(to: point(r * (L.rim + L.band / 2 + 0.01))) + ctx.stroke(hand, with: .color(color), style: StrokeStyle(lineWidth: max(1.2, r * 0.011), lineCap: .round)) + let dot = point(r * L.spiral(progress)) + let d = max(4, r * 0.045) + ctx.fill(Path(ellipseIn: CGRect(x: dot.x - d / 2, y: dot.y - d / 2, width: d, height: d)), with: .color(color)) + } + .allowsHitTesting(false) + } +} + +/// The middle: the battery as a ring, then the time and the price in mono +/// figures. +private struct DialCentre: View { + let centre: PlanDial.Centre + let radius: CGFloat + let palette: PlanDialPalette + let batteryPct: Double? + let batteryOld: Bool + let monochrome: Bool + let compact: Bool + let still: Bool + let caption: String? + + var body: some View { + let L = PlanDial.Layout.self + let ring = radius * 2 * L.battery + let width = radius * L.batteryWidth + ZStack { + Circle() + .stroke(palette.quiet.opacity(0.2), lineWidth: width) + .frame(width: ring, height: ring) + if let batteryPct { + Circle() + .trim(from: 0, to: max(0, min(1, batteryPct / 100))) + .stroke(batteryOld ? palette.quiet.opacity(0.7) : (monochrome ? palette.hand : palette.battery), style: StrokeStyle(lineWidth: width, lineCap: .round)) + // From midnight round, as the day runs. + .rotationEffect(.degrees(90)) + .frame(width: ring, height: ring) + .animation(still ? nil : .spring(response: ChartMotion.response, dampingFraction: ChartMotion.damping), value: batteryPct) + } + VStack(spacing: radius * 0.012) { + if let day = centre.day { + Text(day.uppercased()) + .font(.system(size: max(7, radius * 0.05), weight: .semibold)) + .tracking(0.6) + .foregroundStyle(palette.quiet) + } + Text(centre.time) + .font(.system(size: radius * (compact ? 0.17 : 0.15), weight: .semibold, design: .monospaced)) + .foregroundStyle(palette.hand) + .contentTransition(still ? .identity : .numericText()) + if let price = centre.price { + Text(price) + .font(.system(size: max(compact ? 9 : 8, radius * (compact ? 0.1 : 0.085)), weight: .medium, design: .monospaced)) + .foregroundStyle(palette.hand.opacity(0.85)) + .contentTransition(still ? .identity : .numericText()) + if !compact { + Text(centre.unit) + .font(.system(size: max(7, radius * 0.045))) + .foregroundStyle(palette.quiet) + } + } + if let caption { + Text(caption) + .font(.system(size: max(7, radius * 0.045))) + .foregroundStyle(palette.quiet) + } + } + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(width: ring - width * 2) + } + .allowsHitTesting(false) + } +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index b0313e9..3d34751 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -120,12 +120,14 @@ extension SimulatedHouse { var reason: String } - /// The next 24 hours in quarter hours, the one running now first and - /// starting now. - func plan(nowMs: Double, hours: Double = 24) -> [Step] { + /// The hours ahead in quarter hours, the one running now first and + /// starting now, as far as the prices reach: to midnight, and to the end + /// of tomorrow once its rates publish at 13:00, as Core plans. + func plan(nowMs: Double) -> [Step] { let slotMs: Double = 900_000 var t = (nowMs / slotMs).rounded(.down) * slotMs - let end = t + hours * 3_600_000 + let midnight = nowMs - hourOfDay(nowMs) * 3_600_000 + let end = midnight + (hourOfDay(nowMs) >= 13 ? 48 : 24) * 3_600_000 var prices = [Double]() var probe = t while probe < end { prices.append(priceAt(probe + slotMs / 2)); probe += slotMs } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift index bccb54d..ecbe101 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift @@ -140,6 +140,26 @@ public enum Readouts { return ChartReadout(when: span(range.lowerBound, range.upperBound), what: NodeWords.format("average %@", price(mean, currency))) } + /// A quarter on the plan dial: when, what the plan does with the + /// battery and why, the car, and the price. Past quarters the plan no + /// longer covers give only their price. + public static func dial(_ face: PlanDial.Face, _ i: Int, nowMs: Double) -> ChartReadout? { + guard face.quarters.indices.contains(i) else { return nil } + let q = face.quarters[i] + let span = "\(AppWords.clock(q.startMs, zone: face.timeZone))–\(AppWords.clock(q.endMs, zone: face.timeZone))" + var parts = [String]() + switch q.action { + case .store: parts.append(NodeWords.text("battery stores")) + case .use: parts.append(NodeWords.text("battery is used")) + case .wait: parts.append(NodeWords.text("battery waits")) + case .unplanned: if q.endMs > nowMs { parts.append(NodeWords.text("not planned yet")) } + } + if let reason = q.reason, q.action == .store || q.action == .use { parts.append(PlanText.reason(reason)) } + if q.car { parts.append(NodeWords.text("car charges")) } + parts.append(q.priceMinor.map { price($0, face.currency) } ?? NodeWords.text("no price yet")) + return ChartReadout(when: q.turn == 1 ? NodeWords.format("tomorrow %@", span) : span, what: parts.joined(separator: " · ")) + } + /// A moment of the live curve. public static func live(_ node: EnergyNode, _ points: [LiveTrace.Point], at ms: Double) -> ChartReadout? { guard let p = points.min(by: { abs($0.t - ms) < abs($1.t - ms) }), abs(p.t - ms) < LiveTrace.gapMs else { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 3ca0991..64f4d6b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -216,6 +216,19 @@ public enum NodeWords { "not charging": "laddar inte", "Time": "Tid", "Power": "Effekt", + // The plan dial. + "battery stores": "batteriet lagrar", + "battery is used": "batteriet används", + "battery waits": "batteriet väntar", + "car charges": "bilen laddar", + "no price yet": "inget pris än", + "not planned yet": "inte planerat än", + "stores": "lagrar", + "used": "används", + "waits": "väntar", + "Plan dial": "Plantavla", + "battery %@": "batteri %@", + "Swipe up or down to move an hour.": "Svep uppåt eller nedåt för att flytta en timme.", // Pairing. The box's own dashboard is in English, so its labels // (Settings → FTW app, Show pairing code) stay as it shows them. "Scan the pairing QR": "Skanna QR-koden", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index ee71249..dcf0ad9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -191,6 +191,9 @@ public final class PriceModel: Activatable { public private(set) var prices: Prices? public private(set) var fromMs: Double = 0 + /// An ask is on its way: a stretch without prices is being fetched, + /// not missing. + public private(set) var fetching = false @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private var ask: LiveAsk? @@ -242,6 +245,8 @@ public final class PriceModel: Activatable { generation += 1 let mine = generation let from = midnight(site.nowMs) + fetching = true + defer { if mine == generation { fetching = false } } do { let wire = try await site.prices(fromMs: from, toMs: from + Self.horizonMs) guard mine == generation else { return } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index c9fdd84..dbd95ef 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift @@ -6,7 +6,7 @@ extension PlanEntry { /// car's charging windows with the level it charges toward, and the /// prices under them. Prices come from the price model, or else from the /// plan's own slots. - public static func live(plan: Plan?, points: [Loadpoint], windows: [String: [Loadpoint.Window]], prices: Prices?, timeZone: String? = nil) -> PlanEntry { + public static func live(plan: Plan?, points: [Loadpoint], windows: [String: [Loadpoint.Window]], prices: Prices?, timeZone: String? = nil, daylight: Daylight? = nil, batteryLevel: Level? = nil) -> PlanEntry { let battery = plan.map { p in p.slots.map { Slot(startMs: $0.startMs, durationMs: $0.durationMs, batteryW: $0.batteryW, reason: $0.reason) } } var charges = [Charge]() for lp in points { @@ -18,13 +18,16 @@ extension PlanEntry { if slots.isEmpty, let plan { slots = plan.slots.compactMap { s in s.priceMinor.map { PriceSlot(startMs: s.startMs, durationMs: s.durationMs, spotMinor: $0, totalMinor: $0) } } } - return PlanEntry(battery: battery, charges: charges, prices: slots, currency: prices?.currency ?? "SEK", boxStale: plan?.stale ?? false, timeZone: timeZone) + return PlanEntry(battery: battery, charges: charges, prices: slots, currency: prices?.currency ?? "SEK", boxStale: plan?.stale ?? false, timeZone: timeZone, daylight: daylight, batteryLevel: batteryLevel) } - /// Only what lies ahead, for the widget's record: from an hour back to a - /// day ahead. + /// Only today and tomorrow, in the household's clock, for the widget's + /// record: the plan dial's two turns. func trimmed(nowMs: Double) -> PlanEntry { - let from = nowMs - 3_600_000, to = nowMs + 24 * 3_600_000 + let calendar = Calendar.household(timeZone) + let today = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)) + let from = today.timeIntervalSince1970 * 1000 + let to = (calendar.date(byAdding: .day, value: 2, to: today)?.timeIntervalSince1970).map { $0 * 1000 } ?? from + 48 * 3_600_000 var e = self e.battery = battery?.filter { $0.startMs + $0.durationMs > from && $0.startMs < to } e.charges = charges.filter { $0.toMs > from && $0.fromMs < to } @@ -60,16 +63,36 @@ public final class PlanShare { token = site.observe { [weak self] in self?.check() } } - /// The plan as Now draws it. + /// The plan as Now and the plan dial draw it, with the sun the box + /// forecasts when the app has read it. public var entry: PlanEntry { - PlanEntry.live(plan: site.session.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier) + PlanEntry.live(plan: site.session.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier, daylight: daylight) + } + + /// The box's solar forecast as the dial's sky: nil until it is read. + var daylight: PlanEntry.Daylight? { + guard plan.forecastLoaded else { return nil } + return PlanEntry.Daylight(plan.forecast.map { ($0.startMs, $0.durationMs, $0.pvW) }) + } + + /// A battery level read live, for the widget's dial. It is written again + /// once it has moved and the last one is `levelRewriteMs` old, so a + /// battery at work does not rewrite the record every minute. + static let levelRewriteMs: Double = 5 * 60_000 + private var level: PlanEntry.Level? + + private func refreshLevel() { + guard site.isLive, let pct = site.socPercent else { return } + if let level, level.percent == pct || site.nowMs - level.readAtMs < Self.levelRewriteMs { return } + level = PlanEntry.Level(percent: pct, readAtMs: site.nowMs) } /// What would make the record different. Cheap enough for every tick. var key: String { let w = loadpoints.points.map { lp in "\(lp.id):\((loadpoints.windows[lp.id] ?? lp.planWindows).map { "\($0.fromMs)-\($0.toMs)" }.joined(separator: ",")):\(lp.targetSocPct ?? lp.schedule?.socPct ?? -1)" } let p = prices.prices.map { "\($0.slots.count)@\($0.slots.first?.startMs ?? 0)" } ?? "-" - return "\(planKey)|\(w.joined(separator: ";"))|\(p)" + let sun = plan.forecastLoaded ? "\(plan.forecast.count)@\(plan.forecast.first?.startMs ?? 0)" : "-" + return "\(planKey)|\(w.joined(separator: ";"))|\(p)|\(sun)|\(level?.readAtMs ?? 0)" } private var planKey: String { @@ -91,10 +114,13 @@ public final class PlanShare { } // Nothing to say until the box has sent a plan. guard site.session.plan != nil, let sent = sentAtMs else { return } + refreshLevel() let key = self.key guard key != lastKey || sent != lastSentAtMs else { return } lastKey = key lastSentAtMs = sent - site.sharePlan(entry.trimmed(nowMs: site.nowMs), sentAtMs: sent) + var record = entry.trimmed(nowMs: site.nowMs) + record.batteryLevel = level + site.sharePlan(record, sentAtMs: sent) } } diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/PlanDial.swift new file mode 100644 index 0000000..f89b31c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PlanDial.swift @@ -0,0 +1,328 @@ +import Foundation + +/// FTW's plan as a dial: today and tomorrow as one spiral, today the inner +/// turn and tomorrow the outer. A bar per quarter hour stands as tall as the +/// price, coloured by what the plan does with the battery; a dot marks a +/// quarter when the car charges. +/// +/// The day runs the way the sun does when you face south: midnight at the +/// bottom, noon at the top, up on the left and down on the right. A clock +/// starts at the top and goes round twice a day; this cannot be read as one. +/// +/// Everything here is worked out once per plan, from a `PlanEntry` alone, +/// so the app, its widgets and later the Watch draw the same dial. +public enum PlanDial { + public static let quarterMs: Double = 900_000 + + // MARK: The sun's path + + /// Radians clockwise on screen from straight down, for a time of day + /// given as a share of the day. + public static func angle(_ dayFraction: Double) -> Double { dayFraction * 2 * .pi } + + /// The point for a time of day on a circle of radius one around the + /// centre, in screen axes: y grows downward. + public static func unit(_ dayFraction: Double) -> (x: Double, y: Double) { + let a = angle(dayFraction) + return (-sin(a), cos(a)) + } + + /// The time of day under a point, measured from the centre in screen + /// axes, as a share of the day. + public static func dayFraction(x: Double, y: Double) -> Double { + let a = atan2(-x, y) + return (a < 0 ? a + 2 * .pi : a) / (2 * .pi) + } + + /// Where things sit, in parts of the dial's radius. + public enum Layout { + /// Where today's turn starts, at midnight. + public static let core = 0.385 + /// What one turn adds to the radius. + public static let turn = 0.19 + /// The longest bar. Shorter than a turn, so a bar never reaches the + /// turn outside it. + public static let bar = 0.15 + /// How far a price below zero may reach inward. + public static let barInward = 0.03 + /// The middle of the sky band on the rim, and its width. + public static let rim = 0.945 + public static let band = 0.026 + /// The battery's ring, and its width. + public static let battery = 0.315 + public static let batteryWidth = 0.036 + + /// The radius of the spiral `progress` turns after today's midnight. + public static func spiral(_ progress: Double) -> Double { core + turn * progress } + } + + // MARK: The face + + /// What the plan does with the battery in a quarter. + public enum Action: String, Sendable, Equatable { + /// The battery charges. + case store + /// The battery gives power. + case use + /// The battery rests. + case wait + /// No plan covers the quarter: it is before the plan, or after it. + case unplanned + } + + /// One quarter hour of today or tomorrow. + public struct Quarter: Equatable, Sendable, Identifiable { + public let startMs: Double + /// 0 today, 1 tomorrow. + public let turn: Int + /// The wall clock at its start, in minutes after midnight. + public let minuteOfDay: Int + /// What the household pays, in minor units per kWh. Nil while the + /// box has no price for it: then no bar is drawn. + public let priceMinor: Double? + public let action: Action + /// The box's reason code for the plan's slot. + public let reason: String? + public let car: Bool + + public var id: Double { startMs } + public var endMs: Double { startMs + PlanDial.quarterMs } + /// The time of day at its middle, as a share of the day: its angle. + public var dayFraction: Double { (Double(minuteOfDay) + 7.5) / 1440 } + /// How far along the spiral its middle lies, in turns. + public var progress: Double { Double(turn) + dayFraction } + } + + public struct Face: Equatable, Sendable { + public let dayStartMs: Double + public let tomorrowStartMs: Double + public let endMs: Double + /// Every quarter from today's midnight to tomorrow's end, in order. + public let quarters: [Quarter] + /// Whether the box has tomorrow's prices. The outer turn holds bars + /// only then. + public let tomorrowPriced: Bool + /// The price a full bar stands for: the largest, either side of zero. + public let scale: Double + /// The cheapest stretch ahead, as Now and the plan card name it. + public let cheapest: PlanEntry.Span? + public let daylight: PlanEntry.Daylight? + public let currency: String + public let timeZoneID: String + + public var timeZone: TimeZone { TimeZone(identifier: timeZoneID) ?? .current } + + /// The quarter `ms` falls in. Quarters lie on a fixed grid from + /// midnight, so this is a division. + public func index(at ms: Double) -> Int? { + guard ms >= dayStartMs, ms < endMs, !quarters.isEmpty else { return nil } + return min(quarters.count - 1, Int((ms - dayStartMs) / PlanDial.quarterMs)) + } + + /// The quarters a finger can reach: from the first one with a price + /// or a plan to the last. + public var reach: ClosedRange? { + guard let a = quarters.firstIndex(where: Self.known), let b = quarters.lastIndex(where: Self.known) else { return nil } + return a...b + } + + private static func known(_ q: Quarter) -> Bool { q.priceMinor != nil || q.action != .unplanned } + + /// How far along the spiral `ms` lies, in turns, for the hand. + public func progress(at ms: Double) -> Double { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let c = calendar.dateComponents([.hour, .minute, .second], from: Date(timeIntervalSince1970: ms / 1000)) + let fraction = (Double(c.hour ?? 0) * 3600 + Double(c.minute ?? 0) * 60 + Double(c.second ?? 0)) / 86_400 + return (ms >= tomorrowStartMs ? 1 : 0) + fraction + } + + /// The quarter nearest a place on the spiral, kept within `reach`. + public func index(atProgress p: Double) -> Int? { + guard let reach else { return nil } + var best = reach.lowerBound + for i in reach where abs(quarters[i].progress - p) < abs(quarters[best].progress - p) { best = i } + return best + } + + /// The quarters VoiceOver steps through: the one now and every whole + /// hour a finger can reach, in order. + public func hourStops(nowMs: Double) -> [Int] { + guard let reach else { return [] } + var stops = reach.filter { quarters[$0].minuteOfDay % 60 == 0 } + if let now = index(at: nowMs), reach.contains(now), !stops.contains(now) { + stops.append(now) + stops.sort() + } + return stops + } + + /// VoiceOver's next stop from `index` (now when nil), an hour on or + /// back. Nil at either end. + public func step(from index: Int?, forward: Bool, nowMs: Double) -> Int? { + let stops = hourStops(nowMs: nowMs) + guard let at = index ?? self.index(at: nowMs) ?? stops.first else { return nil } + return forward ? stops.first { $0 > at } : stops.last { $0 < at } + } + + /// Whether a quarter starts or ends the cheapest stretch: a firmer + /// tick under the finger. + public func edgesCheapest(_ i: Int) -> Bool { + guard let cheapest, quarters.indices.contains(i) else { return false } + let q = quarters[i] + return abs(q.startMs - cheapest.fromMs) < 1 || abs(q.endMs - cheapest.toMs) < 1 + } + + /// The sky's runs as arcs on the rim, over the 24 hours from `nowMs`: + /// each place on the rim shows the next time that hour comes round. + /// `covered` is where the forecast speaks, `sun` where it expects + /// the panels to make power. Both empty without a forecast. + public func sky(nowMs: Double) -> (covered: [ClosedRange], sun: [ClosedRange]) { + guard let daylight else { return ([], []) } + let window = PlanEntry.Span(fromMs: nowMs, toMs: nowMs + 86_400_000) + return (arcs([daylight.covered], within: window), arcs(daylight.sun, within: window)) + } + + private func arcs(_ spans: [PlanEntry.Span], within window: PlanEntry.Span) -> [ClosedRange] { + var out = [ClosedRange]() + for s in spans { + let a = max(s.fromMs, window.fromMs), b = min(s.toMs, window.toMs) + guard b > a else { continue } + let from = progress(at: a).truncatingRemainder(dividingBy: 1) + let length = (b - a) / 86_400_000 + let to = from + length + if to <= 1 { + out.append(from...to) + } else { + out.append(from...1) + out.append(0...(to - 1)) + } + } + return out + } + } + + /// The dial for `entry` on the day of `nowMs`, in the household's clock. + public static func face(_ entry: PlanEntry, nowMs: Double, calendar given: Calendar? = nil) -> Face { + let calendar = given ?? .household(entry.timeZone) + let today = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)) + let start = ms(today) + let middle = calendar.date(byAdding: .day, value: 1, to: today).map(ms) ?? start + 86_400_000 + let end = calendar.date(byAdding: .day, value: 2, to: today).map(ms) ?? middle + 86_400_000 + + let prices = entry.prices.filter { $0.totalMinor.isFinite && $0.startMs < end && $0.startMs + $0.durationMs > start }.sorted { $0.startMs < $1.startMs } + let battery = (entry.battery ?? []).sorted { $0.startMs < $1.startMs } + var quarters = [Quarter]() + quarters.reserveCapacity(200) + var p = 0, b = 0 + var t = start + while t < end { + let qEnd = t + quarterMs + let mid = t + quarterMs / 2 + while p < prices.count, prices[p].startMs + prices[p].durationMs <= mid { p += 1 } + let price = p < prices.count && prices[p].startMs <= mid ? prices[p].totalMinor : nil + // The plan's first slot starts the minute the box made it, so a + // slot that reaches into the quarter at all speaks for it. + while b < battery.count, battery[b].startMs + battery[b].durationMs <= t { b += 1 } + let slot = b < battery.count && battery[b].startMs < qEnd ? battery[b] : nil + let car = entry.charges.contains { min($0.toMs, qEnd) - max($0.fromMs, t) >= 60_000 } + let wall = calendar.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: t / 1000)) + quarters.append(Quarter( + startMs: t, + turn: t < middle ? 0 : 1, + minuteOfDay: (wall.hour ?? 0) * 60 + (wall.minute ?? 0), + priceMinor: price, + action: slot.map { action($0.batteryW) } ?? .unplanned, + reason: slot?.reason, + car: car + )) + t = qEnd + } + let scale = max(1, quarters.compactMap { $0.priceMinor.map(abs) }.max() ?? 1) + let cheapest = PlanGlance.cheapest(entry, fromMs: nowMs, toMs: nowMs + PlanGlance.horizonMs).map { PlanEntry.Span(fromMs: $0.startMs, toMs: $0.endMs) } + return Face( + dayStartMs: start, + tomorrowStartMs: middle, + endMs: end, + quarters: quarters, + tomorrowPriced: quarters.contains { $0.turn == 1 && $0.priceMinor != nil }, + scale: scale, + cheapest: cheapest, + daylight: entry.daylight, + currency: entry.currency, + timeZoneID: calendar.timeZone.identifier + ) + } + + public static func action(_ batteryW: Double) -> Action { + batteryW > PlanGlance.idleW ? .store : (batteryW < -PlanGlance.idleW ? .use : .wait) + } + + // MARK: A finger on the dial + + /// Where a finger is on the spiral, in turns after today's midnight. + /// `x` and `y` run from the centre in parts of the radius, screen axes. + /// A finger coming down picks the turn whose bars it is nearest; one that + /// moves goes on round, onto tomorrow past midnight or back to today. + public static func progress(x: Double, y: Double, from previous: Double?) -> Double { + let f = dayFraction(x: x, y: y) + if let previous { + return [f - 1, f, f + 1, f + 2].min { abs($0 - previous) < abs($1 - previous) } ?? f + } + let r = (x * x + y * y).squareRoot() + func bars(_ p: Double) -> Double { abs(r - (Layout.spiral(p) + Layout.bar / 2)) } + return bars(f) <= bars(1 + f) ? f : 1 + f + } + + // MARK: Words + + /// Under an empty outer turn: when tomorrow's prices come, or that they + /// are late. Nil once they are in. + /// `short` for a widget, where two words is all there is room for. + public static func tomorrowNote(_ face: Face, nowMs: Double, short: Bool = false) -> String? { + // Before any price is known there is nothing to say about tomorrow's. + guard !face.tomorrowPriced, face.quarters.contains(where: { $0.turn == 0 && $0.priceMinor != nil }) else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = face.timeZone + let early = calendar.component(.hour, from: Date(timeIntervalSince1970: nowMs / 1000)) < 13 + switch (early, short) { + case (true, false): return PlanWord.pricesAt13.text + case (false, false): return PlanWord.pricesLate.text + case (true, true): return PlanWord.pricesAt13Short.text + case (false, true): return PlanWord.pricesLateShort.text + } + } + + /// "64 %" + public static func percent(_ v: Double) -> String { PlanGlance.percent(v) } + + /// The figures in the middle of the dial. + public struct Centre: Equatable, Sendable { + /// "14:15" + public let time: String + /// "65,5", nil where the box has no price. + public let price: String? + /// "öre/kWh" + public let unit: String + /// "Tomorrow", when the quarter is tomorrow's. + public let day: String? + } + + /// The clock and the price now, or at the quarter under a finger. + public static func centre(_ face: Face, nowMs: Double, selected: Int? = nil) -> Centre { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = face.timeZone + let i = selected.flatMap { face.quarters.indices.contains($0) ? $0 : nil } + let at = i.map { face.quarters[$0].startMs } ?? nowMs + let c = calendar.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: at / 1000)) + let quarter = i.map { face.quarters[$0] } ?? face.index(at: nowMs).map { face.quarters[$0] } + return Centre( + time: String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0), + price: quarter?.priceMinor.map { Words.decimal(PriceStrip.text($0, face.currency)) }, + unit: PriceUnits.unit(face.currency).perKwh, + day: quarter.flatMap { $0.turn == 1 ? PlanWord.tomorrow.text : nil } + ) + } + + private static func ms(_ d: Date) -> Double { d.timeIntervalSince1970 * 1000 } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index d18c804..948ad62 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -55,14 +55,72 @@ public struct PlanEntry: CacheValue { /// The household's time zone, as the box names it, so a widget reads the /// clock the way the home does. Nil in a record from before it was kept. public var timeZone: String? + /// Where the box's own solar forecast expects the panels to make power, + /// for the plan dial's sky. Nil when the app has not read the forecast. + public var daylight: Daylight? + /// The battery's level as the app last read it live. Nil when it had + /// no live reading. + public var batteryLevel: Level? + + /// A stretch of time. + public struct Span: Codable, Sendable, Equatable { + public var fromMs: Double + public var toMs: Double + + public init(fromMs: Double, toMs: Double) { + self.fromMs = fromMs + self.toMs = toMs + } + } + + /// The stretch the solar forecast covers, and the runs in it where it + /// expects more than nothing from the panels. + public struct Daylight: Codable, Sendable, Equatable { + public var covered: Span + public var sun: [Span] + + public init(covered: Span, sun: [Span]) { + self.covered = covered + self.sun = sun + } + + /// From the forecast's slots: start, length and expected watts. + public init?(_ slots: [(startMs: Double, durationMs: Double, pvW: Double)]) { + let sorted = slots.filter { $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } + guard let first = sorted.first, let last = sorted.last else { return nil } + var runs = [Span]() + for s in sorted where s.pvW > 0 { + let end = s.startMs + s.durationMs + if let open = runs.last, s.startMs <= open.toMs + 1 { + runs[runs.count - 1].toMs = max(open.toMs, end) + } else { + runs.append(Span(fromMs: s.startMs, toMs: end)) + } + } + self.init(covered: Span(fromMs: first.startMs, toMs: last.startMs + last.durationMs), sun: runs) + } + } + + /// A battery level, whole percent, and when it was read. + public struct Level: Codable, Sendable, Equatable { + public var percent: Double + public var readAtMs: Double + + public init(percent: Double, readAtMs: Double) { + self.percent = percent + self.readAtMs = readAtMs + } + } - public init(battery: [Slot]?, charges: [Charge], prices: [PriceSlot], currency: String, boxStale: Bool, timeZone: String? = nil) { + public init(battery: [Slot]?, charges: [Charge], prices: [PriceSlot], currency: String, boxStale: Bool, timeZone: String? = nil, daylight: Daylight? = nil, batteryLevel: Level? = nil) { self.battery = battery self.charges = charges self.prices = prices self.currency = currency self.boxStale = boxStale self.timeZone = timeZone + self.daylight = daylight + self.batteryLevel = batteryLevel } } @@ -352,6 +410,8 @@ public enum PlanGlance { public static var title: String { PlanWord.title.text } public static var summaryText: String { PlanWord.summary.text } + public static var dialTitle: String { PlanWord.dialTitle.text } + public static var dialSummary: String { PlanWord.dialSummary.text } } /// Every sentence the plan shows, on Now and in its widget. The English is @@ -379,6 +439,13 @@ enum PlanWord: String, CaseIterable { case cheapPower = "Cheapest power" case planned = "Planned %@" case planFrom = "Plan from %@ · open FTW" + case dialTitle = "Plan dial" + case dialSummary = "Today and tomorrow round a dial: the price, what the battery does and when the car charges." + case pricesAt13 = "Tomorrow's prices come around 13:00" + case pricesLate = "Tomorrow's prices are not in yet" + case pricesAt13Short = "Prices around 13:00" + case pricesLateShort = "No prices yet" + case tomorrow = "Tomorrow" var text: String { Words.bundle.localizedString(forKey: rawValue, value: rawValue, table: "Plan") } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings index 0d5a7ae..92a7565 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings @@ -20,3 +20,10 @@ "Cheapest power" = "Cheapest power"; "Planned %@" = "Planned %@"; "Plan from %@ · open FTW" = "Plan from %@ · open FTW"; +"Plan dial" = "Plan dial"; +"Today and tomorrow round a dial: the price, what the battery does and when the car charges." = "Today and tomorrow round a dial: the price, what the battery does and when the car charges."; +"Tomorrow's prices come around 13:00" = "Tomorrow's prices come around 13:00"; +"Tomorrow's prices are not in yet" = "Tomorrow's prices are not in yet"; +"Prices around 13:00" = "Prices around 13:00"; +"No prices yet" = "No prices yet"; +"Tomorrow" = "Tomorrow"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings index ad12ac7..0188f7f 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -20,3 +20,10 @@ "Cheapest power" = "Billigast el"; "Planned %@" = "Planerad %@"; "Plan from %@ · open FTW" = "Plan från %@ · öppna FTW"; +"Plan dial" = "Plantavla"; +"Today and tomorrow round a dial: the price, what the battery does and when the car charges." = "I dag och i morgon runt en tavla: priset, vad batteriet gör och när bilen laddar."; +"Tomorrow's prices come around 13:00" = "Morgondagens priser kommer runt 13:00"; +"Tomorrow's prices are not in yet" = "Morgondagens priser har inte kommit än"; +"Prices around 13:00" = "Priser runt 13:00"; +"No prices yet" = "Inga priser än"; +"Tomorrow" = "I morgon"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 0e9c210..d75b203 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -411,6 +411,41 @@ import FTWShared } } + /// A quarter on the plan dial: what the plan does with the battery and + /// why, the car, the price; tomorrow named; nothing claimed for a past + /// quarter the plan no longer covers. + @Test func aQuarterOnTheDialSaysWhatThePlanDoesTheCarAndThePrice() { + let zone = "Europe/Stockholm" + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: zone)! + func at(_ day: Int, _ h: Int, _ m: Int = 0) -> Double { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: h, minute: m))!.timeIntervalSince1970 * 1000 + } + let quarter = 900_000.0 + let battery = (0..<8).map { i in + PlanEntry.Slot(startMs: at(13, 22) + Double(i) * quarter, durationMs: quarter, batteryW: i < 4 ? -2_000 : 3_000, reason: i < 4 ? "expensive_import" : "cheap_import") + } + [PlanEntry.Slot(startMs: at(14, 0), durationMs: quarter, batteryW: 0, reason: "idle")] + let prices = (0..<48).map { PriceSlot(startMs: at(13, 0) + Double($0) * 3_600_000, durationMs: 3_600_000, spotMinor: 50, totalMinor: 65.5) } + let entry = PlanEntry(battery: battery, charges: [PlanEntry.Charge(loadpointID: "garage", fromMs: at(13, 23), toMs: at(13, 23, 30), energyWh: 5_000, targetPct: 80)], + prices: prices, currency: "SEK", boxStale: false, timeZone: zone) + let now = at(13, 21, 50) + let face = PlanDial.face(entry, nowMs: now) + func read(_ day: Int, _ h: Int, _ m: Int = 0) -> ChartReadout? { Readouts.dial(face, face.index(at: at(day, h, m))!, nowMs: now) } + AppWords.$language.withValue(.en) { + #expect(read(13, 22)?.when == "22:00–22:15") + #expect(read(13, 22)?.what == "battery is used · power is dear · 65.5 öre/kWh") + #expect(read(13, 23)?.what == "battery stores · power is cheap · car charges · 65.5 öre/kWh") + #expect(read(14, 0)?.when == "tomorrow 00:00–00:15") + #expect(read(14, 0)?.what == "battery waits · 65.5 öre/kWh") + #expect(read(14, 2)?.what == "not planned yet · 65.5 öre/kWh") + #expect(read(13, 9)?.what == "65.5 öre/kWh") + } + AppWords.$language.withValue(.sv) { + #expect(read(13, 22)?.what == "batteriet används · elen är dyr · 65,5 öre/kWh") + #expect(read(14, 0)?.when == "i morgon 00:00–00:15") + } + } + @Test func aSlotAheadSaysWhatThePlanDoesWhyAndThePrice() { AppWords.$language.withValue(.en) { let hour = 3_600_000.0 @@ -767,6 +802,7 @@ import FTWShared "appleApp/FTW/UI/RootView.swift", "appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift", "appleApp/FTW/UI/ChartScrub.swift", "appleApp/FTW/Car/CarView.swift", "appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift", "appleApp/FTW/UI/PriceView.swift", + "appleApp/FTW/UI/PlanDialCard.swift", ] let pattern = #/(?:\bL|NodeWords\.(?:text|format)|AppWords\.(?:text|format))\("((?:[^"\\]|\\.)*)"/# var english = Set() @@ -879,7 +915,12 @@ final class DemoRig { let charge = try #require(first.value.charges.first) #expect(charge.targetPct == 80) #expect(charge.toMs > charge.fromMs) - #expect(first.value == share.entry.trimmed(nowMs: site.nowMs)) + // The record also carries the battery's level, read live, for the + // plan dial on the lock screen. + var expected = share.entry.trimmed(nowMs: site.nowMs) + expected.batteryLevel = first.value.batteryLevel + #expect(first.value == expected) + #expect(first.value.batteryLevel?.percent == site.socPercent) let writes = wakes.count // The clock moving on rewrites nothing. @@ -896,4 +937,40 @@ final class DemoRig { #expect(wakes.count == writes + 1) plan.deactivate() } + + /// The plan dial's record: today and tomorrow whole, the sky from the + /// box's own forecast once read, and the battery's level read live. + @Test func theDialGetsTodayTomorrowTheSkyAndTheBattery() async throws { + let site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: nil, shared: shared) + site.start() + let loadpoints = LoadpointsModel(site: site, charging: nil, cache: nil) + let prices = PriceModel(site: site) + let plan = PlanModel(site: site) + let share = PlanShare(site: site, plan: plan, loadpoints: loadpoints, prices: prices) + site.connect(SimulatedBox.loopback(scheduler)) + loadpoints.activate() + prices.activate() + plan.activate() + await run(2_000) + let before = try #require(CacheReader(sources: [shared]).read(PlanEntry.self, siteId: "home")).value + // No forecast read yet: no sky, never one made up. + #expect(before.daylight == nil) + #expect(share.entry.daylight == nil) + let midnight = Calendar.household(before.timeZone).startOfDay(for: Date(timeIntervalSince1970: site.nowMs / 1000)).timeIntervalSince1970 * 1000 + // Today's prices from midnight, the hours already gone included. + #expect(before.prices.first?.startMs == midnight) + #expect(before.prices.allSatisfy { $0.startMs < midnight + 48 * 3_600_000 }) + #expect(before.batteryLevel?.percent == site.socPercent) + #expect(before.batteryLevel?.readAtMs ?? 0 <= site.nowMs) + + plan.activate(forecast: true) + await run(1_500) + let after = try #require(CacheReader(sources: [shared]).read(PlanEntry.self, siteId: "home")).value + let daylight = try #require(after.daylight) + #expect(daylight.covered.toMs > daylight.covered.fromMs) + #expect(daylight.sun.allSatisfy { $0.fromMs >= daylight.covered.fromMs && $0.toMs <= daylight.covered.toMs }) + #expect(daylight == PlanEntry.Daylight(plan.forecast.map { ($0.startMs, $0.durationMs, $0.pvW) })) + plan.deactivate(forecast: true) + plan.deactivate() + } } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift new file mode 100644 index 0000000..e22aae0 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -0,0 +1,243 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The plan dial, worked out from a `PlanEntry` alone. Times are +/// Stockholm's; 13 October 2026 is a Tuesday, and 25 October the night the +/// clocks go back. These read English: the tests run in the development +/// language. +@Suite struct PlanDialTests { + static let quarter: Double = 900_000 + static let hour: Double = 3_600_000 + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + func at(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Double { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + /// Hourly prices from Tuesday's midnight for `hours`; the plan from + /// 16:10 for a day: the battery gives power 17 to 19, charges 01 to 04 + /// on Wednesday, rests otherwise; the car charges 02:00 to 04:30. + func entry(priceHours: Int = 48, daylight: PlanEntry.Daylight? = nil) -> PlanEntry { + let midnight = at(13, 0) + let prices = (0..= 17 && h < 19 ? -2_000 : (h >= 1 && h < 4 ? 3_000 : 40) + battery.append(PlanEntry.Slot(startMs: t, durationMs: Self.quarter, batteryW: w, reason: w > 100 ? "cheap_import" : (w < -100 ? "expensive_import" : "idle"))) + t += Self.quarter + } + return PlanEntry( + battery: battery, + charges: [PlanEntry.Charge(loadpointID: "garage", fromMs: at(14, 2), toMs: at(14, 4, 30), energyWh: 20_000, targetPct: 80)], + prices: prices, + currency: "SEK", + boxStale: false, + timeZone: "Europe/Stockholm", + daylight: daylight + ) + } + + // MARK: The sun's path + + @Test func midnightAtTheBottomNoonAtTheTopUpOnTheLeft() { + func near(_ p: (x: Double, y: Double), _ x: Double, _ y: Double) -> Bool { abs(p.x - x) < 1e-9 && abs(p.y - y) < 1e-9 } + // Screen axes: y grows downward. + #expect(near(PlanDial.unit(0), 0, 1)) + #expect(near(PlanDial.unit(0.25), -1, 0)) + #expect(near(PlanDial.unit(0.5), 0, -1)) + #expect(near(PlanDial.unit(0.75), 1, 0)) + // The morning rises on the left and the evening falls on the right. + #expect(PlanDial.unit(9.0 / 24).y < PlanDial.unit(6.0 / 24).y) + #expect(PlanDial.unit(9.0 / 24).x < 0) + #expect(PlanDial.unit(15.0 / 24).x > 0) + #expect(PlanDial.unit(21.0 / 24).y > PlanDial.unit(18.0 / 24).y) + for f in stride(from: 0.0, to: 1, by: 0.03125) { + let u = PlanDial.unit(f) + #expect(abs(PlanDial.dayFraction(x: u.x * 0.6, y: u.y * 0.6) - f) < 1e-9) + } + } + + @Test func aBarNeverReachesTheTurnOutsideIt() { + let L = PlanDial.Layout.self + #expect(L.bar < L.turn) + #expect(L.spiral(2) + L.bar < L.rim - L.band / 2) + #expect(L.battery + L.batteryWidth / 2 < L.spiral(0) - L.barInward) + } + + // MARK: The face + + @Test func todayInsideTomorrowOutsideAQuarterABar() { + let face = PlanDial.face(entry(), nowMs: at(13, 16, 20)) + #expect(face.quarters.count == 192) + #expect(face.dayStartMs == at(13, 0) && face.tomorrowStartMs == at(14, 0) && face.endMs == at(15, 0)) + #expect(face.quarters.filter { $0.turn == 0 }.count == 96) + #expect(face.quarters[0].minuteOfDay == 0 && face.quarters[95].minuteOfDay == 23 * 60 + 45) + #expect(face.quarters[96].turn == 1 && face.quarters[96].minuteOfDay == 0) + #expect(face.tomorrowPriced) + // Each quarter of an hourly price stands as tall as that hour. + #expect(face.quarters[72...75].allSatisfy { $0.priceMinor == 200 }) + #expect(face.scale == 200) + #expect(face.index(at: at(13, 16, 20)) == 65) + #expect(face.index(at: at(12, 23)) == nil) + } + + @Test func theColourIsWhatThePlanDoesWithTheBattery() { + let face = PlanDial.face(entry(), nowMs: at(13, 16, 20)) + func q(_ day: Int, _ h: Int, _ m: Int = 0) -> PlanDial.Quarter { face.quarters[face.index(at: at(day, h, m))!] } + // Before the plan there is no plan: a price, no colour. + #expect(q(13, 9).action == .unplanned && q(13, 9).priceMinor == 80) + // The quarter the plan was made in is the plan's. + #expect(q(13, 16).action == .wait) + #expect(q(13, 17, 30).action == .use && q(13, 17, 30).reason == "expensive_import") + #expect(q(14, 2).action == .store) + #expect(q(14, 12).action == .wait) + // Past the plan's end, again nothing. + #expect(q(14, 20).action == .unplanned) + // The car: 02:00 to 04:30, ten quarters. + #expect(face.quarters.filter(\.car).count == 10) + #expect(q(14, 2).car && q(14, 4, 15).car && !q(14, 4, 30).car) + } + + @Test func noPricesForTomorrowLeaveTheOuterTurnEmpty() { + let face = PlanDial.face(entry(priceHours: 24), nowMs: at(13, 11)) + #expect(!face.tomorrowPriced) + #expect(face.quarters.filter { $0.turn == 1 }.allSatisfy { $0.priceMinor == nil }) + // Never a bar without a price; the plan's own colour stays, for + // the finger to read. + #expect(PlanDial.tomorrowNote(face, nowMs: at(13, 11)) == "Tomorrow's prices come around 13:00") + #expect(PlanDial.tomorrowNote(face, nowMs: at(13, 15)) == "Tomorrow's prices are not in yet") + #expect(PlanDial.tomorrowNote(face, nowMs: at(13, 11), short: true) == "Prices around 13:00") + #expect(PlanDial.tomorrowNote(PlanDial.face(entry(), nowMs: at(13, 15)), nowMs: at(13, 15)) == nil) + // Before any price at all there is nothing to say. + #expect(PlanDial.tomorrowNote(PlanDial.face(entry(priceHours: 0), nowMs: at(13, 11)), nowMs: at(13, 11)) == nil) + } + + @Test func theNightTheClocksGoBackHasTwentyFiveHours() { + var e = entry() + e.prices = [] + e.battery = nil + e.charges = [] + let face = PlanDial.face(e, nowMs: at(25, 12)) + // Sunday has 100 quarters, Monday 96. + #expect(face.quarters.count == 196) + #expect(face.quarters.filter { $0.turn == 0 }.count == 100) + // 02:00 to 03:00 comes twice and stands at the same place. + let twos = face.quarters.filter { $0.turn == 0 && $0.minuteOfDay == 120 } + #expect(twos.count == 2) + #expect(face.reach == nil) + } + + // MARK: A finger + + @Test func aFingerGoesOnRoundPastMidnight() { + let L = PlanDial.Layout.self + func spot(_ f: Double, _ r: Double) -> (Double, Double) { + let u = PlanDial.unit(f) + return (u.x * r, u.y * r) + } + // Coming down, the turn whose bars are nearest. + let inner = spot(0.9, L.spiral(0.9) + L.bar / 2) + #expect(abs(PlanDial.progress(x: inner.0, y: inner.1, from: nil) - 0.9) < 1e-9) + let outer = spot(0.3, L.spiral(1.3) + L.bar / 2) + #expect(abs(PlanDial.progress(x: outer.0, y: outer.1, from: nil) - 1.3) < 1e-9) + // Moving on past the bottom carries on into tomorrow, whatever the + // radius, and back again. + let past = spot(0.05, 0.4) + let p = PlanDial.progress(x: past.0, y: past.1, from: 0.97) + #expect(abs(p - 1.05) < 1e-9) + let back = spot(0.95, 0.9) + #expect(abs(PlanDial.progress(x: back.0, y: back.1, from: p) - 0.95) < 1e-9) + } + + @Test func theFingerStaysWhereThereIsAPriceOrAPlan() { + let face = PlanDial.face(entry(priceHours: 24), nowMs: at(13, 16, 20)) + // Today's prices from midnight, the plan into Wednesday afternoon. + let reach = try! #require(face.reach) + #expect(reach.lowerBound == 0) + #expect(face.quarters[reach.upperBound].startMs == at(14, 16)) + #expect(face.index(atProgress: 1.99) == reach.upperBound) + #expect(face.index(atProgress: 0.505) == face.index(at: at(13, 12))) + #expect(face.index(atProgress: -0.2) == 0) + } + + @Test func voiceOverStepsByTheHourFromNow() { + let face = PlanDial.face(entry(), nowMs: at(13, 16, 20)) + let stops = face.hourStops(nowMs: at(13, 16, 20)) + #expect(stops.count == 48 + 1) + #expect(stops.contains(65)) + #expect(stops.filter { $0 != 65 }.allSatisfy { face.quarters[$0].minuteOfDay % 60 == 0 }) + #expect(stops == stops.sorted()) + // From now an hour on, then back to now, and back to the hour before. + #expect(face.step(from: nil, forward: true, nowMs: at(13, 16, 20)) == face.index(at: at(13, 17))) + #expect(face.step(from: face.index(at: at(13, 17)), forward: false, nowMs: at(13, 16, 20)) == 65) + #expect(face.step(from: 65, forward: false, nowMs: at(13, 16, 20)) == face.index(at: at(13, 16))) + // The ends stop. + #expect(face.step(from: stops.last, forward: true, nowMs: at(13, 16, 20)) == nil) + #expect(face.step(from: 0, forward: false, nowMs: at(13, 16, 20)) == nil) + } + + @Test func theCheapestHoursAreLandmarks() { + let face = PlanDial.face(entry(), nowMs: at(13, 16, 20)) + let cheapest = try! #require(face.cheapest) + #expect(cheapest.fromMs == at(14, 2) && cheapest.toMs == at(14, 4)) + #expect(face.edgesCheapest(face.index(at: at(14, 2))!)) + #expect(face.edgesCheapest(face.index(at: at(14, 3, 45))!)) + #expect(!face.edgesCheapest(face.index(at: at(14, 3))!)) + } + + // MARK: The sky and the middle + + @Test func theSkyComesFromTheBoxsForecastOnly() { + #expect(PlanDial.face(entry(), nowMs: at(13, 16)).sky(nowMs: at(13, 16)).covered.isEmpty) + // Forecast slots from 16:00 for a day: sun until 17:30, again 07:30 + // to 17:30 on Wednesday. + var slots = [(startMs: Double, durationMs: Double, pvW: Double)]() + var t = at(13, 16) + while t < at(14, 16) { + let h = Double(calendar.component(.hour, from: Date(timeIntervalSince1970: t / 1000))) + Double(calendar.component(.minute, from: Date(timeIntervalSince1970: t / 1000))) / 60 + slots.append((t, Self.quarter, h >= 7.5 && h < 17.5 ? 1_200 : 0)) + t += Self.quarter + } + let daylight = try! #require(PlanEntry.Daylight(slots)) + #expect(daylight.covered == PlanEntry.Span(fromMs: at(13, 16), toMs: at(14, 16))) + #expect(daylight.sun == [PlanEntry.Span(fromMs: at(13, 16), toMs: at(13, 17, 30)), PlanEntry.Span(fromMs: at(14, 7, 30), toMs: at(14, 16))]) + let sky = PlanDial.face(entry(daylight: daylight), nowMs: at(13, 16)).sky(nowMs: at(13, 16)) + // The 24 hours from now round the rim: the whole ring, in two arcs + // cut at midnight. + #expect(sky.covered.count == 2) + #expect(abs(sky.covered[0].lowerBound - 16.0 / 24) < 1e-9 && abs(sky.covered[0].upperBound - 1) < 1e-9) + #expect(abs(sky.covered.map { $0.upperBound - $0.lowerBound }.reduce(0, +) - 1) < 1e-9) + // Sun from now to 17:30 and from 07:30 round to now. + #expect(sky.sun.count == 2) + #expect(abs(sky.sun[1].lowerBound - 7.5 / 24) < 1e-9 && abs(sky.sun[1].upperBound - 16.0 / 24) < 1e-9) + } + + @Test func theMiddleIsTheClockAndThePriceThere() { + let face = PlanDial.face(entry(), nowMs: at(13, 18, 7)) + let now = PlanDial.centre(face, nowMs: at(13, 18, 7)) + #expect(now.time == "18:07" && now.price == "200" && now.unit == "öre/kWh" && now.day == nil) + let night = PlanDial.centre(face, nowMs: at(13, 18, 7), selected: face.index(at: at(14, 2, 15))) + #expect(night.time == "02:15" && night.price == "20.0" && night.day == "Tomorrow") + } + + @Test func aRecordWithoutTheNewFieldsStillReads() throws { + var e = entry() + e.daylight = nil + e.batteryLevel = nil + var json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(e)) as! [String: Any] + json.removeValue(forKey: "daylight") + json.removeValue(forKey: "batteryLevel") + let back = try JSONDecoder().decode(PlanEntry.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(back == e) + } +} diff --git a/appleApp/FTWUITests/LockScreenTour.swift b/appleApp/FTWUITests/LockScreenTour.swift index 45ff91e..f2bd29b 100644 --- a/appleApp/FTWUITests/LockScreenTour.swift +++ b/appleApp/FTWUITests/LockScreenTour.swift @@ -28,10 +28,14 @@ final class LockScreenTour: XCTestCase { guard tap(ftw) else { return note("no FTW in the gallery") } Thread.sleep(forTimeInterval: 2) shoot("lock-ftw") - // FTW's widgets are pages: the price first, the plan second. A tap - // on the preview adds it. - springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.72)).press(forDuration: 0.1, thenDragTo: springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.72))) - Thread.sleep(forTimeInterval: 1.5) + // FTW's widgets are pages: the price first, the plan second, the + // plan dial's circle after them. `TEST_RUNNER_FTW_LOCK_PAGE` says + // how many to swipe past; a tap on the preview adds it. + let page = Int(ProcessInfo.processInfo.environment["FTW_LOCK_PAGE"] ?? "") ?? 1 + for _ in 0.. XCUICoordinate { + let angle = p.0 * 2 * .pi + return centre.withOffset(CGVector(dx: -sin(angle) * p.1 * r, dy: cos(angle) * p.1 * r)) + } + at(a).press(forDuration: 0.25, thenDragTo: at(b), withVelocity: XCUIGestureVelocity(110), thenHoldForDuration: 1.2) + Thread.sleep(forTimeInterval: 0.8) + } + + @MainActor + private func shoot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/appleApp/FTWUITests/WidgetTour.swift b/appleApp/FTWUITests/WidgetTour.swift new file mode 100644 index 0000000..24ab61b --- /dev/null +++ b/appleApp/FTWUITests/WidgetTour.swift @@ -0,0 +1,25 @@ +import XCTest + +/// Photographs the first page of the home screen, with whatever widgets +/// were placed on it (`IconState.plist`, before boot). Skipped unless asked +/// for: +/// +/// TEST_RUNNER_FTW_WIDGETS=home xcodebuild test … -only-testing:FTWUITests/WidgetTour +@MainActor +final class WidgetTour: XCTestCase { + func testHomeScreen() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["FTW_WIDGETS"] == "home", "Set TEST_RUNNER_FTW_WIDGETS=home to photograph the home screen.") + XCUIDevice.shared.press(.home) + Thread.sleep(forTimeInterval: 1) + XCUIDevice.shared.press(.home) + Thread.sleep(forTimeInterval: 4) + shoot("home") + } + + private func shoot(_ name: String) { + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/appleApp/FTWWidgets/Plan/PlanDialWidget.swift b/appleApp/FTWWidgets/Plan/PlanDialWidget.swift new file mode 100644 index 0000000..2f66ea1 --- /dev/null +++ b/appleApp/FTWWidgets/Plan/PlanDialWidget.swift @@ -0,0 +1,275 @@ +import FTWShared +import SwiftUI +import WidgetKit + +/// The plan dial on the home screen, in StandBy and on the lock screen, +/// from the plan the app left in the App Group. It opens no connection. +/// The hand moves on by the quarter hour; an hour after the plan was made +/// the dial goes grey and says how old the plan is. +struct PlanDialWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "energy.ftw.plan-dial", provider: PlanDialTimeline()) { entry in + PlanDialWidgetView(entry: entry) + } + .configurationDisplayName(PlanGlance.dialTitle) + .description(PlanGlance.dialSummary) + .supportedFamilies([.systemSmall, .accessoryCircular]) + // The dial is round and fills its square; it keeps its own margin. + .contentMarginsDisabled() + } +} + +struct PlanDialEntry: TimelineEntry { + let date: Date + /// Nil when the app has left no plan. + let face: PlanDial.Face? + let moment: PlanGlance.Moment + let level: PlanEntry.Level? + + var nowMs: Double { date.timeIntervalSince1970 * 1000 } +} + +struct PlanDialTimeline: TimelineProvider { + /// How far ahead one timeline runs; WidgetKit asks again at its end. + static let aheadMs: Double = 12 * 3_600_000 + + func placeholder(in context: Context) -> PlanDialEntry { + let now = Date.now.timeIntervalSince1970 * 1000 + return Self.entries(PlanDialTimeline.sample(nowMs: now), now: now, count: 1)[0] + } + + func getSnapshot(in context: Context, completion: @escaping (PlanDialEntry) -> Void) { + let now = Date.now.timeIntervalSince1970 * 1000 + let read = Self.read() + // The gallery shows the shape of a plan, not an empty dial. + completion(Self.entries(read == nil && context.isPreview ? Self.sample(nowMs: now) : read, now: now, count: 1)[0]) + } + + /// One entry now and one at every quarter hour after it, so the hand + /// and the dimming move on. The app reloads every timeline after it + /// writes a new plan. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date.now.timeIntervalSince1970 * 1000 + let entries = Self.entries(Self.read(), now: now, count: Int(Self.aheadMs / PlanDial.quarterMs) + 1) + completion(Timeline(entries: entries, policy: .atEnd)) + } + + private static func read() -> CacheRead? { + let reader = CacheReader.shared() + return reader.flatMap { r in r.latestSiteID(PlanEntry.self).flatMap { r.read(PlanEntry.self, siteId: $0) } } + } + + /// The face is worked out once per day it shows, not once per entry. + static func entries(_ read: CacheRead?, now: Double, count: Int) -> [PlanDialEntry] { + let calendar = Calendar.household(read?.value.timeZone) + var faces = [Date: PlanDial.Face]() + var out = [PlanDialEntry]() + var t = now + for k in 0.. 0 { t = ((now / PlanDial.quarterMs).rounded(.down) + Double(k)) * PlanDial.quarterMs } + let day = calendar.startOfDay(for: Date(timeIntervalSince1970: t / 1000)) + var face: PlanDial.Face? + if let read { + face = faces[day] ?? PlanDial.face(read.value, nowMs: t, calendar: calendar) + faces[day] = face + } + out.append(PlanDialEntry( + date: Date(timeIntervalSince1970: t / 1000), + face: face, + moment: PlanGlance.moment(read, atMs: t, calendar: calendar), + level: read?.value.batteryLevel + )) + } + return out + } + + /// A day's plan for the gallery and the placeholder. Never shown as the + /// home's plan. + static func sample(nowMs: Double) -> CacheRead { + let hour: Double = 3_600_000 + let calendar = Calendar.current + let start = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)).timeIntervalSince1970 * 1000 + let battery = (0..<96).map { i -> PlanEntry.Slot in + let h = Double(i) / 4 + let w: Double = h >= 2 && h < 5 ? 3_000 : (h >= 10 && h < 15 ? 2_500 : (h >= 17 && h < 21 ? -2_000 : 0)) + return PlanEntry.Slot(startMs: start + Double(i) * hour / 4, durationMs: hour / 4, batteryW: w, reason: "idle") + } + let prices = (0..<24).map { i -> PriceSlot in + let wave: Double = sin(Double(i - 9) / 24 * 2 * Double.pi) + let total: Double = 110 + 50 * wave + Double((i * 37) % 20) + return PriceSlot(startMs: start + Double(i) * hour, durationMs: hour, spotMinor: 40, totalMinor: total) + } + let entry = PlanEntry( + battery: battery, + charges: [.init(loadpointID: "car", fromMs: start + 2 * hour, toMs: start + 5 * hour, energyWh: 20_000, targetPct: 80)], + prices: prices, + currency: "SEK", + boxStale: false, + daylight: .init(covered: .init(fromMs: start, toMs: start + 48 * hour), sun: [.init(fromMs: start + 7.5 * hour, toMs: start + 17.5 * hour), .init(fromMs: start + 31.5 * hour, toMs: start + 41.5 * hour)]), + batteryLevel: .init(percent: 64, readAtMs: nowMs) + ) + return CacheRead(record: CacheRecord(siteId: "", carrier: .none, srcState: .live, readAtMs: nowMs, value: entry), origin: .app) + } +} + +/// The battery's level as a widget may show it: plainly while fresh, +/// quietly for a while, then not at all. +enum WidgetLevel { + static let freshMs: Double = 15 * 60_000 + + static func shown(_ level: PlanEntry.Level?, at t: Double) -> (pct: Double, old: Bool)? { + guard let level else { return nil } + let age = t - level.readAtMs + guard age < PlanGlance.staleAfterMs else { return nil } + return (level.percent, age >= freshMs) + } +} + +struct PlanDialWidgetView: View { + let entry: PlanDialEntry + @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + content + .containerBackground(for: .widget) { + // The glass's own dark, so the dial and its square are one. + Rectangle().fill(family == .systemSmall ? AnyShapeStyle(PlanDialPalette.widget.glass) : AnyShapeStyle(.clear)) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(PlanGlance.dialTitle) + .accessibilityValue(spoken) + } + + @ViewBuilder private var content: some View { + switch family { + case .accessoryCircular: + CircularDial(entry: entry) + default: + if let face = entry.face { + let level = WidgetLevel.shown(entry.level, at: entry.nowMs) + PlanDialView( + face: face, + nowMs: entry.nowMs, + palette: .widget, + batteryPct: level?.pct, + batteryOld: level?.old ?? false, + labels: false, + compact: true, + grey: entry.moment.isGrey, + monochrome: renderingMode != .fullColor, + still: true, + note: PlanDial.tomorrowNote(face, nowMs: entry.nowMs, short: true), + caption: entry.moment.isGrey ? entry.moment.ageText.map(Self.short) : nil + ) + .padding(6) + } else { + Text(entry.moment.summary) + .font(.footnote.weight(.medium)) + .foregroundStyle(Pair(0xA0A0A0, 0xA0A0A0)) + .multilineTextAlignment(.center) + .padding(16) + } + } + } + + /// "Plan from 14:05 · open FTW" is too long for the middle: the time + /// alone, after the plan's own word. + private static func short(_ age: String) -> String { + age.components(separatedBy: " · ").first ?? age + } + + private var spoken: String { + var parts = [entry.moment.summary] + if let level = WidgetLevel.shown(entry.level, at: entry.nowMs) { parts.append(PlanDial.percent(level.pct)) } + if let age = entry.moment.ageText { parts.append(age) } + return parts.joined(separator: ", ") + } +} + +/// The lock screen's circle: today round the rim as short ticks, the hand +/// at now, and the battery as a ring around its level. +struct CircularDial: View { + let entry: PlanDialEntry + + var body: some View { + let level = WidgetLevel.shown(entry.level, at: entry.nowMs) + ZStack { + AccessoryWidgetBackground() + Canvas { ctx, size in draw(&ctx, size: size) } + if let level { + Circle() + .trim(from: 0, to: max(0, min(1, level.pct / 100))) + .stroke(style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(90)) + .padding(13) + .opacity(level.old ? 0.5 : 1) + .widgetAccentable() + Text(verbatim: "\(Int(level.pct.rounded()))%") + .font(.system(size: 15, weight: .semibold, design: .rounded).monospacedDigit()) + .lineLimit(1) + .minimumScaleFactor(0.6) + .opacity(level.old ? 0.6 : 1) + .padding(.horizontal, 15) + } else { + Image(systemName: "calendar.day.timeline.left") + .font(.system(size: 13, weight: .semibold)) + .opacity(0.7) + } + } + } + + private func draw(_ ctx: inout GraphicsContext, size: CGSize) { + guard let face = entry.face else { return } + let c = CGPoint(x: size.width / 2, y: size.height / 2) + let r = Double(min(size.width, size.height)) / 2 + let now = face.index(at: entry.nowMs) ?? -1 + let turn = now >= 0 ? face.quarters[now].turn : 0 + let grey = entry.moment.isGrey + func point(_ f: Double, _ rr: Double) -> CGPoint { + let u = PlanDial.unit(f) + return CGPoint(x: c.x + u.x * rr, y: c.y + u.y * rr) + } + // Today's quarters, or tomorrow's once it is tomorrow's turn: a tick + // each, as long as the price, as strong as what the battery does. + for (i, q) in face.quarters.enumerated() where q.turn == turn { + guard let price = q.priceMinor else { continue } + var alpha: Double + switch grey ? .wait : q.action { + case .store: alpha = 1 + case .use: alpha = 0.75 + case .wait: alpha = 0.45 + case .unplanned: alpha = 0.3 + } + if i < now { alpha *= 0.45 } + let length = max(0.05, min(1, abs(price) / face.scale) * 0.2) + var tick = Path() + tick.move(to: point(q.dayFraction, r * 0.75)) + tick.addLine(to: point(q.dayFraction, r * (0.75 + length))) + ctx.stroke(tick, with: .color(.white.opacity(alpha)), lineWidth: max(1, 2 * Double.pi * r * 0.8 / 96 * 0.7)) + } + // Now. + let f = face.progress(at: entry.nowMs).truncatingRemainder(dividingBy: 1) + var hand = Path() + hand.move(to: point(f, r * 0.66)) + hand.addLine(to: point(f, r * 0.99)) + ctx.stroke(hand, with: .color(.white), style: StrokeStyle(lineWidth: 2, lineCap: .round)) + } +} + +extension PlanDialPalette { + /// The app's tokens as they resolve dark (`Theme.xcassets`), since the + /// dial sits on its own dark glass: cyan stores, white is used, grey + /// waits, gold is the sun. + static let widget = PlanDialPalette( + store: Pair(0x13DCF6, 0x13DCF6).dark, + use: Pair(0xE8E8E8, 0xE8E8E8).dark, + wait: Pair(0x858585, 0x858585).dark, + car: Pair(0xCCA8FF, 0xCCA8FF).dark, + sun: Pair(0xFFB000, 0xFFB000).dark, + hand: Pair(0xE8E8E8, 0xE8E8E8).dark, + battery: Pair(0x13DCF6, 0x13DCF6).dark, + quiet: Pair(0xA0A0A0, 0xA0A0A0).dark, + glass: Pair(0x0D0D0D, 0x0D0D0D).dark + ) +} diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index ce61cbd..8b87bb3 100644 --- a/appleApp/FTWWidgets/PriceWidget.swift +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -11,6 +11,7 @@ struct FTWWidgets: WidgetBundle { var body: some Widget { PriceWidget() PlanWidget() + PlanDialWidget() ChargeNowControl() ChargeNowLiveActivity() } From 0151f4a4d97b2dd978309e37cadc1b6e47b00d5a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 17:22:52 +0200 Subject: [PATCH 2/3] Plan dial tests: split two expressions CI's compiler timed out on The runner could not type-check a nested ternary in the test's price fixture in time. A small function and written-out types take it well under 80 ms here. Co-Authored-By: Claude Opus 5.5 --- .../Tests/FTWSharedTests/PlanDialTests.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift index e22aae0..f62c68a 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -24,8 +24,15 @@ import Testing /// on Wednesday, rests otherwise; the car charges 02:00 to 04:30. func entry(priceHours: Int = 48, daylight: PlanEntry.Daylight? = nil) -> PlanEntry { let midnight = at(13, 0) - let prices = (0.. Double { + switch i % 24 { + case 18: return 200 + case 2, 3: return 20 + default: return 80 + } + } + let prices = (0.. PriceSlot in + PriceSlot(startMs: midnight + Double(i) * Self.hour, durationMs: Self.hour, spotMinor: 50, totalMinor: price(i)) } let from = at(13, 16, 10) var battery = [PlanEntry.Slot(startMs: from, durationMs: at(13, 16, 15) - from, batteryW: 0, reason: "idle")] @@ -204,8 +211,10 @@ import Testing var slots = [(startMs: Double, durationMs: Double, pvW: Double)]() var t = at(13, 16) while t < at(14, 16) { - let h = Double(calendar.component(.hour, from: Date(timeIntervalSince1970: t / 1000))) + Double(calendar.component(.minute, from: Date(timeIntervalSince1970: t / 1000))) / 60 - slots.append((t, Self.quarter, h >= 7.5 && h < 17.5 ? 1_200 : 0)) + let date = Date(timeIntervalSince1970: t / 1000) + let h: Double = Double(calendar.component(.hour, from: date)) + Double(calendar.component(.minute, from: date)) / 60 + let pv: Double = h >= 7.5 && h < 17.5 ? 1_200 : 0 + slots.append((startMs: t, durationMs: Self.quarter, pvW: pv)) t += Self.quarter } let daylight = try! #require(PlanEntry.Daylight(slots)) From bbcb787c2cb66063b1f0062f7daa50d42a498952 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 18:19:00 +0200 Subject: [PATCH 3/3] Plan dial: the lanes one tap away, and a scrub that redraws only the mark Fredrik's answers on #27: - The plan view opens on the dial; a segmented switch at the top (Tavla | Banor) brings back the 12-hour lanes, and the phone keeps the viewer's choice (@AppStorage "FTWPlanStyle"). With the lanes, the event rows use the lanes' colours again. - On the dial gold stays the sun's; Now's lanes keep gold for discharge. The scrub, profiled in Instruments: - A drag event is arithmetic and one lookup in a table of the two days' 192 quarters (Face.index(atProgress:), was a scan), and writes nothing a view reads. - A change of quarter moves one @Observable PlanDialSelection, read only by the mark, the middle, the line under the dial and a small view that holds the haptics. The card, the face, the bars and the hand stay put. With .equatable() alone SwiftUI still re-ran the bars and the hand. - The sky and the hand are worked out in their own layers, so a redraw of the face does not pay for them. - Two Points of Interest signposts stay: "dial drag" and "dial quarter". Tests: the finger's lookup on the nights the clocks change; a UI test that the lanes are one tap away and stay after a new launch. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 16 ++- appleApp/FTW/UI/PlanDialCard.swift | 90 +++++++++--- appleApp/FTW/UI/PlanView.swift | 129 +++++++++++++++--- appleApp/FTWCharts/PlanDialView.swift | 77 +++++++---- .../Sources/FTWKit/State/NodeWords.swift | 3 + .../FTWKit/Sources/FTWShared/PlanDial.swift | 28 +++- .../Tests/FTWSharedTests/PlanDialTests.swift | 26 ++++ appleApp/FTWUITests/PlanDialTests.swift | 42 +++++- appleApp/FTWUITests/PlanDialTour.swift | 26 ++-- 9 files changed, 349 insertions(+), 88 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c1b26a..a7800dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,8 +152,8 @@ His rules (2026-10-13): 2. One bar per quarter hour, as tall as the price. Its colour is what the plan does with the battery: `Theme.storage` stores, `Theme.fg` (white on the glass) uses, `Theme.fgMuted` waits. A dot in `Theme.mobility` marks - a quarter when the car charges. Gold is the sun's colour, never the - battery's. + a quarter when the car charges. On the dial, gold is the sun's colour, + never the battery's. 3. In the middle: the battery as a ring, then the time and the price in mono figures. 4. A hand marks now, and what has passed dims. @@ -168,10 +168,14 @@ His rules (2026-10-13): and the lock screen too. `FTWShared/PlanDial.swift` holds the geometry, the quarters and the rules -for a finger; `FTWCharts/PlanDialView.swift` draws. The plan view puts the -dial at its top, and a finger dragged round it scrubs the plan. The -`energy.ftw.plan-dial` widget shows it small, in StandBy and as a circle on -the lock screen. Now keeps the straight plan and never scrolls. +for a finger; `FTWCharts/PlanDialView.swift` draws. The plan view shows the +dial by default, and a finger dragged round it scrubs the plan; a switch +at the top brings back the 12-hour lanes, and the phone keeps the +viewer's choice. A drag event costs one lookup; only a change of quarter +redraws, and then only the mark, the middle and the line under the dial. +The `energy.ftw.plan-dial` widget shows the dial small, in StandBy and as +a circle on the lock screen. Now keeps the straight plan and never +scrolls. ## Non-negotiable invariants diff --git a/appleApp/FTW/UI/PlanDialCard.swift b/appleApp/FTW/UI/PlanDialCard.swift index 358fcfc..0b6e46c 100644 --- a/appleApp/FTW/UI/PlanDialCard.swift +++ b/appleApp/FTW/UI/PlanDialCard.swift @@ -1,6 +1,11 @@ import FTWKit +import os import SwiftUI +/// Points of interest for Instruments: each drag event on the dial, and +/// each change of the quarter under the finger. +private let signposts = OSSignposter(subsystem: "energy.ftw.app", category: .pointsOfInterest) + /// The plan dial at the top of the plan view. A finger dragged round it /// scrubs the plan a quarter at a time, onto tomorrow past midnight and /// back; the line under it says what is planned there and springs back to @@ -20,19 +25,16 @@ struct PlanDialCard: View { @State private var face: PlanDial.Face? @State private var size: CGSize = .zero - /// The finger, in turns along the spiral, and the quarter under it. - @State private var progress: Double? - @State private var selected: Int? + /// The quarter under the finger, or where VoiceOver stepped. The card + /// does not read it, so a move redraws only what shows it. + @State private var selection = PlanDialSelection() + @State private var finger = Finger() /// Where VoiceOver has stepped to. @State private var spoken: Int? - @State private var ticks = 0 - @State private var bumps = 0 - @State private var lastTick = Date.distantPast @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let face = self.face ?? PlanDial.face(entry, nowMs: nowMs) - let marked = selected ?? spoken VStack(alignment: .leading, spacing: 8) { PlanDialView( face: face, @@ -40,7 +42,7 @@ struct PlanDialCard: View { palette: .app, batteryPct: batteryPct, batteryOld: batteryOld, - selected: marked, + selection: selection, still: reduceMotion || PowerState.shared.lowPower, fetching: fetching, note: note(face) @@ -57,12 +59,10 @@ struct PlanDialCard: View { .accessibilityHint(L("Swipe up or down to move an hour.")) .accessibilityAdjustableAction { step(face, $0) } .accessibilityIdentifier("plan.dial") - ReadoutLine(readout: marked.flatMap { Readouts.dial(face, $0, nowMs: nowMs) }, otherwise: summary) - .accessibilityHidden(marked != nil) + .background { ScrubHaptics(finger: finger) } + DialReadout(face: face, selection: selection, nowMs: nowMs, summary: summary) DialLegend(car: face.quarters.contains(where: \.car)) } - .sensoryFeedback(.selection, trigger: ticks) - .sensoryFeedback(.impact(weight: .light), trigger: bumps) .onChange(of: FaceKey(entry: entry, quarter: Int(nowMs / PlanDial.quarterMs)), initial: true) { self.face = PlanDial.face(entry, nowMs: nowMs) } @@ -76,9 +76,15 @@ struct PlanDialCard: View { // MARK: A finger + /// Each drag event costs a little arithmetic and one lookup and writes + /// nothing a view reads. A change of quarter moves `selection`: the + /// mark, the middle and the line under the dial redraw, and a haptic + /// plays. private func scrub(_ face: PlanDial.Face) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { drag in + let event = signposts.beginInterval("dial drag") + defer { signposts.endInterval("dial drag", event) } guard let reach = face.reach, size.width > 0 else { return } let radius = min(size.width, size.height) / 2 / 1.14 let x = (drag.location.x - size.width / 2) / radius @@ -86,20 +92,21 @@ struct PlanDialCard: View { // Past either end the finger winds no further, so turning // back answers at once. let lo = face.quarters[reach.lowerBound].progress, hi = face.quarters[reach.upperBound].progress - let p = min(hi + 0.01, max(lo - 0.01, PlanDial.progress(x: x, y: y, from: progress))) - progress = p - guard let i = face.index(atProgress: p), i != selected else { return } - selected = i + let p = min(hi + 0.01, max(lo - 0.01, PlanDial.progress(x: x, y: y, from: finger.progress))) + finger.progress = p + guard let i = face.index(atProgress: p), i != selection.index else { return } + signposts.emitEvent("dial quarter") + selection.index = i if i == face.index(at: nowMs) || face.edgesCheapest(i) { - bumps += 1 - } else if Date().timeIntervalSince(lastTick) >= 0.04 { - lastTick = Date() - ticks += 1 + finger.bumps += 1 + } else if drag.time.timeIntervalSince(finger.lastTick) >= 0.04 { + finger.lastTick = drag.time + finger.ticks += 1 } } .onEnded { _ in - progress = nil - selected = nil + finger.progress = nil + selection.index = spoken } } @@ -115,6 +122,7 @@ struct PlanDialCard: View { @unknown default: return } spoken = face.step(from: spoken, forward: forward, nowMs: nowMs) ?? spoken + selection.index = spoken } /// "14:00–14:15, battery stores · spare solar · 65,5 öre/kWh", and the @@ -130,6 +138,44 @@ struct PlanDialCard: View { } } +/// The finger: where it is on the spiral and when it last ticked, written +/// on every drag event and read by nothing that draws, and the haptics' +/// counts, read only by `ScrubHaptics`. +@MainActor +@Observable +private final class Finger { + @ObservationIgnored var progress: Double? + @ObservationIgnored var lastTick = Date.distantPast + var ticks = 0 + var bumps = 0 +} + +/// The haptics, in a view of their own so a tick redraws nothing else. +private struct ScrubHaptics: View { + let finger: Finger + + var body: some View { + Color.clear + .sensoryFeedback(.selection, trigger: finger.ticks) + .sensoryFeedback(.impact(weight: .light), trigger: finger.bumps) + } +} + +/// The line under the dial: what is planned at the marked quarter, or the +/// plan's own sentence. It alone of the card reads the selection. +private struct DialReadout: View { + let face: PlanDial.Face + let selection: PlanDialSelection + let nowMs: Double + let summary: String + + var body: some View { + let marked = selection.index + ReadoutLine(readout: marked.flatMap { Readouts.dial(face, $0, nowMs: nowMs) }, otherwise: summary) + .accessibilityHidden(marked != nil) + } +} + /// What makes the face out of date: the plan, and the quarter, since the /// cheapest stretch is reckoned from now. private struct FaceKey: Equatable { diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 1e8b26c..c843245 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -1,19 +1,23 @@ import FTWKit import SwiftUI -/// The plan, opened from Now: today and tomorrow round the plan dial, then -/// each thing the box means to do, when, and why, in the box's own -/// reasons. A finger on the dial scrubs the plan. +/// The plan, opened from Now: today and tomorrow round the plan dial, or +/// the next twelve hours in lanes as on Now, then each thing the box means +/// to do, when, and why, in the box's own reasons. A finger on the dial +/// scrubs the plan. struct PlanView: View { let app: AppModel let home: HomeModels @State private var choosingMode = false + /// The viewer's own choice, kept on this phone. `-FTWPlanStyle lanes` + /// at launch holds it there, for screenshots and tests. + @AppStorage("FTWPlanStyle") private var style: PlanStyle = .dial private var models: NodeModels { home.nodes } var body: some View { - FitScreen(spacing: 10) { _ in - PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, chooseMode: { choosingMode = true }) + FitScreen(spacing: 10) { fill in + PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: $style, fill: fill, chooseMode: { choosingMode = true }) } .navigationTitle(PlanGlance.title) .navigationBarTitleDisplayMode(.inline) @@ -36,11 +40,26 @@ struct PlanView: View { } } +/// How the plan view draws the plan: round on the dial, or straight in +/// lanes as on Now. +enum PlanStyle: String, CaseIterable { + case dial, lanes + + var label: String { + switch self { + case .dial: return L("Dial") + case .lanes: return L("Lanes") + } + } +} + private struct PlanBody: View { let site: SiteModel let models: NodeModels let loadpoints: LoadpointsModel let prices: PriceModel + @Binding var style: PlanStyle + let fill: Bool let chooseMode: () -> Void var body: some View { @@ -48,23 +67,53 @@ private struct PlanBody: View { let minute = (site.nowMs / 60_000).rounded(.down) * 60_000 let entry = models.planShare.entry let moment = PlanGlance.live(entry, nowMs: minute) - PlanDialCard( - entry: entry, - nowMs: minute, - batteryPct: site.socPercent, - batteryOld: !site.isLive, - fetching: prices.fetching, - summary: moment.summary - ) - Text(stateLine(moment)) - .font(.footnote) - .foregroundStyle(moment.state == .boxStale ? Theme.generation : Theme.fgDim) - .fixedSize(horizontal: false, vertical: true) + Picker(L("Show the plan as"), selection: $style.animation(Theme.spring)) { + ForEach(PlanStyle.allCases, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("plan.style") + switch style { + case .dial: + PlanDialCard( + entry: entry, + nowMs: minute, + batteryPct: site.socPercent, + batteryOld: !site.isLive, + fetching: prices.fetching, + summary: moment.summary + ) + stateText(moment) + case .lanes: + VStack(alignment: .leading, spacing: 4) { + Text(moment.summary) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + stateText(moment) + } + VStack(alignment: .leading, spacing: 6) { + PlanLanes(moment: moment) + .equatable() + .frame(minHeight: fill ? 84 : 140, idealHeight: fill ? 84 : 140, maxHeight: fill ? 200 : 140) + Legend(kinds: Set(moment.events.map(\.kind))) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + } PriceRow(prices: prices, minute: minute) - Events(moment: moment, entry: models.planShare.entry, loadpoints: loadpoints) + Events(moment: moment, entry: models.planShare.entry, loadpoints: loadpoints, style: style) CompactMode(mode: models.mode, choose: chooseMode) } + private func stateText(_ m: PlanGlance.Moment) -> some View { + Text(stateLine(m)) + .font(.footnote) + .foregroundStyle(m.state == .boxStale ? Theme.generation : Theme.fgDim) + .fixedSize(horizontal: false, vertical: true) + } + /// When the box made it, or that it could not. private func stateLine(_ m: PlanGlance.Moment) -> String { if m.state == .boxStale { return L("Your box couldn't plan ahead just now, so it runs on safe defaults.") } @@ -73,11 +122,42 @@ private struct PlanBody: View { } } -/// Each thing the box means to do, when, and why. +/// What each colour on the lanes means. +private struct Legend: View { + let kinds: Set + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { items } + VStack(alignment: .leading, spacing: 4) { items } + } + .font(.caption2) + .foregroundStyle(Theme.fgDim) + } + + /// Only what the lanes hold, in short words: the rows below say the rest. + @ViewBuilder private var items: some View { + if kinds.contains(.car) { item(.car, L("Car")) } + if kinds.contains(.charge) { item(.charge, L("charging")) } + if kinds.contains(.discharge) { item(.discharge, L("discharging")) } + if kinds.contains(.cheap) { item(.cheap, L("Cheapest")) } + } + + private func item(_ kind: PlanGlance.Kind, _ text: String) -> some View { + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 2).fill(PlanLanes.color(kind)).frame(width: 10, height: 6) + Text(text).lineLimit(1) + } + } +} + +/// Each thing the box means to do, when, and why, marked as the plan above +/// draws it. private struct Events: View { let moment: PlanGlance.Moment let entry: PlanEntry let loadpoints: LoadpointsModel + let style: PlanStyle var body: some View { VStack(spacing: 0) { @@ -109,8 +189,15 @@ private struct Events: View { private func row(_ e: PlanGlance.Event) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { - DialSwatch(kind: e.kind) - .alignmentGuide(.firstTextBaseline) { $0[.bottom] - 2 } + Group { + switch style { + case .dial: + DialSwatch(kind: e.kind) + case .lanes: + RoundedRectangle(cornerRadius: 2).fill(PlanLanes.color(e.kind)).frame(width: 10, height: 6) + } + } + .alignmentGuide(.firstTextBaseline) { $0[.bottom] - (style == .dial ? 2 : 0) } VStack(alignment: .leading, spacing: 1) { Text(PlanGlance.what(e)) .font(.subheadline.weight(.semibold)) diff --git a/appleApp/FTWCharts/PlanDialView.swift b/appleApp/FTWCharts/PlanDialView.swift index ac0f4ce..79339e1 100644 --- a/appleApp/FTWCharts/PlanDialView.swift +++ b/appleApp/FTWCharts/PlanDialView.swift @@ -25,6 +25,15 @@ struct PlanDialPalette: Equatable { var glass: Color } +/// The quarter a finger, VoiceOver or later the Crown marks on the dial. +/// Only the mark and the middle read it, so when it moves they redraw and +/// nothing else does. +@MainActor +@Observable +final class PlanDialSelection { + var index: Int? +} + /// FTW's plan as a round dial: today the inner turn of a spiral, tomorrow /// the outer, a bar per quarter hour as tall as the price and coloured by /// what the plan does with the battery. Midnight at the bottom, noon at the @@ -33,10 +42,10 @@ struct PlanDialPalette: Equatable { /// the time and the price. /// /// The app, the widgets and later the Watch draw this same view. It draws; -/// a finger or the Crown belongs to whoever holds it, and hands it the -/// quarter to mark as `selected`. Each layer redraws only when what it -/// shows changes: the bars when the plan or the quarter does, the hand by -/// the minute, the mark as the finger moves. +/// a finger or the Crown belongs to whoever holds it, and marks a quarter +/// through `selection`. Each layer redraws only when what it shows +/// changes: the bars when the plan or the quarter does, the hand by the +/// minute, the mark and the middle as the finger moves. struct PlanDialView: View { let face: PlanDial.Face let nowMs: Double @@ -45,8 +54,9 @@ struct PlanDialView: View { var batteryPct: Double? /// The reading is old: the ring shows it quietly. var batteryOld = false - /// The quarter under a finger or the Crown. - var selected: Int? + /// The quarter under a finger or the Crown. Nil where nothing can + /// mark one, as in a widget. + var selection: PlanDialSelection? /// Hour labels inside the rim. The app has room for them. var labels = true /// The middle without the price's unit, for a widget's small dial. @@ -76,7 +86,7 @@ struct PlanDialView: View { DialBars( face: face, nowIndex: nowIndex, - sky: Sky(face.sky(nowMs: quarterStart)), + skyFromMs: quarterStart, radius: radius, palette: palette, labels: labels, @@ -86,12 +96,15 @@ struct PlanDialView: View { fetching: fetching ) .equatable() - if let selected, face.quarters.indices.contains(selected) { - DialMark(face: face, index: selected, nowIndex: nowIndex, radius: radius, palette: palette, grey: grey, monochrome: monochrome) + if let selection { + DialMark(face: face, selection: selection, radius: radius, palette: palette, grey: grey, monochrome: monochrome) } - DialHand(progress: face.progress(at: minute), radius: radius, color: palette.hand) + DialHand(face: face, minute: minute, radius: radius, color: palette.hand) + .equatable() DialCentre( - centre: PlanDial.centre(face, nowMs: minute, selected: selected), + face: face, + minute: minute, + selection: selection, radius: radius, palette: palette, batteryPct: batteryPct, @@ -185,7 +198,8 @@ private struct DialMotion { private struct DialBars: View, Equatable { let face: PlanDial.Face let nowIndex: Int - let sky: Sky + /// The sky runs for the 24 hours from here. + let skyFromMs: Double let radius: CGFloat let palette: PlanDialPalette let labels: Bool @@ -199,19 +213,20 @@ private struct DialBars: View, Equatable { @State private var moving = false nonisolated static func == (a: Self, b: Self) -> Bool { - a.face == b.face && a.nowIndex == b.nowIndex && a.sky == b.sky && a.radius == b.radius && a.palette == b.palette + a.face == b.face && a.nowIndex == b.nowIndex && a.skyFromMs == b.skyFromMs && a.radius == b.radius && a.palette == b.palette && a.labels == b.labels && a.grey == b.grey && a.monochrome == b.monochrome && a.still == b.still && a.fetching == b.fetching } var body: some View { + let sky = Sky(face.sky(nowMs: skyFromMs)) if still { // A widget, or no motion: the bars as they are, nothing to follow. let lengths = Self.targets(face) - Canvas { ctx, size in draw(&ctx, size: size, lengths: lengths, at: .distantFuture) } + Canvas { ctx, size in draw(&ctx, size: size, sky: sky, lengths: lengths, at: .distantFuture) } } else { TimelineView(.animation(paused: !moving)) { context in let at = moving ? context.date : .distantFuture - Canvas { ctx, size in draw(&ctx, size: size, lengths: shown, at: at) } + Canvas { ctx, size in draw(&ctx, size: size, sky: sky, lengths: shown, at: at) } } .onAppear { begin() } .onChange(of: face) { begin() } @@ -249,7 +264,7 @@ private struct DialBars: View, Equatable { // MARK: Drawing - private func draw(_ ctx: inout GraphicsContext, size: CGSize, lengths: [Double: Double], at date: Date) { + private func draw(_ ctx: inout GraphicsContext, size: CGSize, sky: Sky, lengths: [Double: Double], at date: Date) { let c = CGPoint(x: size.width / 2, y: size.height / 2) let r = Double(radius) func point(_ f: Double, _ rr: Double) -> CGPoint { @@ -386,14 +401,19 @@ private struct DialBars: View, Equatable { /// drawn bright over the rest. private struct DialMark: View { let face: PlanDial.Face - let index: Int - let nowIndex: Int + let selection: PlanDialSelection let radius: CGFloat let palette: PlanDialPalette let grey: Bool let monochrome: Bool var body: some View { + if let index = selection.index, face.quarters.indices.contains(index) { + mark(index) + } + } + + private func mark(_ index: Int) -> some View { Canvas { ctx, size in let c = CGPoint(x: size.width / 2, y: size.height / 2) let r = Double(radius) @@ -430,13 +450,21 @@ private struct DialMark: View { } /// Now: a hand from the battery's ring to the rim, and a dot where it -/// crosses the spiral. -private struct DialHand: View { - let progress: Double +/// crosses the spiral. Redrawn by the minute and nothing else. +private struct DialHand: View, Equatable { + let face: PlanDial.Face + let minute: Double let radius: CGFloat let color: Color + /// The hand reads only the face's day and clock. + nonisolated static func == (a: Self, b: Self) -> Bool { + a.minute == b.minute && a.radius == b.radius && a.color == b.color + && a.face.dayStartMs == b.face.dayStartMs && a.face.timeZoneID == b.face.timeZoneID + } + var body: some View { + let progress = face.progress(at: minute) Canvas { ctx, size in let c = CGPoint(x: size.width / 2, y: size.height / 2) let r = Double(radius) @@ -456,9 +484,11 @@ private struct DialHand: View { } /// The middle: the battery as a ring, then the time and the price in mono -/// figures. +/// figures, now or at the marked quarter. private struct DialCentre: View { - let centre: PlanDial.Centre + let face: PlanDial.Face + let minute: Double + let selection: PlanDialSelection? let radius: CGFloat let palette: PlanDialPalette let batteryPct: Double? @@ -469,6 +499,7 @@ private struct DialCentre: View { let caption: String? var body: some View { + let centre = PlanDial.centre(face, nowMs: minute, selected: selection?.index) let L = PlanDial.Layout.self let ring = radius * 2 * L.battery let width = radius * L.batteryWidth diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 64f4d6b..5a5ab63 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -229,6 +229,9 @@ public enum NodeWords { "Plan dial": "Plantavla", "battery %@": "batteri %@", "Swipe up or down to move an hour.": "Svep uppåt eller nedåt för att flytta en timme.", + "Dial": "Tavla", + "Lanes": "Banor", + "Show the plan as": "Visa planen som", // Pairing. The box's own dashboard is in English, so its labels // (Settings → FTW app, Show pairing code) stay as it shows them. "Scan the pairing QR": "Skanna QR-koden", diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/PlanDial.swift index f89b31c..5a16b23 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanDial.swift @@ -109,6 +109,9 @@ public enum PlanDial { public let daylight: PlanEntry.Daylight? public let currency: String public let timeZoneID: String + /// The quarter at each of the two days' 192 quarter hours on the + /// clock, so a place on the spiral is one lookup. + let slots: [Int] public var timeZone: TimeZone { TimeZone(identifier: timeZoneID) ?? .current } @@ -137,12 +140,12 @@ public enum PlanDial { return (ms >= tomorrowStartMs ? 1 : 0) + fraction } - /// The quarter nearest a place on the spiral, kept within `reach`. + /// The quarter at a place on the spiral, kept within `reach`: one + /// lookup, cheap enough for every move of a finger. public func index(atProgress p: Double) -> Int? { - guard let reach else { return nil } - var best = reach.lowerBound - for i in reach where abs(quarters[i].progress - p) < abs(quarters[best].progress - p) { best = i } - return best + guard let reach, !slots.isEmpty else { return nil } + let k = min(slots.count - 1, max(0, Int((p * 96).rounded(.down)))) + return min(reach.upperBound, max(reach.lowerBound, slots[k])) } /// The quarters VoiceOver steps through: the one now and every whole @@ -238,6 +241,18 @@ public enum PlanDial { )) t = qEnd } + // The night the clocks go back, an hour's quarters come twice; a + // finger gets the first. The night they go forward, the skipped + // hour takes the quarter before it. + var slots = [Int](repeating: -1, count: 192) + for (i, q) in quarters.enumerated() { + let k = q.turn * 96 + q.minuteOfDay / 15 + if slots[k] < 0 { slots[k] = i } + } + var last = 0 + for k in slots.indices { + if slots[k] < 0 { slots[k] = last } else { last = slots[k] } + } let scale = max(1, quarters.compactMap { $0.priceMinor.map(abs) }.max() ?? 1) let cheapest = PlanGlance.cheapest(entry, fromMs: nowMs, toMs: nowMs + PlanGlance.horizonMs).map { PlanEntry.Span(fromMs: $0.startMs, toMs: $0.endMs) } return Face( @@ -250,7 +265,8 @@ public enum PlanDial { cheapest: cheapest, daylight: entry.daylight, currency: entry.currency, - timeZoneID: calendar.timeZone.identifier + timeZoneID: calendar.timeZone.identifier, + slots: slots ) } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift index f62c68a..fce2499 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -144,6 +144,32 @@ import Testing #expect(face.reach == nil) } + /// A finger finds its quarter in one lookup, on the nights the clocks + /// change too: the hour that comes twice gives its first quarters, the + /// hour that never comes gives the quarter before it. + @Test func aFingerFindsItsQuarterOnTheNightsTheClocksChange() { + func prices(from start: Double) -> [PriceSlot] { + (0..<200).map { PriceSlot(startMs: start + Double($0) * Self.quarter, durationMs: Self.quarter, spotMinor: 50, totalMinor: 80) } + } + var back = entry() + back.prices = prices(from: at(25, 0)) + let autumn = PlanDial.face(back, nowMs: at(25, 12)) + let first = autumn.quarters.firstIndex { $0.minuteOfDay == 150 } + #expect(autumn.index(atProgress: 152.0 / 1440) == first) + #expect(autumn.quarters[autumn.index(atProgress: 12.1 / 24)!].minuteOfDay == 720) + + var spring = calendar + spring.timeZone = TimeZone(identifier: "Europe/Stockholm")! + let march = spring.date(from: DateComponents(year: 2026, month: 3, day: 29))!.timeIntervalSince1970 * 1000 + var forward = entry() + forward.prices = prices(from: march) + let face = PlanDial.face(forward, nowMs: march + 12 * Self.hour, calendar: spring) + #expect(face.quarters.filter { $0.turn == 0 }.count == 92) + let skipped = face.index(atProgress: 152.0 / 1440)! + #expect(face.quarters[skipped].minuteOfDay == 105) + #expect(face.quarters[face.index(atProgress: 3.1 / 24)!].minuteOfDay == 180) + } + // MARK: A finger @Test func aFingerGoesOnRoundPastMidnight() { diff --git a/appleApp/FTWUITests/PlanDialTests.swift b/appleApp/FTWUITests/PlanDialTests.swift index aa8243f..804a116 100644 --- a/appleApp/FTWUITests/PlanDialTests.swift +++ b/appleApp/FTWUITests/PlanDialTests.swift @@ -3,7 +3,8 @@ import XCTest /// The plan dial at the top of the plan view, as VoiceOver meets it: one /// element that starts at now and reads the time, what the plan does, the /// price and the battery. Its hour steps are `PlanDial.Face.step`, tested -/// in FTWShared: XCTest on iOS cannot swipe an adjustable element. +/// in FTWShared: XCTest on iOS cannot swipe an adjustable element. And the +/// switch to the lanes, which the phone remembers. /// /// xcodebuild test -project FTW.xcodeproj -scheme FTW \ /// -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ @@ -17,7 +18,8 @@ final class PlanDialTests: XCTestCase { func testVoiceOverReadsTheDialAtNow() throws { let app = XCUIApplication() app.launchArguments = [ - "-FTWDemo", "YES", "-FTWTab", "plan", + // The dial whatever the phone last chose. + "-FTWDemo", "YES", "-FTWTab", "plan", "-FTWPlanStyle", "dial", "-AppleLanguages", "(en)", "-AppleLocale", "en_US", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL", ] @@ -31,4 +33,40 @@ final class PlanDialTests: XCTestCase { let start = try XCTUnwrap(dial.value as? String) XCTAssertTrue(start.contains("battery "), "now leaves out the battery's level: \(start)") } + + /// The lanes are one tap away, and the phone remembers the choice: a + /// new launch opens on the lanes until the viewer taps the dial again. + @MainActor + func testTheLanesAreOneTapAwayAndStay() throws { + func launch() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-FTWDemo", "YES", "-FTWTab", "plan", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US", + "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL", + ] + app.launch() + return app + } + var app = launch() + let style = app.segmentedControls["plan.style"] + XCTAssertTrue(style.waitForExistence(timeout: 15), "no dial or lanes switch") + if !app.descendants(matching: .any)["plan.dial"].firstMatch.exists { style.buttons["Dial"].tap() } + XCTAssertTrue(app.descendants(matching: .any)["plan.dial"].firstMatch.waitForExistence(timeout: 5), "the dial is not the default") + XCTAssertTrue(style.isHittable, "the switch is not in reach") + + style.buttons["Lanes"].tap() + XCTAssertTrue(app.descendants(matching: .any)["plan.dial"].firstMatch.waitForNonExistence(timeout: 5), "the dial stayed") + XCTAssertTrue(style.buttons["Lanes"].isSelected) + + app.terminate() + app = launch() + XCTAssertTrue(app.segmentedControls["plan.style"].waitForExistence(timeout: 15)) + XCTAssertTrue(app.segmentedControls["plan.style"].buttons["Lanes"].isSelected, "the choice was not kept") + XCTAssertFalse(app.descendants(matching: .any)["plan.dial"].firstMatch.exists) + + // Back to the dial, as the next test expects. + app.segmentedControls["plan.style"].buttons["Dial"].tap() + XCTAssertTrue(app.descendants(matching: .any)["plan.dial"].firstMatch.waitForExistence(timeout: 5)) + } } diff --git a/appleApp/FTWUITests/PlanDialTour.swift b/appleApp/FTWUITests/PlanDialTour.swift index e2f0948..a851329 100644 --- a/appleApp/FTWUITests/PlanDialTour.swift +++ b/appleApp/FTWUITests/PlanDialTour.swift @@ -1,10 +1,14 @@ import XCTest -/// Drags a finger round the plan dial, for screen recordings: this -/// evening, across midnight into tomorrow, then on round tomorrow. Each -/// drag is one touch, slow, and held at its end. Skipped unless asked for: +/// Drags a finger round the plan dial, for screen recordings and +/// Instruments: this evening, across midnight into tomorrow, then on round +/// tomorrow. Each drag is one touch, slow, and held at its end. Skipped +/// unless asked for: /// /// TEST_RUNNER_FTW_DIAL=1 xcodebuild test … -only-testing:FTWUITests/PlanDialTour +/// +/// With `TEST_RUNNER_FTW_DIAL_ATTACH=1` it takes over an app already +/// running, as one `xctrace record --launch` started. final class PlanDialTour: XCTestCase { @MainActor func testScrubRoundTheDial() throws { @@ -12,11 +16,17 @@ final class PlanDialTour: XCTestCase { try XCTSkipUnless(env["FTW_DIAL"] == "1", "Set TEST_RUNNER_FTW_DIAL=1 to drive the plan dial.") let lang = env["FTW_LANG"] ?? "sv" let app = XCUIApplication() - app.launchArguments = [ - "-FTWDemo", "YES", "-FTWTab", "plan", - "-AppleLanguages", "(\(lang))", "-AppleLocale", lang == "sv" ? "sv_SE" : "en_US", - ] - app.launch() + if env["FTW_DIAL_ATTACH"] == "1" { + // Instruments started the app (`-FTWDemo YES -FTWTab plan`), so + // it can trace SwiftUI: take it over as it runs. + app.activate() + } else { + app.launchArguments = [ + "-FTWDemo", "YES", "-FTWTab", "plan", "-FTWPlanStyle", "dial", + "-AppleLanguages", "(\(lang))", "-AppleLocale", lang == "sv" ? "sv_SE" : "en_US", + ] + app.launch() + } let dial = app.descendants(matching: .any)["plan.dial"].firstMatch XCTAssertTrue(dial.waitForExistence(timeout: 15)) // The bars grow in round the spiral; let them settle.