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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 0 additions & 36 deletions appleApp/FTW/UI/ChartMotion.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
281 changes: 281 additions & 0 deletions appleApp/FTW/UI/PlanDialCard.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
Loading
Loading