diff --git a/CLAUDE.md b/CLAUDE.md index 8d11e7c..a7800dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,43 @@ 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. 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. +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 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 - **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..0b6e46c --- /dev/null +++ b/appleApp/FTW/UI/PlanDialCard.swift @@ -0,0 +1,281 @@ +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 +/// 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 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? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let face = self.face ?? PlanDial.face(entry, nowMs: nowMs) + VStack(alignment: .leading, spacing: 8) { + PlanDialView( + face: face, + nowMs: nowMs, + palette: .app, + batteryPct: batteryPct, + batteryOld: batteryOld, + selection: selection, + 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") + .background { ScrubHaptics(finger: finger) } + DialReadout(face: face, selection: selection, nowMs: nowMs, summary: summary) + DialLegend(car: face.quarters.contains(where: \.car)) + } + .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 + + /// 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 + 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: 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) { + finger.bumps += 1 + } else if drag.time.timeIntervalSince(finger.lastTick) >= 0.04 { + finger.lastTick = drag.time + finger.ticks += 1 + } + } + .onEnded { _ in + finger.progress = nil + selection.index = spoken + } + } + + // 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 + selection.index = 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 + } +} + +/// 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 { + 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..c843245 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -1,30 +1,36 @@ 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, 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) { fill in - PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, fill: fill, chooseMode: { choosingMode = true }) + PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: $style, fill: fill, 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() } @@ -34,42 +40,80 @@ 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 { // 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) + let entry = models.planShare.entry + let moment = PlanGlance.live(entry, nowMs: minute) + Picker(L("Show the plan as"), selection: $style.animation(Theme.spring)) { + ForEach(PlanStyle.allCases, id: \.self) { Text($0.label).tag($0) } } - 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))) + .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)) } - .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.") } @@ -107,11 +151,13 @@ private struct Legend: View { } } -/// Each thing the box means to do, when, and why. +/// 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) { @@ -143,10 +189,15 @@ 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] } + 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/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..79339e1 --- /dev/null +++ b/appleApp/FTWCharts/PlanDialView.swift @@ -0,0 +1,553 @@ +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 +} + +/// 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 +/// 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 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 + 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. 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. + 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, + skyFromMs: quarterStart, + radius: radius, + palette: palette, + labels: labels, + grey: grey, + monochrome: monochrome, + still: still, + fetching: fetching + ) + .equatable() + if let selection { + DialMark(face: face, selection: selection, radius: radius, palette: palette, grey: grey, monochrome: monochrome) + } + DialHand(face: face, minute: minute, radius: radius, color: palette.hand) + .equatable() + DialCentre( + face: face, + minute: minute, + selection: selection, + 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 + /// The sky runs for the 24 hours from here. + let skyFromMs: Double + 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.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, 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, sky: sky, 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, 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 { + 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 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) + 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. 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) + 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, now or at the marked quarter. +private struct DialCentre: View { + let face: PlanDial.Face + let minute: Double + let selection: PlanDialSelection? + 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 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 + 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..5a5ab63 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -216,6 +216,22 @@ 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.", + "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/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..5a16b23 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PlanDial.swift @@ -0,0 +1,344 @@ +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 + /// 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 } + + /// 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 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, !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 + /// 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 + } + // 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( + 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, + slots: slots + ) + } + + 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..fce2499 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -0,0 +1,278 @@ +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) + func price(_ i: Int) -> 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")] + var t = at(13, 16, 15) + while t < at(14, 16, 15) { + let h = calendar.component(.hour, from: Date(timeIntervalSince1970: t / 1000)) + let w: Double = h >= 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) + } + + /// 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() { + 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 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)) + #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.. 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 new file mode 100644 index 0000000..a851329 --- /dev/null +++ b/appleApp/FTWUITests/PlanDialTour.swift @@ -0,0 +1,67 @@ +import XCTest + +/// 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 { + let env = ProcessInfo.processInfo.environment + 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() + 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. + Thread.sleep(forTimeInterval: 2) + shoot(app, "dial") + let r = dial.frame.height / 2 / 1.14 + // This evening on today's turn, then across midnight onto + // tomorrow's, then round tomorrow. + drag(dial, r, from: (0.70, 0.60), to: (0.95, 0.64)) + drag(dial, r, from: (0.90, 0.62), to: (0.16, 0.62)) + drag(dial, r, from: (0.18, 0.78), to: (0.45, 0.80)) + drag(dial, r, from: (0.45, 0.80), to: (0.72, 0.78)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "after") + } + + /// One touch from a time of day at a radius to another, as shares of + /// the day and of the dial's radius: midnight at the bottom, noon at + /// the top, up on the left. + @MainActor + private func drag(_ dial: XCUIElement, _ r: CGFloat, from a: (Double, Double), to b: (Double, Double)) { + let centre = dial.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + func at(_ p: (Double, Double)) -> 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() }