From c80a7cd3819ed935fbe93d0d43107f522fd33c7f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 17:28:08 +0200 Subject: [PATCH] iPhone: the plan dial as one turn, shared with the watch, and its widget (WP11) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 34 +++ appleApp/FTW/UI/PlanDialCard.swift | 184 ++++++++++++ appleApp/FTW/UI/PlanView.swift | 188 +++++++++--- appleApp/FTWCharts/PlanDialFace.swift | 278 ++++++++++++++++++ .../Sources/FTWKit/State/NodeWords.swift | 4 + .../Sources/FTWKit/State/PlanShare.swift | 21 +- .../WatchDial.swift => Charts/PlanDial.swift} | 164 +++++++++-- .../FTWKit/Sources/FTWShared/PlanGlance.swift | 45 ++- .../FTWShared/Resources/en.lproj/Dial.strings | 12 + .../FTWShared/Resources/sv.lproj/Dial.strings | 12 + .../Sources/FTWShared/Watch/WatchGlance.swift | 8 +- .../Tests/FTWSharedTests/PlanDialTests.swift | 151 ++++++++++ .../Tests/FTWSharedTests/WatchTests.swift | 18 +- appleApp/FTWUITests/LockScreenTour.swift | 12 +- appleApp/FTWUITests/PlanDialTests.swift | 87 ++++++ appleApp/FTWWatch/PlanPage.swift | 14 +- appleApp/FTWWatchShared/BlockBars.swift | 4 +- appleApp/FTWWatchShared/DialFace.swift | 155 ---------- .../FTWWatchWidgets/PlanComplication.swift | 2 +- appleApp/FTWWidgets/Plan/PlanDialWidget.swift | 189 ++++++++++++ appleApp/FTWWidgets/PriceWidget.swift | 1 + 21 files changed, 1323 insertions(+), 260 deletions(-) create mode 100644 appleApp/FTW/UI/PlanDialCard.swift create mode 100644 appleApp/FTWCharts/PlanDialFace.swift rename appleApp/FTWKit/Sources/FTWShared/{Watch/WatchDial.swift => Charts/PlanDial.swift} (51%) create mode 100644 appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings create mode 100644 appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift create mode 100644 appleApp/FTWUITests/PlanDialTests.swift delete mode 100644 appleApp/FTWWatchShared/DialFace.swift create mode 100644 appleApp/FTWWidgets/Plan/PlanDialWidget.swift diff --git a/CLAUDE.md b/CLAUDE.md index 4ea3105..08beffa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,6 +263,40 @@ never their graphics or brands. | A trace that burns at its head and fades when the signal stops | Oscilloscopes, CRT terminals | Beams on Now, the live trace, the status dot | | One mono status line, and an inverse-video tag for what is selected | Terminal tools | The status line under each title, the scrub tag | +### Plan dial + +FTW's own form, from Fredrik's film (srcfl/sourceful-films, +`product/ftw-confirmed`). His rules, as he settled them on 2026-10-13: + +1. One turn is the next 24 hours. Midnight sits at the bottom and noon at + the top, so the day's light fills the upper half. It never reads as a + 12-hour clock. +2. One tick per quarter hour, as long as the price in eight levels. Its + colour is what the plan does with the battery: `Theme.stores` stores, + `Theme.ink` gives power, `Theme.ink3` waits. On the dial, gold is the + sun's and never the battery's. +3. A dotted arc in `Theme.car` marks when the car charges, with its goal + at the end. +4. A hand marks now. A 2° gap in the rim marks the seam where +24 h meets + now, and the ticks fade toward it. +5. Never a tick without a price: a quarter the market has not priced is a + stub. +6. The sun's arc on the rim comes from the box's own solar forecast, never + from astronomy or the phone's location. No forecast, no arc. +7. The middle holds the time and the price, and a third line kept for + what the plan does under a finger. No battery ring and no glass disc in + the app; the widget keeps a dark ground so it reads on any wallpaper. + +`FTWShared/Charts/PlanDial.swift` holds the quarters, the sun, the words +and the rule for a finger; `FTWCharts/PlanDialFace.swift` draws. The +phone's plan view, its `energy.ftw.plan-dial` widget (StandBy small and +the lock screen's circle), the watch app and the watch's complication all +draw that one face. The plan view opens on the dial; the lanes are one +tap away in the toolbar, and the phone keeps the viewer's choice. A drag +round the ring costs one lookup per event, and only a change of quarter +redraws, and then only the hand and the middle. Now keeps its straight +strip, with gold for the battery giving power. + ## Non-negotiable invariants - **Never fake live.** Every reading carries its age. diff --git a/appleApp/FTW/UI/PlanDialCard.swift b/appleApp/FTW/UI/PlanDialCard.swift new file mode 100644 index 0000000..b60fd5a --- /dev/null +++ b/appleApp/FTW/UI/PlanDialCard.swift @@ -0,0 +1,184 @@ +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: the next 24 hours as one +/// turn, the time and the price in the middle. A finger dragged round the +/// ring reads the plan a quarter at a time: the hand follows, brackets +/// close on its tick, the middle shows that quarter, `.selection` ticks per +/// quarter and a light impact marks now, the cheapest hours' edges and the +/// car's goal. On lift the hand springs back to now. VoiceOver steps it an +/// hour at a time. +/// +/// The face never sees the finger. Only the hand and the middle read the +/// scrub model, so a drag redraws those two, once per quarter. +struct PlanDialCard: View { + let dial: PlanDial + /// The plan is old or the box could not plan. + let grey: Bool + + @State private var scrub = ScrubModel() + /// The quarter the finger was last on, so it cannot wind past the seam. + /// Written on every drag event and read by nothing that draws. + @State private var finger = Finger() + @State private var side: CGFloat = 0 + /// Where VoiceOver has stepped to. + @State private var spoken: Int? + + var body: some View { + ZStack { + PlanDialFace(dial: dial, grey: grey) + .equatable() + DialHand(dial: dial, model: scrub) + DialCentre(dial: dial, model: scrub, spoken: spoken) + } + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 340) + .frame(maxWidth: .infinity) + .onGeometryChange(for: CGFloat.self) { min($0.size.width, $0.size.height) } action: { side = $0 } + // Only the ring takes a finger, so a drag from the middle scrolls + // the page. + .contentShape(DialRing(), eoFill: true) + .gesture(drag) + .accessibilityElement(children: .ignore) + .accessibilityLabel(PlanDial.title) + .accessibilityValue(spokenValue(spoken ?? 0)) + .accessibilityHint(L("Swipe up or down to move an hour.")) + .accessibilityAdjustableAction { step($0) } + .accessibilityIdentifier("plan.dial") + #if DEBUG + .task { await autoScrub() } + #endif + } + + /// Each drag event costs a little arithmetic and one lookup and writes + /// nothing a view reads. A change of quarter moves the scrub model. + private var drag: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { follow($0.location) } + .onEnded { _ in + finger.previous = nil + withAnimation(Theme.spring) { scrub.lift() } + } + } + + private func follow(_ p: CGPoint) { + let event = signposts.beginInterval("dial drag") + defer { signposts.endInterval("dial drag", event) } + guard side > 0 else { return } + let i = dial.index(x: p.x - side / 2, y: p.y - side / 2, from: finger.previous) + finger.previous = i + guard i != scrub.index else { return } + signposts.emitEvent("dial quarter") + scrub.move(to: i, landmark: dial.landmarks.contains(i)) + } + + // MARK: VoiceOver + + /// An hour a step, from now to the end of the turn and back. + private func step(_ direction: AccessibilityAdjustmentDirection) { + let at = spoken ?? 0 + switch direction { + case .increment: spoken = min(PlanDial.count - 1, at + 4) + case .decrement: spoken = max(0, at - 4) + @unknown default: return + } + } + + /// "14:00, 98 öre/kWh, battery stores 3.0 kW". + private func spokenValue(_ i: Int) -> String { + [dial.time(i), dial.price(i), dial.what(i)].joined(separator: ", ") + } + + #if DEBUG + /// With `-FTWDialScrub YES`, a finger goes once round the ring and back + /// through the same path a touch takes, for recordings and frame + /// measurements where nothing can touch. + private func autoScrub() async { + guard UserDefaults.standard.bool(forKey: "FTWDialScrub") else { return } + try? await Task.sleep(for: .seconds(2)) + let began = ContinuousClock.now + let r = side * 0.42 + while ContinuousClock.now - began < .seconds(12) { + let t = (ContinuousClock.now - began) / .milliseconds(6_000) + let along = t < 1 ? t : 2 - t + let a = dial.seam + 0.01 + along * (2 * .pi - 0.02) + follow(CGPoint(x: side / 2 + r * sin(a), y: side / 2 - r * cos(a))) + try? await Task.sleep(for: .milliseconds(8)) + } + finger.previous = nil + withAnimation(Theme.spring) { scrub.lift() } + } + #endif +} + +/// The finger's last quarter: a plain reference, so a drag event writes +/// nothing SwiftUI watches. +@MainActor +private final class Finger { + var previous: Int? +} + +/// The hand and the lock-on, the only marks that move with a finger. +/// Haptics ride here too, in the same update as the quarter. +private struct DialHand: View { + let dial: PlanDial + let model: ScrubModel + + var body: some View { + PlanDialHand(dial: dial, selected: model.index) + .sensoryFeedback(.selection, trigger: model.ticks) + .sensoryFeedback(.impact(weight: .light), trigger: model.bumps) + } +} + +/// The middle: the time at 40 pt, the price in mono, and a third line kept +/// for what the plan does in the quarter under the finger. +private struct DialCentre: View { + let dial: PlanDial + let model: ScrubModel + let spoken: Int? + + var body: some View { + let i = model.index ?? spoken ?? 0 + GeometryReader { geo in + let side = min(geo.size.width, geo.size.height) + VStack(spacing: side * 0.008) { + Text(dial.time(i)) + .font(Theme.figure(side * 0.118)) + .tracking(-1) + .foregroundStyle(Theme.ink) + Text(dial.prices[i]) + .font(.system(size: max(9, side * 0.031), weight: .medium, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(Theme.ink2) + // Kept whether or not a finger is down, so nothing moves. + Text(model.index == nil ? " " : dial.doing[i]) + .font(.system(size: max(9, side * 0.031), weight: .semibold, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(Theme.ink) + } + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(width: side * 0.5) + .position(x: geo.size.width / 2, y: geo.size.height / 2) + } + .accessibilityHidden(true) + } +} + +/// Where a finger scrubs: the ring of ticks and the rim, not the middle. +private struct DialRing: Shape { + func path(in rect: CGRect) -> Path { + let side = min(rect.width, rect.height) + let inner = side * 0.26 + 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 + } +} diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index cd3ee23..a574ec7 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -1,30 +1,59 @@ 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: the next 24 hours round the plan dial, or +/// the next twelve hours in lanes, then each thing the box means to do, +/// when, and why, in the box's own reasons. The dial is the default; the +/// lanes are one tap away in the toolbar, and the phone keeps the viewer's +/// choice. 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 }) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: style, chooseMode: { choosingMode = true }) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: 640, alignment: .topLeading) + .frame(maxWidth: .infinity) } + .scrollBounceBehavior(.basedOnSize) + .background(Theme.ground) .navigationTitle(PlanGlance.title) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Picker(L("Show the plan as"), selection: $style.animation(.easeInOut(duration: 0.25))) { + ForEach(PlanStyle.allCases, id: \.self) { s in + Image(systemName: s.symbol) + .accessibilityLabel(s.label) + .tag(s) + } + } + .pickerStyle(.segmented) + .fixedSize() + .accessibilityIdentifier("plan.style") + } + } .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,43 +63,100 @@ struct PlanView: View { } } +/// How the plan view draws the plan: round on the dial, or straight in +/// lanes. +enum PlanStyle: String, CaseIterable { + case dial, lanes + + var label: String { + switch self { + case .dial: return L("Dial") + case .lanes: return L("Lanes") + } + } + + var symbol: String { + switch self { + case .dial: return "circle.dashed" + case .lanes: return "chart.bar.xaxis" + } + } +} + private struct PlanBody: View { let site: SiteModel let models: NodeModels let loadpoints: LoadpointsModel let prices: PriceModel - let fill: Bool + let style: PlanStyle let chooseMode: () -> Void + @State private var dials = DialMemo() 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 minute = site.minuteMs let moment = models.planShare.moment(nowMs: minute) - VStack(alignment: .leading, spacing: 4) { - Text(moment.summary) - .font(.headline) - .foregroundStyle(moment.isGrey ? Theme.fgDim : Theme.fg) - .fixedSize(horizontal: false, vertical: true) - .redacted(reason: moment.placeholder ? .placeholder : []) - Text(stateLine(moment)) - .font(.footnote) - .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.fgDim) - } - VStack(alignment: .leading, spacing: 6) { - PlanLanes(moment: moment) - .equatable() - .frame(minHeight: fill ? 84 : 140, idealHeight: fill ? 84 : 140, maxHeight: fill ? 200 : 140) - Legend(kinds: Set(moment.events.map(\.kind))) + Group { + switch style { + case .dial: + let dial = dials.dial(models.planShare.entry, nowMs: minute) + VStack(alignment: .leading, spacing: 12) { + PlanDialCard(dial: dial, grey: moment.isGrey) + VStack(alignment: .leading, spacing: 6) { + Text(moment.summary) + .font(.subheadline) + .foregroundStyle(moment.isGrey ? Theme.ink3 : Theme.ink2) + .fixedSize(horizontal: false, vertical: true) + .redacted(reason: moment.placeholder ? .placeholder : []) + // Mono stops growing at accessibility1. + Group { + if let cheapest = dial.cheapestText { + Text(cheapest).foregroundStyle(Theme.ink2) + } + Text(plannedLine(moment, zone: dial.zone)) + .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.ink3) + } + .font(Theme.mono()) + .tracking(0.5) + .fixedSize(horizontal: false, vertical: true) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } + } + .transition(.opacity) + case .lanes: + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(moment.summary) + .font(.headline) + .foregroundStyle(moment.isGrey ? Theme.fgDim : Theme.fg) + .fixedSize(horizontal: false, vertical: true) + .redacted(reason: moment.placeholder ? .placeholder : []) + Text(stateLine(moment)) + .font(.footnote) + .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.fgDim) + } + VStack(alignment: .leading, spacing: 6) { + PlanLanes(moment: moment) + .equatable() + .frame(height: 140) + Legend(kinds: Set(moment.events.map(\.kind))) + } + } + .transition(.opacity) + } } - .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) } + /// "PLANNED 16:51 · REPLANS EVERY 15 MIN", or that the box could not. + private func plannedLine(_ m: PlanGlance.Moment, zone: TimeZone) -> String { + if m.state == .boxStale { return L("Your box couldn't plan ahead just now, so it runs on safe defaults.") } + guard let at = models.plan.receivedAtMs ?? m.readAtMs else { return L("Reading the plan from your box…") } + return PlanDial.planned(atMs: at, old: m.state == .old, zone: zone) + } + /// 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.") } @@ -79,6 +165,22 @@ private struct PlanBody: View { } } +/// The dial worked out once per plan and minute, so a body that runs again +/// for any other reason reads it. +@MainActor +private final class DialMemo { + private var key: (entry: PlanEntry, minute: Double)? + private var kept: PlanDial? + + func dial(_ entry: PlanEntry, nowMs: Double) -> PlanDial { + if let key, key.entry == entry, key.minute == nowMs, let kept { return kept } + let d = PlanDial(plan: entry, prices: nil, nowMs: nowMs) + key = (entry, nowMs) + kept = d + return d + } +} + /// What each colour on the lanes means. private struct Legend: View { let kinds: Set @@ -108,11 +210,14 @@ private struct Legend: View { } } -/// Each thing the box means to do, when, and why. +/// Each thing the box means to do, when, and why, as plain rows on the +/// ground. Each row's mark has the colour the plan draws it in above, so +/// the rows are the legend. private struct Events: View { let moment: PlanGlance.Moment let entry: PlanEntry let loadpoints: LoadpointsModel + let style: PlanStyle var body: some View { VStack(spacing: 0) { @@ -136,16 +241,18 @@ private struct Events: View { .padding(.top, 6) } } - .padding(.vertical, 4) - .padding(.horizontal, 12) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + } + + /// On the dial gold is the sun's, so the battery giving power is ink + /// there; the lanes keep gold. + private func color(_ kind: PlanGlance.Kind) -> Color { + style == .dial && kind == .discharge ? Theme.ink : PlanLanes.color(kind) } private func row(_ e: PlanGlance.Event) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { RoundedRectangle(cornerRadius: 2) - .fill(PlanLanes.color(e.kind)) + .fill(color(e.kind)) .frame(width: 10, height: 6) .alignmentGuide(.firstTextBaseline) { $0[.bottom] } VStack(alignment: .leading, spacing: 1) { @@ -221,11 +328,8 @@ private struct PriceRow: View { .foregroundStyle(Theme.fgMuted) } .padding(.vertical, 10) - .padding(.horizontal, 12) .frame(minHeight: 44) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) - .contentShape(RoundedRectangle(cornerRadius: Theme.radius)) + .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityIdentifier("plan.price") @@ -254,10 +358,8 @@ private struct CompactMode: View { } } .padding(.vertical, 10) - .padding(.horizontal, 12) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) - .contentShape(RoundedRectangle(cornerRadius: Theme.radius)) + .frame(minHeight: 44) + .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!mode.canControl) diff --git a/appleApp/FTWCharts/PlanDialFace.swift b/appleApp/FTWCharts/PlanDialFace.swift new file mode 100644 index 0000000..d3e04e2 --- /dev/null +++ b/appleApp/FTWCharts/PlanDialFace.swift @@ -0,0 +1,278 @@ +import FTWShared +import SwiftUI +import WidgetKit + +/// The plan dial, drawn from a `PlanDial`. The phone's plan view, its dial +/// widget, the watch app and the watch's complication all draw this one +/// face, so the four never differ. +/// +/// From the outside in: the rim, a hairline with a 2° gap at the seam where +/// +24 h meets now, and the sun's arc on it from the box's forecast; the +/// hours just outside it; 96 price ticks in eight levels, cyan where the +/// battery stores, ink where it gives power, faint where it waits; stubs +/// where the market has not priced a quarter yet; the car's dotted arc +/// with its goal. The hand is drawn apart (`PlanDialHand`), so a finger on +/// the phone moves it without redrawing the face. +/// +/// Every mark is a still path filled once. Nothing glows: a plan is intent, +/// not a reading. +struct PlanDialFace: View, Equatable { + enum Size: Equatable { + /// The phone's plan view and the watch app: rim, hours, the car's goal. + case app + /// A widget or a complication: the marks alone. + case small + } + + let dial: PlanDial + /// The plan is old or the box could not plan: every tick in ink3. + var grey = false + var size: Size = .app + + var body: some View { + GeometryReader { geo in + let g = DialGeometry(side: min(geo.size.width, geo.size.height), size: size) + ZStack { + if size == .app { + rim(g).stroke(Theme.ink.opacity(0.18), style: StrokeStyle(lineWidth: 0.5, lineCap: .butt, lineJoin: .round)) + HourLabels(g: g) + } + sun(g).stroke(grey ? Theme.ink3 : Theme.solar, style: StrokeStyle(lineWidth: g.sunWidth, lineCap: .butt, lineJoin: .round)) + ticks(g, .waits).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink3.opacity(0.6)) + ticks(g, .gives).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink) + ticks(g, .stores).fill(grey ? Theme.ink3.opacity(0.45) : Theme.stores) + .widgetAccentable() + stubs(g).fill(Theme.ink3.opacity(0.5)) + carDots(g).fill(grey ? Theme.ink3 : Theme.car) + .widgetAccentable() + if size == .app { CarGoals(g: g, dial: dial, grey: grey) } + } + .frame(width: geo.size.width, height: geo.size.height) + } + .accessibilityHidden(true) + } + + // MARK: Paths + + /// The rim, round from just past the seam to just before it. + private func rim(_ g: DialGeometry) -> Path { + g.arc(from: dial.seam + DialGeometry.gap, to: dial.seam + 2 * .pi - DialGeometry.gap, radius: g.rim) + } + + /// The sun on the rim, sunrise to sunset as the box forecasts them, + /// clear of the seam. + private func sun(_ g: DialGeometry) -> Path { + var p = Path() + let lo = dial.seam + DialGeometry.gap, hi = dial.seam + 2 * .pi - DialGeometry.gap + for span in dial.sun { + // Each end as a distance along the turn from the seam. + var a = dial.angle(span.lowerBound), b = dial.angle(span.upperBound) + while a < lo { a += 2 * .pi } + while b <= a { b += 2 * .pi } + p.addPath(g.arc(from: max(a, lo), to: min(b, hi), radius: g.rim)) + } + return p + } + + /// One path per battery action, so each fills in one go. + private func ticks(_ g: DialGeometry, _ action: PlanDial.Battery) -> Path { + var p = Path() + for (i, q) in dial.quarters.enumerated() where q.battery == action { + guard let level = q.level else { continue } + let length = g.unit * Double(level + 1) + p.addPath(g.tick(at: dial.angle(quarter: i), from: g.inner, length: length, width: g.tickWidth * dial.fade(i).squareRoot())) + } + return p + } + + /// Quarters the market has not priced yet: a short stub, never a level. + private func stubs(_ g: DialGeometry) -> Path { + var p = Path() + for (i, q) in dial.quarters.enumerated() where q.minor == nil && i % 2 == 0 { + p.addPath(g.tick(at: dial.angle(quarter: i), from: g.inner, length: g.unit * 0.6, width: g.tickWidth * 0.8)) + } + return p + } + + /// The car's charging: a dotted arc inside the ticks, a dot every + /// other quarter. + private func carDots(_ g: DialGeometry) -> Path { + var p = Path() + for (i, q) in dial.quarters.enumerated() where q.car && i % 2 == 0 { + let c = g.point(dial.angle(quarter: i), g.carRadius) + p.addEllipse(in: CGRect(x: c.x - g.dot / 2, y: c.y - g.dot / 2, width: g.dot, height: g.dot)) + } + return p + } +} + +/// The dial's hand, and the lock-on on the quarter a finger or the Crown +/// chose. Apart from the face, so moving it redraws only this. +struct PlanDialHand: View { + let dial: PlanDial + /// The quarter chosen; nil or 0 points the hand at now. + var selected: Int? + var size: PlanDialFace.Size = .app + + var body: some View { + GeometryReader { geo in + let g = DialGeometry(side: min(geo.size.width, geo.size.height), size: size) + ZStack { + if size == .app, let i = selected, i > 0 { + lockOn(g, i).fill(Theme.ink) + } + // From inside the ticks to just past the rim, through the + // seam's gap while it points at now. + HandLine(along: along, seam: dial.seam, from: g.inner - g.unit * 1.2, to: g.rim + (size == .app ? 3 : 1)) + .stroke(Theme.ink, style: StrokeStyle(lineWidth: size == .app ? 1.5 : 1.2, lineCap: .round, lineJoin: .round)) + } + .frame(width: geo.size.width, height: geo.size.height) + } + .accessibilityHidden(true) + } + + /// The hand's place as a distance round the turn from the seam, so a + /// spring back to now runs back along the day, never across the seam. + private var along: Double { + var a = dial.handAngle(selected: selected) - dial.seam + if a < 0 { a += 2 * .pi } + return a + } + + /// The quarter chosen: its tick at full length, in ink, and the lock-on + /// brackets closing on its outer end. + private func lockOn(_ g: DialGeometry, _ i: Int) -> Path { + let a = dial.angle(quarter: i) + var p = g.tick(at: a, from: g.inner - g.unit * 0.8, length: g.rim - g.inner + g.unit * 0.8, width: g.tickWidth * 1.6) + p.addPath(LockOn.brackets(around: g.point(a, g.rim - g.unit), half: g.unit * 1.6, arm: g.unit * 0.8) + .strokedPath(StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round))) + return p + } + +} + +/// The hand as a shape whose angle can spring. +private struct HandLine: Shape { + var along: Double + let seam: Double + let from: CGFloat + let to: CGFloat + + var animatableData: Double { + get { along } + set { along = newValue } + } + + func path(in rect: CGRect) -> Path { + let a = seam + along + let c = CGPoint(x: rect.midX, y: rect.midY) + var p = Path() + p.move(to: CGPoint(x: c.x + from * CGFloat(sin(a)), y: c.y - from * CGFloat(cos(a)))) + p.addLine(to: CGPoint(x: c.x + to * CGFloat(sin(a)), y: c.y - to * CGFloat(cos(a)))) + return p + } +} + +/// The face and its hand together, for the places a finger never touches: +/// the watch, where the Crown moves the hand, and the widgets. +struct PlanDialView: View { + let dial: PlanDial + var selected: Int? + var grey = false + var size: PlanDialFace.Size = .app + + var body: some View { + ZStack { + PlanDialFace(dial: dial, grey: grey, size: size) + .equatable() + PlanDialHand(dial: dial, selected: selected, size: size) + } + } +} + +/// Where things sit on a dial `side` points across. +struct DialGeometry { + let side: CGFloat + let size: PlanDialFace.Size + + /// Half the seam's gap: 1° each side of where +24 h meets now. + static let gap = Double.pi / 180 + + var centre: CGPoint { CGPoint(x: side / 2, y: side / 2) } + /// The outer edge of the dearest tick, and the sun's arc. + var rim: CGFloat { side / 2 - (size == .app ? 13 : 1.5) } + /// The hours sit just outside the rim, clear of the sun's arc. + var labelRadius: CGFloat { side / 2 - 5.5 } + /// Where every tick starts. + var inner: CGFloat { rim * (size == .app ? 0.62 : 0.56) } + /// One price level's length. + var unit: CGFloat { (rim - inner) / CGFloat(PlanDial.levels) } + var tickWidth: CGFloat { max(0.9, side * (size == .app ? 0.0085 : 0.012)) } + var carRadius: CGFloat { inner - unit * (size == .app ? 1.6 : 1.4) } + var dot: CGFloat { max(1.4, side * 0.012) } + var sunWidth: CGFloat { size == .app ? 2 : max(1.2, side * 0.015) } + /// The hours' and the car goal's mono size. + var label: CGFloat { max(9, (side * 0.03).rounded()) } + + /// A point `r` from the centre, `a` radians clockwise from the top. + func point(_ a: Double, _ r: CGFloat) -> CGPoint { + CGPoint(x: centre.x + r * CGFloat(sin(a)), y: centre.y - r * CGFloat(cos(a))) + } + + /// An arc clockwise from `a` to `b`, as short straight steps. + func arc(from a: Double, to b: Double, radius r: CGFloat) -> Path { + var p = Path() + guard b > a else { return p } + let steps = max(2, Int((b - a) / (.pi / 180))) + p.move(to: point(a, r)) + for k in 1...steps { p.addLine(to: point(a + (b - a) * Double(k) / Double(steps), r)) } + return p + } + + /// A tick as a thin quad along the radius. + func tick(at a: Double, from r0: CGFloat, length: CGFloat, width: CGFloat) -> Path { + let half = Double(width / 2) + let nx = CGFloat(cos(a)) * CGFloat(half), ny = CGFloat(sin(a)) * CGFloat(half) + let p0 = point(a, r0), p1 = point(a, r0 + length) + var p = Path() + p.move(to: CGPoint(x: p0.x - nx, y: p0.y - ny)) + p.addLine(to: CGPoint(x: p1.x - nx, y: p1.y - ny)) + p.addLine(to: CGPoint(x: p1.x + nx, y: p1.y + ny)) + p.addLine(to: CGPoint(x: p0.x + nx, y: p0.y + ny)) + p.closeSubpath() + return p + } +} + +/// 00, 06, 12 and 18 just outside the rim, in the terminal's mono. +private struct HourLabels: View { + let g: DialGeometry + + var body: some View { + ForEach([0, 6, 12, 18], id: \.self) { h in + // 00 at the bottom, 06 on the left, 12 at the top. + let a = Double(h) / 24 * 2 * .pi + .pi + Text(verbatim: h < 10 ? "0\(h)" : "\(h)") + .font(.system(size: g.label, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.ink3) + .position(g.point(a, g.labelRadius)) + } + } +} + +/// The level each car charges toward, at the end of its dotted arc. +private struct CarGoals: View { + let g: DialGeometry + let dial: PlanDial + let grey: Bool + + var body: some View { + ForEach(Array(dial.quarters.indices.filter { dial.quarters[$0].carGoalPct != nil }), id: \.self) { i in + Text(verbatim: "\(Int((dial.quarters[i].carGoalPct ?? 0).rounded()))\u{00A0}%") + .font(.system(size: g.label * 0.9, weight: .medium, design: .monospaced)) + .foregroundStyle(grey ? Theme.ink3 : Theme.car) + .fixedSize() + .position(g.point(dial.angle(quarter: i), g.carRadius - g.label * 1.3)) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 24523de..9c47490 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -132,6 +132,10 @@ public enum NodeWords { // The plan view "Opens the plan.": "Öppnar planen.", + "Dial": "Tavla", + "Lanes": "Banor", + "Show the plan as": "Visa planen som", + "Swipe up or down to move an hour.": "Svep uppåt eller nedåt för att flytta en timme.", "Battery charges": "Batteriet laddar", "Battery gives power": "Batteriet laddar ur", "Cheapest": "Billigast", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index 62247e6..5b8de3e 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) -> 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,7 +18,7 @@ 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) } /// Only what lies ahead, for the widget's record: from an hour back to a @@ -63,9 +63,19 @@ public final class PlanShare { plan.received = { [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 once the app has read the forecast. public var entry: PlanEntry { - PlanEntry.live(plan: plan.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier) + PlanEntry.live(plan: plan.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, from the box's latest + /// word or the one this phone kept. A forecast that has run out leaves + /// no sun inside the turn, so an old one draws no sky rather than a + /// false one. + var daylight: PlanEntry.Daylight? { + guard plan.forecastStamp != nil else { return nil } + return PlanEntry.Daylight(plan.forecast.map { ($0.startMs, $0.durationMs, $0.pvW) }) } /// The plan at `nowMs` as Now and the plan view show it: the box's own @@ -85,7 +95,8 @@ public final class PlanShare { 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)" } ?? "-" let plan = self.plan.plan - return "\(plan?.rev ?? 0)|\(plan?.slots.first?.startMs ?? 0)|\(plan?.slots.count ?? 0)|\(plan?.stale ?? false)|\(w.joined(separator: ";"))|\(p)" + let sun = self.plan.forecast.first.map { "\(self.plan.forecast.count)@\($0.startMs)" } ?? "-" + return "\(plan?.rev ?? 0)|\(plan?.slots.first?.startMs ?? 0)|\(plan?.slots.count ?? 0)|\(plan?.stale ?? false)|\(w.joined(separator: ";"))|\(p)|\(sun)" } func check() { diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift similarity index 51% rename from appleApp/FTWKit/Sources/FTWShared/Watch/WatchDial.swift rename to appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index 5353d87..07329a1 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift @@ -1,21 +1,23 @@ import Foundation -/// The plan dial as the watch draws it: one turn is the next 24 hours, on a -/// 24-hour face with 00 at the bottom and 12 at the top, so the day's -/// light sits in the upper half and morning on the left. Each quarter hour -/// is one tick. Its length is the price in eight levels, its colour what -/// the battery does then, and a dotted arc inside marks the car's charging. +/// The plan dial, as the phone, its widget and the watch all draw it: one +/// turn is the next 24 hours, on a 24-hour face with 00 at the bottom and +/// 12 at the top, so the day's light sits in the upper half and morning on +/// the left. Each quarter hour is one tick. Its length is the price in +/// eight levels, its colour what the battery does then, and a dotted arc +/// inside marks the car's charging. The sun's arc on the rim comes from the +/// box's own forecast, never from astronomy. /// -/// Built on the chart engine in `Charts/` (`SlotGrid`, `ZoneClock`), and -/// worked out once per plan, so the Crown only reads a table. The phone's -/// own dial (WP11) is not on `main` yet; when it lands in FTWShared, the -/// watch draws that face and this type goes. -public struct WatchDial: Equatable, Sendable { +/// Built on the chart engine (`SlotGrid`, `ZoneClock`) and worked out once +/// per plan and minute, so a finger or the Crown only reads a table. +public struct PlanDial: Equatable, Sendable { public static let quarterMs: Double = 15 * 60_000 /// One turn: 24 hours of quarters. public static let count = 96 /// The block-glyph scale: ▁▂▃▄▅▆▇█. public static let levels = 8 + /// The box's forecast counts as sun from this many watts up. + static let sunW: Double = 10 public enum Battery: Equatable, Sendable { case stores, gives, waits @@ -44,11 +46,22 @@ public struct WatchDial: Equatable, Sendable { /// The cheapest two hours ahead, as quarter indices, when they stand /// out from the rest. public let cheapest: ClosedRange? - /// Where the Crown gives a light tap: now, the cheapest window's - /// edges and each car goal. + /// "CHEAPEST 00:00–02:00 · 28,5 ÖRE", when `cheapest` is set. + public let cheapestText: String? + /// Where a finger or the Crown gives a light tap: now, the cheapest + /// window's edges and each car goal. public let landmarks: Set /// The box sent a battery plan. public let hasPlan: Bool + /// Where the box's forecast expects the sun inside the turn, from + /// sunrise to sunset. Empty without a forecast: no sky is drawn rather + /// than one worked out from the calendar. + public let sun: [ClosedRange] + /// Per quarter, read by a finger: "14:30", "98 ÖRE/KWH", "STORES · + /// 3,0 KW · CAR". Made once, so a drag costs a subscript. + public let times: [String] + public let prices: [String] + public let doing: [String] /// The face at `nowMs`. Prices come from the price record where it has /// them, else from the plan's own. @@ -64,18 +77,22 @@ public struct WatchDial: Equatable, Sendable { .sorted { $0.startMs < $1.startMs } let battery = (plan?.battery ?? []).sorted { $0.startMs < $1.startMs } let charges = plan?.charges ?? [] + let currency = prices?.prices.currency ?? plan?.currency ?? "SEK" - func price(_ t: Double) -> Double? { - // The last slot that starts at or before t, if it still covers t. - guard let i = Self.last(slots, atOrBefore: t, start: \.startMs) else { return nil } - return t < slots[i].startMs + slots[i].durationMs ? slots[i].totalMinor : nil - } - func batteryW(_ t: Double) -> Double? { - guard let i = Self.last(battery, atOrBefore: t, start: \.startMs) else { return nil } - return t < battery[i].startMs + battery[i].durationMs ? battery[i].batteryW : nil + // What holds in the part of quarter `q` still to come: the box + // starts its plan the moment it makes it, so the first slot often + // begins inside the quarter running now. + func holding(_ items: [T], _ q: Int, start: KeyPath, length: KeyPath) -> T? { + let a = max(grid.startMs(q), nowMs), b = grid.endMs(q) + let i = Self.last(items, atOrBefore: a, start: start) + if let i, a < items[i][keyPath: start] + items[i][keyPath: length] { return items[i] } + let next = (i ?? -1) + 1 + return next < items.count && items[next][keyPath: start] < b ? items[next] : nil } + func price(_ q: Int) -> Double? { holding(slots, q, start: \.startMs, length: \.durationMs)?.totalMinor } + func batteryW(_ q: Int) -> Double? { holding(battery, q, start: \.startMs, length: \.durationMs)?.batteryW } - let minors = (0.. PlanGlance.idleW ? .stores : ($0 < -PlanGlance.idleW ? .gives : .waits) } ?? .waits return Quarter( startMs: start, @@ -99,13 +116,18 @@ public struct WatchDial: Equatable, Sendable { ) } + let unit = PriceUnits.unit(currency) + func figure(_ minor: Double) -> String { Words.decimal(PriceStrip.text(abs(minor), currency)) } + var cheapest: ClosedRange? + var cheapestText: String? let ahead = slots.filter { $0.startMs + $0.durationMs > nowMs && $0.startMs < grid.toMs } if ahead.count >= 2, let block = PriceStrip.bestBlock(ahead, hours: 2, cheapest: true) { let mean = ahead.map(\.totalMinor).reduce(0, +) / Double(ahead.count) if block.meanMinor < mean * PlanGlance.cheapShare || block.meanMinor < 0, let a = grid.index(atMs: max(block.startMs, nowMs)), let b = grid.index(atMs: block.endMs - 1) { cheapest = a...b + cheapestText = PriceWord.cheapest.format(clock.label(block.startMs) + "–" + clock.label(block.endMs), figure(block.meanMinor) + " " + unit.label.uppercased()) } } @@ -113,13 +135,37 @@ public struct WatchDial: Equatable, Sendable { if let cheapest { marks.formUnion([cheapest.lowerBound, cheapest.upperBound]) } marks.formUnion(goals.keys) + let perKwh = unit.perKwh.uppercased() self.grid = grid + self.quarters = quarters self.nowMs = nowMs - self.currency = prices?.prices.currency ?? plan?.currency ?? "SEK" + self.currency = currency self.zone = zone self.cheapest = cheapest + self.cheapestText = cheapestText landmarks = marks hasPlan = !battery.isEmpty + sun = (plan?.daylight?.sun ?? []).compactMap { s in + let from = max(s.fromMs, first), to = min(s.toMs, grid.toMs) + return from < to ? from...to : nil + } + times = (0.. Double { angle(grid.startMs(i) + Self.quarterMs / 2) } - /// Where the hand points: now, or the start of the quarter the Crown - /// chose. + /// Where the turn begins and ends: +24 h meets now. + public var seam: Double { angle(grid.firstMs) } + + /// Where the hand points: now, or the start of the quarter chosen. public func handAngle(selected: Int?) -> Double { guard let i = selected, i > 0 else { return angle(nowMs) } return angle(grid.startMs(i)) @@ -166,11 +214,29 @@ public struct WatchDial: Equatable, Sendable { /// seam where tomorrow meets now reads as the end of the turn. public func fade(_ i: Int) -> Double { 1 - 0.55 * Double(i) / Double(Self.count - 1) } - // MARK: What the Crown reads + /// The quarter under a finger `x` right of and `y` below the dial's + /// centre. One turn holds each quarter once, so the angle alone finds + /// it. A finger that crosses the seam winds no further: coming from the + /// end it stays on the last quarter, from now on the first, so turning + /// back answers at once. + public func index(x: Double, y: Double, from previous: Int?) -> Int { + var a = atan2(x, -y) + if a < 0 { a += 2 * .pi } + var along = a - seam + if along < 0 { along += 2 * .pi } + let i = min(Self.count - 1, max(0, Int(along / (2 * .pi) * Double(Self.count)))) + guard let previous, abs(i - previous) > Self.count / 2 else { return i } + return previous < Self.count / 2 ? 0 : Self.count - 1 + } + + // MARK: What a finger or the Crown reads /// "14:30" - public func time(_ i: Int) -> String { - ZoneClock.of(zone).label(i == 0 ? nowMs : grid.startMs(i)) + public func time(_ i: Int) -> String { times[i] } + + /// "98", or "–" before the price is known: the widget's middle. + public func figure(_ i: Int) -> String { + quarters[i].minor.map { Words.decimal(PriceStrip.text($0, currency)) } ?? "–" } /// "98 öre", or "—" before the price is known. @@ -200,3 +266,43 @@ public struct WatchDial: Equatable, Sendable { return parts.joined(separator: " · ") } } + +/// The dial's mono words, on the phone and the watch. The English is the +/// key; the Swedish lives in `Resources/sv.lproj/Dial.strings`, and a test +/// checks each one has it. +enum DialWord: String, CaseIterable { + case stores = "STORES · %@" + case gives = "GIVES · %@" + case waits = "WAITS" + case noPlan = "NO PLAN" + case car = "CAR" + case carTo = "CAR TO %@" + case noPrice = "NO PRICE YET" + case planned = "PLANNED %@ · REPLANS EVERY 15 MIN" + case plannedOld = "PLAN FROM %@ · OLD" + case title = "Plan dial" + case summary = "The next 24 hours round a dial: the price, what the battery does and when the car charges." + + var text: String { Words.bundle.localizedString(forKey: rawValue, value: rawValue, table: "Dial") } + + func format(_ args: String...) -> String { + var out = text + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } +} + +extension PlanDial { + /// "Plan dial", for the widget gallery and VoiceOver. + public static var title: String { DialWord.title.text } + public static var summary: String { DialWord.summary.text } + + /// "PLANNED 16:51 · REPLANS EVERY 15 MIN", or once old, "PLAN FROM + /// 14:05 · OLD". + public static func planned(atMs: Double, old: Bool, zone: TimeZone) -> String { + (old ? DialWord.plannedOld : DialWord.planned).format(ZoneClock.of(zone).label(atMs)) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index 95ed7b9..bd4cdb8 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -55,14 +55,57 @@ 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? - public init(battery: [Slot]?, charges: [Charge], prices: [PriceSlot], currency: String, boxStale: Bool, timeZone: String? = nil) { + /// 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 the panels to make power. + 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 >= PlanDial.sunW { + 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) + } + } + + public init(battery: [Slot]?, charges: [Charge], prices: [PriceSlot], currency: String, boxStale: Bool, timeZone: String? = nil, daylight: Daylight? = nil) { self.battery = battery self.charges = charges self.prices = prices self.currency = currency self.boxStale = boxStale self.timeZone = timeZone + self.daylight = daylight } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings new file mode 100644 index 0000000..0aab377 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings @@ -0,0 +1,12 @@ +/* The plan dial's words, on the phone, its widget and the watch. English is the key (DialWord). */ +"STORES · %@" = "STORES · %@"; +"GIVES · %@" = "GIVES · %@"; +"WAITS" = "WAITS"; +"NO PLAN" = "NO PLAN"; +"CAR" = "CAR"; +"CAR TO %@" = "CAR TO %@"; +"NO PRICE YET" = "NO PRICE YET"; +"PLANNED %@ · REPLANS EVERY 15 MIN" = "PLANNED %@ · REPLANS EVERY 15 MIN"; +"PLAN FROM %@ · OLD" = "PLAN FROM %@ · OLD"; +"Plan dial" = "Plan dial"; +"The next 24 hours round a dial: the price, what the battery does and when the car charges." = "The next 24 hours round a dial: the price, what the battery does and when the car charges."; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings new file mode 100644 index 0000000..aa7e343 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings @@ -0,0 +1,12 @@ +/* The plan dial's words, on the phone, its widget and the watch. Keys are the English (DialWord). */ +"STORES · %@" = "LAGRAR · %@"; +"GIVES · %@" = "GER · %@"; +"WAITS" = "VILAR"; +"NO PLAN" = "INGEN PLAN"; +"CAR" = "BILEN"; +"CAR TO %@" = "BILEN TILL %@"; +"NO PRICE YET" = "INGET PRIS ÄN"; +"PLANNED %@ · REPLANS EVERY 15 MIN" = "PLANERAD %@ · PLANERAS OM VAR 15:E MIN"; +"PLAN FROM %@ · OLD" = "PLAN FRÅN %@ · GAMMAL"; +"Plan dial" = "Plantavla"; +"The next 24 hours round a dial: the price, what the battery does and when the car charges." = "De kommande 24 timmarna runt en tavla: priset, vad batteriet gör och när bilen laddar."; diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift index 323cb47..4be77bc 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift @@ -232,7 +232,7 @@ public enum WatchGlance { /// recent enough to show, and the plan and the price in words. public struct Face: Equatable, Sendable { public let atMs: Double - public let dial: WatchDial? + public let dial: PlanDial? public let batteryPct: Double? public let plan: PlanGlance.Moment public let price: PriceGlance.Moment @@ -244,16 +244,16 @@ public enum WatchGlance { let ends = [plan?.value.battery?.map { $0.startMs + $0.durationMs }.max(), prices?.value.prices.slots.map { $0.startMs + $0.durationMs }.max()].compactMap { $0 } let until = min(nowMs + 24 * 3_600_000, ends.max() ?? nowMs) var times = [nowMs] - var t = (nowMs / WatchDial.quarterMs).rounded(.down) * WatchDial.quarterMs + WatchDial.quarterMs + var t = (nowMs / PlanDial.quarterMs).rounded(.down) * PlanDial.quarterMs + PlanDial.quarterMs while t < until { times.append(t) - t += WatchDial.quarterMs + t += PlanDial.quarterMs } let level = now?.value.reading(.battery)?.levelPct return times.map { at in Face( atMs: at, - dial: plan == nil && prices == nil ? nil : WatchDial(plan: plan?.value, prices: prices?.value, nowMs: at), + dial: plan == nil && prices == nil ? nil : PlanDial(plan: plan?.value, prices: prices?.value, nowMs: at), batteryPct: now.flatMap { at - $0.record.readAtMs <= levelForMs ? level : nil }, plan: PlanGlance.moment(plan, atMs: at), price: PriceGlance.moment(prices, atMs: at) diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift new file mode 100644 index 0000000..f5268f9 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The plan dial as the phone, its widget and the watch draw it: the seam, +/// a finger round the ring, the sun from the box's forecast and the words +/// a finger reads. The plan and prices are `WatchTests`': from Wednesday +/// 12:00 the battery gives power 17–19 and stores 22:00–23:30, the car +/// charges 01:00–04:45 toward 80 %, and power costs 30 öre 02–04, 160 öre +/// 17–21 and 70 öre otherwise. +@Suite struct PlanDialTests { + let watch = WatchTests() + func at(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Double { watch.at(day, hour, minute) } + func near(_ a: Double, _ b: Double) -> Bool { abs(a - b) < 1e-9 || abs(abs(a - b) - 2 * .pi) < 1e-9 } + + func dial(at t: Double? = nil, daylight: PlanEntry.Daylight? = nil) -> PlanDial { + var plan = watch.plan() + plan.daylight = daylight + return PlanDial(plan: plan, prices: watch.prices(), nowMs: t ?? at(14, 12, 7)) + } + + /// Where a finger at quarter `i`'s middle sits, `r` from the centre. + func finger(_ d: PlanDial, _ i: Int, r: Double = 120) -> (x: Double, y: Double) { + let a = d.angle(quarter: i) + return (r * sin(a), -r * cos(a)) + } + + @Test func theSeamIsWhereTheTurnBegins() { + let d = dial() + #expect(near(d.seam, d.angle(at(14, 12)))) + // The hand at now sits just past it, inside the first quarter. + #expect(d.handAngle(selected: nil) > d.seam) + #expect(d.handAngle(selected: nil) - d.seam < 2 * .pi / 96) + } + + @Test func aFingerFindsTheQuarterUnderIt() { + let d = dial() + for i in [0, 1, 17, 48, 80, 95] { + let p = finger(d, i) + #expect(d.index(x: p.x, y: p.y, from: nil) == i) + #expect(d.index(x: p.x, y: p.y, from: max(0, i - 1)) == i) + } + } + + /// Past the seam the finger winds no further: from the end it stays on + /// the last quarter, from now on the first. + @Test func theSeamStopsAFinger() { + let d = dial() + let start = finger(d, 0), end = finger(d, 95) + #expect(d.index(x: start.x, y: start.y, from: 95) == 95) + #expect(d.index(x: start.x, y: start.y, from: 90) == 95) + #expect(d.index(x: end.x, y: end.y, from: 0) == 0) + #expect(d.index(x: end.x, y: end.y, from: 3) == 0) + // Half a turn on is still an honest move. + #expect(d.index(x: finger(d, 47).x, y: finger(d, 47).y, from: 1) == 47) + } + + /// The sky is the box's forecast inside the turn, never astronomy: at + /// noon the turn holds this afternoon's sun and tomorrow morning's. + @Test func theSunComesFromTheForecast() { + let day = PlanEntry.Daylight(covered: .init(fromMs: at(14, 0), toMs: at(16, 0)), sun: [ + .init(fromMs: at(14, 7, 30), toMs: at(14, 17, 30)), + .init(fromMs: at(15, 7, 45), toMs: at(15, 17, 15)), + ]) + let d = dial(daylight: day) + #expect(d.sun == [at(14, 12)...at(14, 17, 30), at(15, 7, 45)...at(15, 12)]) + // No forecast, no sky. + #expect(dial().sun.isEmpty) + // A forecast that has run out draws none either. + let old = PlanEntry.Daylight(covered: .init(fromMs: at(12, 0), toMs: at(13, 0)), sun: [.init(fromMs: at(12, 7), toMs: at(12, 17))]) + #expect(dial(daylight: old).sun.isEmpty) + } + + /// From the forecast's quarters: sun where it expects the panels to + /// make at least 10 W, joined into runs. + @Test func daylightFromTheForecast() throws { + let slots: [(startMs: Double, durationMs: Double, pvW: Double)] = (0..<96).map { i in + let t = at(14, 0) + Double(i) * WatchTests.quarter + let h = Double(i) / 4 + return (t, WatchTests.quarter, h >= 7.5 && h < 17 ? 1_500 : (h == 7.25 ? 4 : 0)) + } + let d = try #require(PlanEntry.Daylight(slots)) + #expect(d.covered == .init(fromMs: at(14, 0), toMs: at(15, 0))) + #expect(d.sun == [.init(fromMs: at(14, 7, 30), toMs: at(14, 17))]) + #expect(PlanEntry.Daylight([]) == nil) + } + + @Test func theMiddleAndTheScrubLineAreMadeOnce() { + let d = dial() + let six = d.grid.index(atMs: at(14, 18))!, ten = d.grid.index(atMs: at(14, 22))!, goal = d.grid.index(atMs: at(15, 4, 30))! + #expect(d.times[0] == "12:07" && d.times[six] == "18:00") + #expect(d.prices[six] == "160 ÖRE/KWH") + #expect(d.doing[six] == "GIVES · 2.0 KW") + #expect(d.doing[ten] == "STORES · 3.0 KW") + #expect(d.doing[goal] == "WAITS · CAR TO 80\u{00A0}%") + #expect(d.doing[d.grid.index(atMs: at(15, 2))!] == "WAITS · CAR") + #expect(d.figure(six) == "160") + #expect(d.cheapestText == "CHEAPEST 02:00–04:00 · 30.0 ÖRE") + // Without a plan, nothing is claimed about the battery. + let bare = PlanDial(plan: nil, prices: watch.prices(), nowMs: at(14, 12)) + #expect(bare.doing[0] == "NO PLAN") + // Before the market prices a quarter, the middle says so. + let short = PlanDial(plan: watch.plan(), prices: watch.prices(days: 1), nowMs: at(14, 12)) + #expect(short.prices[short.grid.index(atMs: at(15, 3))!] == "NO PRICE YET") + #expect(short.figure(short.grid.index(atMs: at(15, 3))!) == "–") + } + + /// The box starts its plan the moment it makes it, so the first slot + /// begins inside the quarter running now. That quarter still has its + /// battery and its price. + @Test func theQuarterRunningNowReadsAPlanMadeInsideIt() { + var plan = watch.plan() + let made = at(14, 17, 7) + plan.battery = [PlanEntry.Slot(startMs: made, durationMs: at(14, 17, 15) - made, batteryW: -2_000, reason: "test")] + + (plan.battery ?? []).filter { $0.startMs >= at(14, 17, 15) } + let d = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 17, 8)) + #expect(d.quarters[0].battery == .gives) + #expect(d.doing[0] == "GIVES · 2.0 KW") + #expect(d.quarters[0].minor == 160) + } + + @Test func thePlannedLine() { + let zone = TimeZone(identifier: WatchTests.zone)! + #expect(PlanDial.planned(atMs: at(14, 16, 51), old: false, zone: zone) == "PLANNED 16:51 · REPLANS EVERY 15 MIN") + #expect(PlanDial.planned(atMs: at(14, 14, 5), old: true, zone: zone) == "PLAN FROM 14:05 · OLD") + } + + /// The sun travels to the watch with the plan: an older record without + /// it still reads. + @Test func aRecordWithoutTheSunStillReads() throws { + var entry = watch.plan() + entry.daylight = PlanEntry.Daylight(covered: .init(fromMs: at(14, 0), toMs: at(15, 0)), sun: [.init(fromMs: at(14, 8), toMs: at(14, 16))]) + let back = try JSONDecoder().decode(PlanEntry.self, from: JSONEncoder().encode(entry)) + #expect(back == entry) + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(entry)) as? [String: Any]) + json["daylight"] = nil + let old = try JSONDecoder().decode(PlanEntry.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(old.daylight == nil) + } + + /// Every word has Swedish, with as many blanks as the English. + @Test func everyWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Dial", withExtension: "strings", subdirectory: nil, localization: "sv")) + let sv = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in DialWord.allCases { + let swedish = try #require(sv[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(!swedish.isEmpty) + #expect(word.rawValue.components(separatedBy: "%@").count == swedish.components(separatedBy: "%@").count, "\(word.rawValue)") + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift index 0a2308e..6ee67e0 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift @@ -195,7 +195,7 @@ import Testing @Test func theFaceIsTheNextDayInQuarters() { let t = at(14, 12, 7) - let dial = WatchDial(plan: plan(), prices: prices(), nowMs: t) + let dial = PlanDial(plan: plan(), prices: prices(), nowMs: t) #expect(dial.quarters.count == 96) #expect(dial.quarters[0].startMs == at(14, 12)) #expect(dial.quarters[95].startMs == at(15, 11, 45)) @@ -206,7 +206,7 @@ import Testing /// 00 at the bottom, 06 on the left, 12 at the top, 18 on the right. @Test func midnightSitsAtTheBottomAndNoonAtTheTop() { - let dial = WatchDial(plan: nil, prices: prices(), nowMs: at(14, 12)) + let dial = PlanDial(plan: nil, prices: prices(), nowMs: at(14, 12)) func near(_ a: Double, _ b: Double) -> Bool { abs(a - b) < 1e-9 || abs(abs(a - b) - 2 * .pi) < 1e-9 } #expect(near(dial.angle(at(14, 0)), .pi)) #expect(near(dial.angle(at(14, 6)), 1.5 * .pi)) @@ -217,8 +217,8 @@ import Testing } @Test func eachTickHoldsItsPriceLevelAndWhatTheBatteryDoes() { - let dial = WatchDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) - func q(_ day: Int, _ h: Int, _ m: Int = 0) -> WatchDial.Quarter { dial.quarters[dial.grid.index(atMs: at(day, h, m))!] } + let dial = PlanDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) + func q(_ day: Int, _ h: Int, _ m: Int = 0) -> PlanDial.Quarter { dial.quarters[dial.grid.index(atMs: at(day, h, m))!] } #expect(q(14, 18).level == 7) #expect(q(15, 2).level == 0) #expect(q(14, 13).level == 2) @@ -234,7 +234,7 @@ import Testing /// A tick never shows without a price: before tomorrow's prices come, /// those quarters have no level. @Test func unpricedQuartersHaveNoLevel() { - let dial = WatchDial(plan: plan(), prices: prices(days: 1), nowMs: at(14, 12)) + let dial = PlanDial(plan: plan(), prices: prices(days: 1), nowMs: at(14, 12)) let tomorrow = dial.grid.index(atMs: at(15, 3))! #expect(dial.quarters[tomorrow].minor == nil && dial.quarters[tomorrow].level == nil) #expect(dial.price(tomorrow) == "—") @@ -242,7 +242,7 @@ import Testing } @Test func theCrownTapsAtNowTheCheapestEdgesAndTheCarsGoal() { - let dial = WatchDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) + let dial = PlanDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) let cheap = try! #require(dial.cheapest) #expect(dial.grid.startMs(cheap.lowerBound) == at(15, 2)) #expect(dial.grid.startMs(cheap.upperBound) == at(15, 3, 45)) @@ -250,9 +250,9 @@ import Testing } @Test func aFlatDaySitsInTheMiddle() { - #expect(WatchDial.level(50, lo: 50, hi: 50) == 3) - #expect(WatchDial.level(50, lo: 0, hi: 100) == 4) - #expect(WatchDial.level(100, lo: 0, hi: 100) == 7) + #expect(PlanDial.level(50, lo: 50, hi: 50) == 3) + #expect(PlanDial.level(50, lo: 0, hi: 100) == 4) + #expect(PlanDial.level(100, lo: 0, hi: 100) == 7) } // MARK: The car, cheap power and the complication 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", + ] + more + app.launch() + return app + } + + private func dial(_ app: XCUIApplication) -> XCUIElement { + app.descendants(matching: .any)["plan.dial"].firstMatch + } + + /// One element that starts at now and reads the time, the price and + /// what the plan does then. + @MainActor + func testVoiceOverReadsTheDialAtNow() throws { + let app = launch(["-FTWPlanStyle", "dial"]) + let dial = dial(app) + XCTAssertTrue(dial.waitForExistence(timeout: 15), "no plan dial") + XCTAssertEqual(dial.label, "Plan dial") + // The demo's plan and prices arrive a moment after the view. + let priced = NSPredicate(format: "value CONTAINS 'öre' AND value CONTAINS 'Battery'") + XCTAssertEqual(XCTWaiter().wait(for: [expectation(for: priced, evaluatedWith: dial)], timeout: 10), .completed, "the dial does not read now: \(dial.value ?? "")") + } + + /// A drag round the ring reads the plan; the page stays where it is. + @MainActor + func testAFingerRoundTheRingKeepsThePageStill() throws { + let app = launch(["-FTWPlanStyle", "dial"]) + let dial = dial(app) + XCTAssertTrue(dial.waitForExistence(timeout: 15)) + let frame = dial.frame + // Two points on the ring, 60° apart, so the straight drag stays on it. + func ring(_ degrees: Double) -> XCUICoordinate { + let a = degrees * .pi / 180 + return dial.coordinate(withNormalizedOffset: CGVector(dx: 0.5 + 0.4 * sin(a), dy: 0.5 - 0.4 * cos(a))) + } + ring(30).press(forDuration: 0.05, thenDragTo: ring(90), withVelocity: .slow, thenHoldForDuration: 0.6) + ring(200).press(forDuration: 0.05, thenDragTo: ring(140), withVelocity: .default, thenHoldForDuration: 0.6) + XCTAssertEqual(dial.frame, frame, "the page moved under the finger") + XCTAssertTrue(app.navigationBars["Plan ahead"].exists) + } + + /// The lanes are one tap away, and the phone remembers the choice: a + /// new launch opens on the lanes until the viewer taps the dial again. + @MainActor + func testTheLanesAreOneTapAwayAndStay() throws { + var app = launch() + let style = app.segmentedControls["plan.style"] + XCTAssertTrue(style.waitForExistence(timeout: 15), "no dial or lanes switch") + if !dial(app).exists { style.buttons["Dial"].tap() } + XCTAssertTrue(dial(app).waitForExistence(timeout: 5), "the dial is not the default") + XCTAssertTrue(style.isHittable, "the switch is not in reach") + + style.buttons["Lanes"].tap() + XCTAssertTrue(dial(app).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(dial(app).exists) + + // Back to the dial, as the next run expects. + app.segmentedControls["plan.style"].buttons["Dial"].tap() + XCTAssertTrue(dial(app).waitForExistence(timeout: 5)) + } +} diff --git a/appleApp/FTWWatch/PlanPage.swift b/appleApp/FTWWatch/PlanPage.swift index 101e8a4..ff5c6d0 100644 --- a/appleApp/FTWWatch/PlanPage.swift +++ b/appleApp/FTWWatch/PlanPage.swift @@ -20,13 +20,13 @@ struct PlanPage: View { let ctx = model.context let dial = faces.dial(plan: ctx?.plan, prices: ctx?.prices, nowMs: nowMs) let moment = PlanGlance.moment(ctx?.plan.map { CacheRead(record: $0, origin: .app) }, atMs: nowMs) - let i = min(max(quarter, 0), WatchDial.count - 1) + let i = min(max(quarter, 0), PlanDial.count - 1) GeometryReader { geo in // The dial takes what the two lines under it leave. let side = max(80, min(geo.size.width - 4, geo.size.height - 30)) VStack(spacing: 2) { ZStack { - DialFace(dial: dial, selected: i, grey: moment.isGrey) + PlanDialView(dial: dial, selected: i, grey: moment.isGrey) VStack(spacing: 0) { Text(dial.time(i)) .font(Theme.figure(side * 0.15)) @@ -59,7 +59,7 @@ struct PlanPage: View { } .focusable() .focused($focused) - .digitalCrownRotation(detent: $quarter, from: 0, through: WatchDial.count - 1, by: 1, sensitivity: .high, isContinuous: false, isHapticFeedbackEnabled: false) + .digitalCrownRotation(detent: $quarter, from: 0, through: PlanDial.count - 1, by: 1, sensitivity: .high, isContinuous: false, isHapticFeedbackEnabled: false) .onChange(of: quarter) { _, q in scrub.move(to: q, landmark: dial.landmarks.contains(q)) } @@ -69,7 +69,7 @@ struct PlanPage: View { .accessibilityHint(WatchWord.turnTheCrown.text) .accessibilityAdjustableAction { direction in switch direction { - case .increment: quarter = min(quarter + 1, WatchDial.count - 1) + case .increment: quarter = min(quarter + 1, PlanDial.count - 1) case .decrement: quarter = max(quarter - 1, 0) @unknown default: break } @@ -108,13 +108,13 @@ struct PlanPage: View { @MainActor final class DialMemo { @ObservationIgnored private var key: String? - @ObservationIgnored private var kept: WatchDial? + @ObservationIgnored private var kept: PlanDial? - func dial(plan: CacheRecord?, prices: CacheRecord?, nowMs: Double) -> WatchDial { + func dial(plan: CacheRecord?, prices: CacheRecord?, nowMs: Double) -> PlanDial { let minute = (nowMs / 60_000).rounded(.down) let key = "\(plan?.readAtMs ?? 0)|\(prices?.readAtMs ?? 0)|\(minute)" if key == self.key, let kept { return kept } - let d = WatchDial(plan: plan?.value, prices: prices?.value, nowMs: nowMs) + let d = PlanDial(plan: plan?.value, prices: prices?.value, nowMs: nowMs) self.key = key kept = d return d diff --git a/appleApp/FTWWatchShared/BlockBars.swift b/appleApp/FTWWatchShared/BlockBars.swift index 4293987..a21f589 100644 --- a/appleApp/FTWWatchShared/BlockBars.swift +++ b/appleApp/FTWWatchShared/BlockBars.swift @@ -23,10 +23,10 @@ struct BlockBars: View { let lo = values.min() ?? 0, hi = values.max() ?? 0 let gap: CGFloat = bars.count > 30 ? 0.5 : 1 let w = (size.width - gap * CGFloat(max(bars.count - 1, 0))) / CGFloat(max(bars.count, 1)) - let step = size.height / CGFloat(WatchDial.levels) + let step = size.height / CGFloat(PlanDial.levels) var p = Path() for (i, b) in bars.enumerated() where b.current == current { - let level = hi - lo < 0.5 ? WatchDial.levels / 2 - 1 : min(WatchDial.levels - 1, Int((b.minor - lo) / (hi - lo) * Double(WatchDial.levels))) + let level = hi - lo < 0.5 ? PlanDial.levels / 2 - 1 : min(PlanDial.levels - 1, Int((b.minor - lo) / (hi - lo) * Double(PlanDial.levels))) let h = step * CGFloat(level + 1) p.addRect(CGRect(x: CGFloat(i) * (w + gap), y: size.height - h, width: max(0.6, w), height: h)) } diff --git a/appleApp/FTWWatchShared/DialFace.swift b/appleApp/FTWWatchShared/DialFace.swift deleted file mode 100644 index 367bda2..0000000 --- a/appleApp/FTWWatchShared/DialFace.swift +++ /dev/null @@ -1,155 +0,0 @@ -import FTWShared -import SwiftUI -import WidgetKit - -/// The plan dial's face, drawn from a `WatchDial`: price ticks coloured by -/// what the battery does, the car's dotted arc and the hand. The watch app -/// draws it large with its hours and the Crown's choice; the complication -/// draws the same face small. -/// -/// Every mark is a still path filled once. Nothing glows: a plan is intent, -/// not a reading. -struct DialFace: View { - enum Size { case app, complication } - - let dial: WatchDial - /// The quarter the Crown chose; nil or 0 points the hand at now. - var selected: Int? = nil - /// The plan is old or the box could not plan: every tick in ink3. - var grey = false - var size: Size = .app - - var body: some View { - GeometryReader { geo in - let side = min(geo.size.width, geo.size.height) - let g = DialGeometry(side: side, size: size) - ZStack { - if size == .app { - Circle() - .stroke(Theme.ink.opacity(0.18), lineWidth: 0.5) - .frame(width: g.rim * 2, height: g.rim * 2) - HourLabels(g: g, dial: dial) - } - ticks(g, .waits).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink3.opacity(0.6)) - ticks(g, .gives).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink) - ticks(g, .stores).fill(grey ? Theme.ink3.opacity(0.45) : Theme.stores) - .widgetAccentable() - stubs(g).fill(Theme.ink3.opacity(0.5)) - carDots(g).fill(grey ? Theme.ink3 : Theme.car) - .widgetAccentable() - if size == .app, let i = selected, i > 0 { - lockOn(g, i).fill(Theme.ink) - } - hand(g).stroke(Theme.ink, style: StrokeStyle(lineWidth: size == .app ? 1.5 : 1.2, lineCap: .round)) - } - .frame(width: geo.size.width, height: geo.size.height) - } - } - - // MARK: Paths - - /// One path per battery action, so each fills in one go. - private func ticks(_ g: DialGeometry, _ action: WatchDial.Battery) -> Path { - var p = Path() - for (i, q) in dial.quarters.enumerated() where q.battery == action { - guard let level = q.level else { continue } - let length = g.unit * Double(level + 1) - p.addPath(g.tick(at: dial.angle(quarter: i), from: g.inner, length: length, width: g.tickWidth * dial.fade(i).squareRoot())) - } - return p - } - - /// Quarters the market has not priced yet: a short stub, never a level. - private func stubs(_ g: DialGeometry) -> Path { - var p = Path() - for (i, q) in dial.quarters.enumerated() where q.minor == nil && i % 2 == 0 { - p.addPath(g.tick(at: dial.angle(quarter: i), from: g.inner, length: g.unit * 0.6, width: g.tickWidth * 0.8)) - } - return p - } - - /// The car's charging: a dotted arc inside the ticks, a dot every - /// other quarter. - private func carDots(_ g: DialGeometry) -> Path { - var p = Path() - for (i, q) in dial.quarters.enumerated() where q.car && i % 2 == 0 { - let c = g.point(dial.angle(quarter: i), g.carRadius) - p.addEllipse(in: CGRect(x: c.x - g.dot / 2, y: c.y - g.dot / 2, width: g.dot, height: g.dot)) - } - return p - } - - /// The quarter the Crown chose: its tick at full length, in ink, and the - /// phone's lock-on brackets (`LockOn`) closing on its outer end. - private func lockOn(_ g: DialGeometry, _ i: Int) -> Path { - let a = dial.angle(quarter: i) - var p = g.tick(at: a, from: g.inner - g.unit * 0.8, length: g.rim - g.inner + g.unit * 0.8, width: g.tickWidth * 1.6) - p.addPath(LockOn.brackets(around: g.point(a, g.rim - g.unit), half: g.unit * 1.6, arm: g.unit * 0.8) - .strokedPath(StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round))) - return p - } - - private func hand(_ g: DialGeometry) -> Path { - let a = dial.handAngle(selected: selected) - var p = Path() - p.move(to: g.point(a, g.inner - g.unit * 1.2)) - p.addLine(to: g.point(a, g.rim + (size == .app ? 3 : 1))) - return p - } -} - -/// Where things sit on a dial `side` points across. -struct DialGeometry { - let side: CGFloat - let size: DialFace.Size - - var centre: CGPoint { CGPoint(x: side / 2, y: side / 2) } - /// The outer edge of the dearest tick. - var rim: CGFloat { side / 2 - (size == .app ? 11 : 1) } - /// The hours sit just outside the rim. - var labelRadius: CGFloat { side / 2 - 5 } - /// Where every tick starts. - var inner: CGFloat { rim * (size == .app ? 0.62 : 0.56) } - /// One price level's length. - var unit: CGFloat { (rim - inner) / CGFloat(WatchDial.levels) } - var tickWidth: CGFloat { max(0.9, side * (size == .app ? 0.0085 : 0.012)) } - var carRadius: CGFloat { inner - unit * (size == .app ? 1.6 : 1.4) } - var dot: CGFloat { max(1.4, side * 0.012) } - - /// A point `r` from the centre, `a` radians clockwise from the top. - func point(_ a: Double, _ r: CGFloat) -> CGPoint { - CGPoint(x: centre.x + r * CGFloat(sin(a)), y: centre.y - r * CGFloat(cos(a))) - } - - /// A tick as a thin quad along the radius. - func tick(at a: Double, from r0: CGFloat, length: CGFloat, width: CGFloat) -> Path { - let half = Double(width / 2) - let nx = CGFloat(cos(a)) * CGFloat(half), ny = CGFloat(sin(a)) * CGFloat(half) - let p0 = point(a, r0), p1 = point(a, r0 + length) - var p = Path() - p.move(to: CGPoint(x: p0.x - nx, y: p0.y - ny)) - p.addLine(to: CGPoint(x: p1.x - nx, y: p1.y - ny)) - p.addLine(to: CGPoint(x: p1.x + nx, y: p1.y + ny)) - p.addLine(to: CGPoint(x: p0.x + nx, y: p0.y + ny)) - p.closeSubpath() - return p - } -} - -/// 00, 06, 12 and 18 just outside the rim, in the terminal's mono. -private struct HourLabels: View { - let g: DialGeometry - let dial: WatchDial - - var body: some View { - ForEach([0, 6, 12, 18], id: \.self) { h in - // 00 at the bottom, 06 on the left, 12 at the top. - let a = Double(h) / 24 * 2 * .pi + .pi - let p = g.point(a, g.labelRadius) - Text(verbatim: h < 10 ? "0\(h)" : "\(h)") - .font(.system(size: 9, weight: .regular, design: .monospaced)) - .foregroundStyle(Theme.ink3) - .position(p) - } - } -} diff --git a/appleApp/FTWWatchWidgets/PlanComplication.swift b/appleApp/FTWWatchWidgets/PlanComplication.swift index 2a20fd4..18f4439 100644 --- a/appleApp/FTWWatchWidgets/PlanComplication.swift +++ b/appleApp/FTWWatchWidgets/PlanComplication.swift @@ -65,7 +65,7 @@ struct PlanComplicationView: View { private var circular: some View { ZStack { if let dial = face.dial { - DialFace(dial: dial, grey: face.plan.isGrey, size: .complication) + PlanDialView(dial: dial, grey: face.plan.isGrey, size: .small) } else { Circle().stroke(Theme.ink3, lineWidth: 1) } diff --git a/appleApp/FTWWidgets/Plan/PlanDialWidget.swift b/appleApp/FTWWidgets/Plan/PlanDialWidget.swift new file mode 100644 index 0000000..6582cee --- /dev/null +++ b/appleApp/FTWWidgets/Plan/PlanDialWidget.swift @@ -0,0 +1,189 @@ +import FTWShared +import SwiftUI +import WidgetKit + +/// The plan dial in StandBy, on the home screen and as a circle on the +/// lock screen, from the plan the app left in the App Group. The same face +/// as the plan view and the watch, in the watch's material: a still path +/// on a dark ground, nothing glowing. It opens no connection. The hand +/// moves on by the quarter hour; an hour after the box made the plan the +/// dial goes grey and says how old the plan is. +struct PlanDialWidget: Widget { + static let kind = "energy.ftw.plan-dial" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: PlanDialTimeline()) { entry in + PlanDialWidgetView(entry: entry) + } + .configurationDisplayName(PlanDial.title) + .description(PlanDial.summary) + // systemSmall is also the StandBy widget. + .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 and no prices. + let dial: PlanDial? + let moment: PlanGlance.Moment + /// When the box made the plan, or nil without one. + let plannedAtMs: Double? +} + +struct PlanDialTimeline: TimelineProvider { + /// How far ahead one timeline runs; the app reloads it with each plan. + static let aheadMs: Double = 12 * 3_600_000 + + func placeholder(in context: Context) -> PlanDialEntry { + let now = Date.now.timeIntervalSince1970 * 1000 + return Self.entries(Self.sample(nowMs: now), nowMs: 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, nowMs: now, count: 1)[0]) + } + + /// One entry now and one at each quarter hour after it, so the hand + /// moves without waking the app. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date.now.timeIntervalSince1970 * 1000 + let entries = Self.entries(Self.read(), nowMs: 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) } } + } + + static func entries(_ read: CacheRead?, nowMs: Double, count: Int) -> [PlanDialEntry] { + (0.. CacheRead { + let hour: Double = 3_600_000 + let start = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)).timeIntervalSince1970 * 1000 + let battery = (0..<192).map { i -> PlanEntry.Slot in + let h = (Double(i) / 4).truncatingRemainder(dividingBy: 24) + 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..<48).map { i -> PriceSlot in + let wave = sin(Double(i % 24 - 9) / 24 * 2 * .pi) + return PriceSlot(startMs: start + Double(i) * hour, durationMs: hour, spotMinor: 40, totalMinor: 110 + 50 * wave + Double((i * 37) % 20)) + } + let entry = PlanEntry( + battery: battery, + charges: [.init(loadpointID: "car", fromMs: start + 26 * hour, toMs: start + 29 * 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)]) + ) + return CacheRead(record: CacheRecord(siteId: "", carrier: .none, srcState: .live, readAtMs: nowMs, value: entry), origin: .app) + } +} + +struct PlanDialWidgetView: View { + let entry: PlanDialEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + content + .containerBackground(for: .widget) { + // The watch's material: the dial on its own dark ground in + // either scheme, so it reads on any wallpaper and in StandBy + // at night. + if family == .systemSmall { Theme.ground.environment(\.colorScheme, .dark) } else { Color.clear } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(PlanDial.title) + .accessibilityValue(entry.moment.summary) + } + + @ViewBuilder private var content: some View { + switch family { + case .accessoryCircular: circle + default: small.environment(\.colorScheme, .dark) + } + } + + /// StandBy and the home screen: the dial with the price now in the + /// middle, and under it when the box made the plan, always. + private var small: some View { + GeometryReader { geo in + let room = min(geo.size.width, geo.size.height) + let side = room - 26 + VStack(spacing: 3) { + if let dial = entry.dial { + ZStack { + PlanDialView(dial: dial, grey: entry.moment.isGrey, size: .small) + VStack(spacing: 0) { + Text(dial.figure(0)) + .font(Theme.figure(side * 0.17)) + .foregroundStyle(entry.moment.isGrey ? Theme.ink3 : Theme.ink) + Text(PriceUnits.unit(dial.currency).perKwh.uppercased()) + .font(.system(size: max(7, side * 0.052), weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.ink2) + } + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(width: side * 0.42) + } + .frame(width: side, height: side) + Text(planned(dial) ?? " ") + .font(.system(size: max(7, room * 0.05), weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.ink3) + .lineLimit(1) + .minimumScaleFactor(0.7) + } else { + Text(entry.moment.summary) + .font(.caption) + .foregroundStyle(Theme.ink2) + .multilineTextAlignment(.center) + .padding() + } + } + .frame(width: geo.size.width, height: geo.size.height) + } + } + + /// The lock screen: the dial and the price now. + private var circle: some View { + ZStack { + if let dial = entry.dial { + PlanDialView(dial: dial, grey: entry.moment.isGrey, size: .small) + Text(figure(dial)) + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .minimumScaleFactor(0.7) + .widgetAccentable() + } else { + Circle().stroke(Theme.ink3, lineWidth: 1) + } + } + } + + /// "98", the price in the quarter running now. + private func figure(_ dial: PlanDial) -> String { dial.figure(0) } + + /// "PLANNED 16:45", or once old, "PLAN FROM 14:05 · OLD". + private func planned(_ dial: PlanDial) -> String? { + entry.plannedAtMs.map { PlanDial.planned(atMs: $0, old: entry.moment.state == .old, zone: dial.zone).components(separatedBy: " · ").first ?? "" } + } +} diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index cd4af44..63af632 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() GridTodayWidget()