From 46186cf8e388350695c84c0bf5f82ae7a5c9d1ea Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 17:22:09 +0200 Subject: [PATCH 1/5] Apple Watch W1.1: Now alive on one screen, pages without scrolling, and graphic complications Fredrik's notes on W1 (14 Oct 2026): the watch widgets let text run outside and should hold little text; the app should have the phone's live view and pages instead of scrolling. - Now: the status line, one node's figure large, the phone's live trace (LiveSeries drawn by LiveTraceSurface in TraceInk), glowing only while live, and the other nodes small. A tap on a small one traces it. The mode sits behind a toolbar button. - Every page fits one screen at the standard sizes; at text sizes too large for the watch it scrolls rather than cut a reading off. The car's commands are one row of glyph buttons. - Complications: no sentences. Rectangular is a glyph, one time, the price now and the plan unrolled as a strip; corner is the price with the day's range as a gauge; inline is the price; the cheap card is a glyph, its hours, one figure and the hours ahead with the window standing out. Every text has one line and may shrink. - LiveTraceSurface takes `paused`, so a trace that is not live holds still. WatchNow.Reading gives its signed watts for the trace, and each node the phone's scale floor. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWCharts/LiveTrace.swift | 5 +- .../Sources/FTWShared/Watch/WatchGlance.swift | 6 +- .../Sources/FTWShared/Watch/WatchWire.swift | 22 +++ .../Tests/FTWSharedTests/WatchTests.swift | 23 +++ appleApp/FTWWatch/CarPage.swift | 100 +++++----- appleApp/FTWWatch/NowPage.swift | 172 ++++++++++++++++++ appleApp/FTWWatch/Phosphor.swift | 13 ++ appleApp/FTWWatch/PricePage.swift | 35 ++-- appleApp/FTWWatch/WatchModel.swift | 17 ++ appleApp/FTWWatch/WatchRoot.swift | 90 +-------- appleApp/FTWWatch/WatchTrace.swift | 82 +++++++++ appleApp/FTWWatchShared/BlockBars.swift | 18 +- appleApp/FTWWatchShared/PlanStrip.swift | 78 ++++++++ appleApp/FTWWatchUITests/WatchTour.swift | 40 +++- .../FTWWatchWidgets/CheapPowerWidget.swift | 42 +++-- .../FTWWatchWidgets/PlanComplication.swift | 98 ++++++---- 16 files changed, 635 insertions(+), 206 deletions(-) create mode 100644 appleApp/FTWWatch/NowPage.swift create mode 100644 appleApp/FTWWatch/WatchTrace.swift create mode 100644 appleApp/FTWWatchShared/PlanStrip.swift diff --git a/appleApp/FTWCharts/LiveTrace.swift b/appleApp/FTWCharts/LiveTrace.swift index 329082e..99f26b4 100644 --- a/appleApp/FTWCharts/LiveTrace.swift +++ b/appleApp/FTWCharts/LiveTrace.swift @@ -106,6 +106,9 @@ struct LiveTraceSurface: View { /// when it lifted: the window springs from there back to now. var catchUp: (fromMs: Double, at: Date)? var axisSize: CGFloat = 10 + /// Hold still at the last reading: a trace that is not live has + /// nothing to scroll toward (the watch, whose redraws cost battery). + var paused = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.scenePhase) private var phase @@ -119,7 +122,7 @@ struct LiveTraceSurface: View { var body: some View { let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled let moving = settledAt.map { $0 < max(frame.arrivedAt, catchUp?.at ?? .distantPast) } ?? true - let still = reduceMotion || frozenAtMs != nil || phase != .active + let still = reduceMotion || frozenAtMs != nil || phase != .active || paused TimelineView(.animation(minimumInterval: lowPower ? 1.0 / 10 : (moving ? nil : 1.0 / 15), paused: still)) { tick in Canvas(rendersAsynchronously: false) { g, size in #if DEBUG diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift index 323cb47..832ff4a 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift @@ -195,6 +195,9 @@ public enum WatchGlance { public let range: String /// "28,5 öre" public let price: String + /// "29": the price in whole units, for a slot with room for one + /// figure. + public let short: String public var id: Double { startMs } } @@ -220,7 +223,8 @@ public enum WatchGlance { endMs: block.endMs, meanMinor: block.meanMinor, range: "\(clock.label(block.startMs))–\(clock.label(block.endMs))", - price: "\(Words.decimal(PriceStrip.text(block.meanMinor, currency))) \(PriceUnits.unit(currency).label)" + price: "\(Words.decimal(PriceStrip.text(block.meanMinor, currency))) \(PriceUnits.unit(currency).label)", + short: "\(Int(PriceUnits.display(block.meanMinor, currency).rounded()))" )) } return out diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift index f1ea622..9fd1e08 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift @@ -19,6 +19,16 @@ public struct WatchNow: CacheValue { public enum Node: String, Codable, Sendable, CaseIterable { case solar, battery, home, grid, car + + /// The least a trace's scale reaches, as the phone's node view has + /// it (`EnergyNode.scaleFloorW`), so a quiet node draws flat. + public var floorW: Double { + switch self { + case .home: return 2_000 + case .grid, .battery, .solar: return 3_000 + case .car: return 3_700 + } + } } /// The word beside a figure. The sign is in the word, never a minus. @@ -39,6 +49,18 @@ public struct WatchNow: CacheValue { /// source live. The watch adds its own rule on arrival. public var live: Bool + /// The watts with their direction as a trace draws them: the sun's + /// output, the home's use and the car's draw up; the battery up + /// while it stores and down while it gives; the grid up while the + /// home buys and down while it sells. + public var signedW: Double? { + guard let watts else { return nil } + switch way { + case .giving, .selling: return -watts + default: return watts + } + } + public init(node: Node, watts: Double?, way: Way, levelPct: Double? = nil, levelAssumed: Bool = false, live: Bool) { self.node = node self.watts = watts diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift index 0a2308e..e3e37b1 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift @@ -311,6 +311,29 @@ import Testing #expect(faces.allSatisfy { $0.dial != nil }) } + // MARK: The trace on the wrist + + /// The trace draws each direction on its side of zero, as the phone's + /// node view does: giving and selling below, everything else above. + @Test func aReadingKeepsItsDirectionForTheTrace() { + func r(_ node: WatchNow.Node, _ w: Double?, _ way: WatchNow.Way) -> Double? { + WatchNow.Reading(node: node, watts: w, way: way, live: true).signedW + } + #expect(r(.battery, 1_200, .giving) == -1_200) + #expect(r(.battery, 900, .charging) == 900) + #expect(r(.grid, 300, .selling) == -300) + #expect(r(.grid, 300, .buying) == 300) + #expect(r(.solar, 2_000, .making) == 2_000) + #expect(r(.home, 412, .using) == 412) + #expect(r(.grid, nil, .noReading) == nil) + #expect(WatchNow.Node.home.floorW == 2_000 && WatchNow.Node.car.floorW == 3_700) + } + + @Test func aCheapWindowHasOneFigureForASmallSlot() { + let w = WatchGlance.cheapWindows(prices(), nowMs: at(14, 12)) + #expect(w.map(\.short) == ["70", "30"]) + } + // MARK: Words @Test func everyWatchWordHasSwedish() throws { diff --git a/appleApp/FTWWatch/CarPage.swift b/appleApp/FTWWatch/CarPage.swift index 8f532e1..21e6d8c 100644 --- a/appleApp/FTWWatch/CarPage.swift +++ b/appleApp/FTWWatch/CarPage.swift @@ -1,9 +1,13 @@ import FTWShared import SwiftUI -/// The car as the box last described it, dated, and the three commands: -/// Charge now, Pause and Back to the plan. Each asks once more, goes -/// through the phone, and shows the box's receipt under the buttons. +/// The car on one screen: its level as the 10-cell meter, what it does, +/// its goal and next window, when that was read, the receipt of the last +/// command, and the three commands as one row of buttons. Each asks once +/// more and names itself in full there; the box's answers come back as the +/// receipt. +/// +/// With more than one charger, the one with a car plugged in shows. struct CarPage: View { let model: WatchModel @State private var asking: Ask? @@ -19,24 +23,22 @@ struct CarPage: View { TimelineView(.everyMinute) { timeline in let nowMs = timeline.date.timeIntervalSince1970 * 1000 let cars = WatchGlance.cars(model.context?.cars, plan: model.context?.plan?.value, nowMs: nowMs, zone: model.zone) - ScrollView { - VStack(alignment: .leading, spacing: 8) { - if model.context?.cars == nil { - // Nothing read yet is not "no charger": the shape of - // the page until the phone says. - CarBlock(car: .shape, model: model, ask: { _ in }) - .redacted(reason: .placeholder) - .disabled(true) - } else if cars.isEmpty { - Text(WatchWord.noCharger.text) - .font(.footnote) - .foregroundStyle(Theme.ink2) - } - ForEach(cars) { car in - CarBlock(car: car, model: model, ask: { asking = $0 }) - } + let car = cars.first(where: \.pluggedIn) ?? cars.first + Group { + if let car { + OnePage { CarBlock(car: car, model: model, ask: { asking = $0 }) } + } else if model.context?.cars == nil { + // Nothing read yet is not "no charger": the shape of the + // page until the phone says. + CarBlock(car: .shape, model: model, ask: { _ in }) + .redacted(reason: .placeholder) + .disabled(true) + } else { + Text(WatchWord.noCharger.text) + .font(.footnote) + .foregroundStyle(Theme.ink2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .frame(maxWidth: .infinity, alignment: .leading) } } .navigationTitle(WatchWord.car.text) @@ -52,13 +54,14 @@ private struct CarBlock: View { let ask: (CarPage.Ask) -> Void var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 6) { + // "20 % antaget": a level FTW assumed says so. Text(car.levelText ?? "—") .font(Theme.figure(26)) .foregroundStyle(Theme.ink) - .minimumScaleFactor(0.7) .lineLimit(1) + .minimumScaleFactor(0.7) Spacer(minLength: 0) if let cells = car.cells { Meter(cells: cells, hue: Theme.car) @@ -67,17 +70,20 @@ private struct CarBlock: View { Text(car.state) .font(.footnote.weight(.semibold)) .foregroundStyle(Theme.ink) - if let line = [car.goal, car.next].compactMap({ $0 }).nilIfEmpty?.joined(separator: " · ") { - Text(line) - .font(.caption2) - .foregroundStyle(Theme.ink2) - } + .lineLimit(1) + .minimumScaleFactor(0.8) + Text([car.goal, car.next].compactMap { $0 }.joined(separator: " · ")) + .font(.caption2) + .foregroundStyle(Theme.ink2) + .lineLimit(1) + .minimumScaleFactor(0.8) Text(car.asOf) .font(Theme.mono(.caption2)) .foregroundStyle(Theme.ink3) - + .lineLimit(1) + Spacer(minLength: 2) // Room for the receipt, above the buttons, so nothing moves - // when it lands and it shows where the tap was. + // when it lands. ZStack(alignment: .topLeading) { ReceiptLine(receipt: WatchReceipt(commandID: "", op: .mode(""), sent: .waiting, accepted: .waiting, confirmed: .waiting, text: "\n", tone: .working)) .hidden() @@ -90,25 +96,31 @@ private struct CarBlock: View { .foregroundStyle(Theme.ink2) } } - let sending = model.sending(car.loadpointID) - let off = !model.canSend || sending - VStack(spacing: 4) { - command(WatchWord.chargeNow.text, .chargeNow(loadpointID: car.loadpointID), WatchWord.chargeNowAsk.text, off: off) + let off = !model.canSend || model.sending(car.loadpointID) + HStack(spacing: 6) { + command("bolt.fill", WatchWord.chargeNow.text, .chargeNow(loadpointID: car.loadpointID), WatchWord.chargeNowAsk.text, off: off, on: car.holding) .accessibilityIdentifier("watch.car.chargeNow") - command(WatchWord.pause.text, .pause(loadpointID: car.loadpointID), WatchWord.pauseAsk.text, off: off) - command(WatchWord.backToPlan.text, .backToPlan(loadpointID: car.loadpointID), WatchWord.backToPlanAsk.text, off: off) + command("pause.fill", WatchWord.pause.text, .pause(loadpointID: car.loadpointID), WatchWord.pauseAsk.text, off: off, on: false) + command("arrow.uturn.backward", WatchWord.backToPlan.text, .backToPlan(loadpointID: car.loadpointID), WatchWord.backToPlanAsk.text, off: off, on: false) } - .padding(.top, 2) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private func command(_ title: String, _ op: WatchCommand.Op, _ question: String, off: Bool) -> some View { - Button(title) { ask(CarPage.Ask(op: op, question: question, action: title)) } - .buttonStyle(.bordered) - .disabled(off) + /// One command: a glyph on the button, its full name in the question + /// and to VoiceOver. `on` marks the one the box last confirmed as + /// holding. + private func command(_ glyph: String, _ title: String, _ op: WatchCommand.Op, _ question: String, off: Bool, on: Bool) -> some View { + Button { + ask(CarPage.Ask(op: op, question: question, action: title)) + } label: { + Image(systemName: glyph) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(on ? Theme.car : Theme.ink) + .frame(maxWidth: .infinity, minHeight: 34) + } + .buttonStyle(.bordered) + .disabled(off) + .accessibilityLabel(title) } } - -extension Array { - var nilIfEmpty: [Element]? { isEmpty ? nil : self } -} diff --git a/appleApp/FTWWatch/NowPage.swift b/appleApp/FTWWatch/NowPage.swift new file mode 100644 index 0000000..0e3b3ed --- /dev/null +++ b/appleApp/FTWWatch/NowPage.swift @@ -0,0 +1,172 @@ +import FTWShared +import SwiftUI + +/// Now, alive, on one screen: the status line, one node's figure large, +/// its live trace, and the other nodes small. A tap on a small one makes +/// it the traced one. The trace and the figures glow only while the phone +/// passes on live frames; anything else is ink with the minute it was read. +/// How the home is run sits behind the toolbar's button. +struct NowPage: View { + let model: WatchModel + /// Always On redraws once a minute: a figure lit then would show an old + /// reading as live, so nothing glows while the wrist is down. + @Environment(\.isLuminanceReduced) private var dim + + var body: some View { + let lit = model.lit && !dim + let figures = WatchGlance.figures(model.now?.value, lit: lit) + let hero = figures.first { $0.node == model.trace.node } ?? figures.first + let rest = figures.filter { $0.node != hero?.node } + OnePage { + nowStack(lit: lit, figures: figures, hero: hero, rest: rest) + } + // A home without the traced node (no car, no sun) traces its first. + .onChange(of: figures.map(\.node), initial: true) { _, nodes in + if let first = nodes.first, !nodes.contains(model.trace.node) { model.trace.follow(first) } + } + .navigationTitle(WatchWord.nowTitle.text) + .toolbar { modeButton } + } + + private func nowStack(lit: Bool, figures: [WatchGlance.Figure], hero: WatchGlance.Figure?, rest: [WatchGlance.Figure]) -> some View { + VStack(alignment: .leading, spacing: 3) { + StatusLine(status: WatchGlance.status(model.now, lit: lit, demo: model.context?.isDemo == true, zone: model.zone)) + // Clear of the page dots at the right edge. + .padding(.trailing, 10) + if let hero { + HeroFigure(figure: hero, way: model.now?.value.reading(hero.node)?.way ?? .resting) + } + TraceView(trace: model.trace, live: lit) + .frame(minHeight: 40, maxHeight: .infinity) + Grid(alignment: .leading, horizontalSpacing: 6, verticalSpacing: 3) { + ForEach(Array(stride(from: 0, to: rest.count, by: 2)), id: \.self) { i in + GridRow { + SmallFigure(figure: rest[i], way: model.now?.value.reading(rest[i].node)?.way ?? .resting) { model.follow(rest[i].node) } + if i + 1 < rest.count { + SmallFigure(figure: rest[i + 1], way: model.now?.value.reading(rest[i + 1].node)?.way ?? .resting) { model.follow(rest[i + 1].node) } + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ToolbarContentBuilder private var modeButton: some ToolbarContent { + if let modes = model.context?.modes, modes.label != nil { + ToolbarItem(placement: .topBarLeading) { + NavigationLink { + ModeList(model: model, modes: modes) + } label: { + Image(systemName: model.sending(WatchModel.modeTarget) ? "ellipsis" : "slider.horizontal.3") + } + .accessibilityLabel(WatchWord.mode.text) + .accessibilityValue(modes.label ?? "") + .accessibilityIdentifier("watch.mode") + } + } + } +} + +/// Each node's glyph, so a small figure needs no word. +enum NodeGlyph { + static func name(_ node: WatchNow.Node) -> String { + switch node { + case .solar: return "sun.max.fill" + case .battery: return "battery.75percent" + case .home: return "house.fill" + case .grid: return "powerplug.fill" + case .car: return "car.fill" + } + } +} + +/// The traced node: its glyph, the figure large, and its word. +private struct HeroFigure: View { + let figure: WatchGlance.Figure + let way: WatchNow.Way + + var body: some View { + let hue = figure.lit ? Hue.of(figure.node, way: way) : Theme.ink + HStack(alignment: .firstTextBaseline, spacing: 3) { + Image(systemName: NodeGlyph.name(figure.node)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(figure.lit ? hue : Theme.ink2) + Text(figure.value) + .font(Theme.figure(32)) + .foregroundStyle(hue) + .contentTransition(.numericText()) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(figure.unit) + .font(.footnote.weight(.medium)) + .foregroundStyle(Theme.ink2) + Spacer(minLength: 2) + Text(figure.level ?? figure.word) + .font(.caption2) + .foregroundStyle(Theme.ink2) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .animation(Theme.spring, value: figure.value) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(figure.title), \(figure.value) \(figure.unit), \(figure.word)") + .accessibilityIdentifier("watch.now.hero") + } +} + +/// Another node, small: glyph and figure. A tap traces it. +private struct SmallFigure: View { + let figure: WatchGlance.Figure + let way: WatchNow.Way + let choose: () -> Void + + var body: some View { + let hue = figure.lit ? Hue.of(figure.node, way: way) : Theme.ink + Button(action: choose) { + HStack(alignment: .firstTextBaseline, spacing: 3) { + Image(systemName: NodeGlyph.name(figure.node)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(figure.lit ? hue : Theme.ink3) + .frame(width: 13) + Text(figure.value) + .font(Theme.figure(15)) + .foregroundStyle(hue) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(figure.unit) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Theme.ink2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(figure.title), \(figure.value) \(figure.unit), \(figure.word)") + .accessibilityIdentifier("watch.now.\(figure.node.rawValue)") + } +} + +/// The phone's live trace surface, small: three minutes, glowing only +/// while live. No magnitudes in a gutter: the round edge of the screen cuts +/// them, and the figure above says the value. +private struct TraceView: View { + let trace: WatchTrace + let live: Bool + + var body: some View { + GeometryReader { geo in + let chart = ChartFrame(top: 4, bottom: 2, trailing: 0) + let plot = chart.plot(in: geo.size) + ZStack { + Color.clear + if let frame = trace.frame { + LiveTraceSurface(frame: frame, look: WatchTrace.look(trace.node), live: live, chartFrame: chart, axisSize: 8, paused: !live) + } + } + .onAppear { trace.resize(plot.size) } + .onChange(of: plot.size) { _, size in trace.resize(size) } + } + .accessibilityHidden(true) + } +} diff --git a/appleApp/FTWWatch/Phosphor.swift b/appleApp/FTWWatch/Phosphor.swift index d504c1e..6f0a636 100644 --- a/appleApp/FTWWatch/Phosphor.swift +++ b/appleApp/FTWWatch/Phosphor.swift @@ -126,3 +126,16 @@ extension Theme { /// as the app does. static let spring = Animation.spring(duration: 0.35, bounce: 0.15) } + +/// A page on one screen. At text sizes too large for the watch it scrolls +/// rather than cut a reading off. +struct OnePage: View { + @ViewBuilder let content: Content + + var body: some View { + ViewThatFits(in: .vertical) { + content + ScrollView { content } + } + } +} diff --git a/appleApp/FTWWatch/PricePage.swift b/appleApp/FTWWatch/PricePage.swift index f61cd6c..2cb4838 100644 --- a/appleApp/FTWWatch/PricePage.swift +++ b/appleApp/FTWWatch/PricePage.swift @@ -1,9 +1,10 @@ import FTWShared import SwiftUI -/// The price now, the next twelve hours as block bars in eight levels, and -/// when it is cheapest. Prices are a level, never a state: ink, stronger as -/// they get dearer, with no red and no green. +/// The price on one screen: the price now, the hours ahead as block bars in +/// eight levels, when it is cheapest, and when the prices were fetched. +/// Prices are a level, never a state: ink, stronger as they get dearer, +/// with no red and no green. struct PricePage: View { let model: WatchModel @@ -11,46 +12,50 @@ struct PricePage: View { TimelineView(.everyMinute) { timeline in let nowMs = timeline.date.timeIntervalSince1970 * 1000 let m = PriceGlance.moment(model.context?.prices.map { CacheRead(record: $0, origin: .app) }, atMs: nowMs) - ScrollView { - VStack(alignment: .leading, spacing: 5) { - Text(WatchWord.priceNow.text.uppercased()) - .font(Theme.mono(.caption2)) - .foregroundStyle(Theme.ink3) + OnePage { + VStack(alignment: .leading, spacing: 3) { if let price = m.priceText { HStack(alignment: .firstTextBaseline, spacing: 3) { Text(price) .font(Theme.figure(34)) .foregroundStyle(Theme.ink) + .lineLimit(1) + .minimumScaleFactor(0.6) Text(m.unitText) .font(.caption2.weight(.medium)) .foregroundStyle(Theme.ink2) + .lineLimit(1) } Text(m.includedText) .font(.caption2) .foregroundStyle(Theme.ink3) + .lineLimit(1) + .minimumScaleFactor(0.8) } else { Text(m.emptyText ?? "—") .font(.footnote) .foregroundStyle(Theme.ink2) + .lineLimit(2) } - if !m.curve.isEmpty { - BlockBars(bars: m.curve) - .frame(height: 30) - .padding(.vertical, 2) - .accessibilityHidden(true) - } + BlockBars(bars: m.curve) + .frame(maxHeight: .infinity) + .padding(.vertical, 3) + .accessibilityHidden(true) if let cheap = m.cheapestText { Text(cheap) .font(.footnote.weight(.semibold)) .foregroundStyle(Theme.ink) + .lineLimit(1) + .minimumScaleFactor(0.8) } if let fetched = m.fetchedText { Text(fetched.uppercased()) .font(Theme.mono(.caption2)) .foregroundStyle(Theme.ink3) + .lineLimit(1) } } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .accessibilityIdentifier("watch.price") } diff --git a/appleApp/FTWWatch/WatchModel.swift b/appleApp/FTWWatch/WatchModel.swift index a8ccc67..fe377c3 100644 --- a/appleApp/FTWWatch/WatchModel.swift +++ b/appleApp/FTWWatch/WatchModel.swift @@ -30,6 +30,20 @@ final class WatchModel { static let modeTarget = "mode" + /// Now's live trace, fed by the frames the phone passes on. + let trace = WatchTrace(node: WatchModel.heroNode) + + /// The node Now traces, as the wrist last chose it. The home's use by + /// default: it moves whenever something in the house switches. + static var heroNode: WatchNow.Node { + UserDefaults.standard.string(forKey: "FTWWatchHero").flatMap(WatchNow.Node.init(rawValue:)) ?? .home + } + + func follow(_ node: WatchNow.Node) { + UserDefaults.standard.set(node.rawValue, forKey: "FTWWatchHero") + trace.follow(node) + } + @ObservationIgnored private let read: any ReadPath @ObservationIgnored private let command: any CommandPath @ObservationIgnored private let cache: WatchCache? @@ -100,6 +114,8 @@ final class WatchModel { now = ctx.now nowArrivedAtMs = nil relight() + // A kept reading joins the trace in ink: seen, but not live. + if let record = ctx.now { trace.add(record, lit: false) } } if let r = ctx.receipt { receipt(r) } if keep { self.keep(ctx) } @@ -115,6 +131,7 @@ final class WatchModel { now = record nowArrivedAtMs = nowMs relight() + trace.add(record, lit: lit) } /// Glow while the last frame is fresh, then cool once: one wake per diff --git a/appleApp/FTWWatch/WatchRoot.swift b/appleApp/FTWWatch/WatchRoot.swift index 3d4e517..aaef3f0 100644 --- a/appleApp/FTWWatch/WatchRoot.swift +++ b/appleApp/FTWWatch/WatchRoot.swift @@ -1,8 +1,9 @@ import FTWShared import SwiftUI -/// Four pages, one idea each: Now, the plan, the car and the price. The -/// Crown moves between them, except on the plan, where it steps the hand. +/// Four pages, one idea each, each on one screen with nothing to scroll: +/// Now alive, the plan, the car and the price. The Crown turns the pages, +/// except on the plan, where it steps the hand; a swipe goes on from there. struct WatchRoot: View { let model: WatchModel @State private var page: Page = WatchRoot.firstPage @@ -53,91 +54,6 @@ private struct NoHome: View { } } -/// Now in one glance: each node's figure, lit in its hue while the phone -/// streams it, ink with the time it was read otherwise, and how the home -/// is run. -struct NowPage: View { - let model: WatchModel - /// Always On redraws once a minute: a figure lit then would show an old - /// reading as live, so nothing glows while the wrist is down. - @Environment(\.isLuminanceReduced) private var dim - - var body: some View { - let lit = model.lit && !dim - let figures = WatchGlance.figures(model.now?.value, lit: lit) - ScrollView { - VStack(alignment: .leading, spacing: 6) { - StatusLine(status: WatchGlance.status(model.now, lit: lit, demo: model.context?.isDemo == true, zone: model.zone)) - .padding(.bottom, 2) - ForEach(figures) { f in - FigureRow(figure: f, way: model.now?.value.reading(f.node)?.way ?? .resting) - } - if let modes = model.context?.modes, let label = modes.label { - NavigationLink { - ModeList(model: model, modes: modes) - } label: { - HStack { - VStack(alignment: .leading, spacing: 1) { - Text(WatchWord.mode.text.uppercased()) - .font(Theme.mono(.caption2)) - .foregroundStyle(Theme.ink3) - Text(label) - .font(.footnote.weight(.semibold)) - .foregroundStyle(Theme.ink) - .lineLimit(2) - } - Spacer(minLength: 0) - if model.sending(WatchModel.modeTarget) { - ProgressView().frame(width: 16) - } - } - } - .padding(.top, 4) - .accessibilityIdentifier("watch.mode") - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .navigationTitle(WatchWord.nowTitle.text) - } -} - -/// One node: its name and word on the left, the figure on the right. Lit, -/// the figure takes its kind's hue; otherwise it is ink and holds still. -private struct FigureRow: View { - let figure: WatchGlance.Figure - let way: WatchNow.Way - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 4) { - VStack(alignment: .leading, spacing: 0) { - Text(figure.title) - .font(.caption2.weight(.semibold)) - .foregroundStyle(Theme.ink2) - .lineLimit(1) - Text(figure.level.map { "\(figure.word) · \($0)" } ?? figure.word) - .font(.caption2) - .foregroundStyle(Theme.ink3) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - Spacer(minLength: 2) - HStack(alignment: .firstTextBaseline, spacing: 2) { - Text(figure.value) - .font(Theme.figure(20)) - .foregroundStyle(figure.lit ? Hue.of(figure.node, way: way) : Theme.ink) - .contentTransition(.numericText()) - Text(figure.unit) - .font(.caption2.weight(.medium)) - .foregroundStyle(Theme.ink2) - } - .animation(Theme.spring, value: figure.value) - } - .accessibilityElement(children: .combine) - .accessibilityIdentifier("watch.now.\(figure.node.rawValue)") - } -} - /// The box's modes, as the phone last read them. A choice asks once more /// and then goes through the phone; the receipt stays under the list. struct ModeList: View { diff --git a/appleApp/FTWWatch/WatchTrace.swift b/appleApp/FTWWatch/WatchTrace.swift new file mode 100644 index 0000000..7757070 --- /dev/null +++ b/appleApp/FTWWatch/WatchTrace.swift @@ -0,0 +1,82 @@ +import FTWShared +import Observation +import SwiftUI + +/// Now's live trace on the wrist: the phone's node view trace (`LiveSeries`, +/// drawn in `TraceInk`), for one node at a time, from the frames the phone +/// passes on while the watch looks. A reading counts as seen only when its +/// frame came live, so the line glows only then and is ink otherwise. +/// +/// Built once per frame or change of node or size; the view only scrolls +/// and strokes it. +@Observable +@MainActor +final class WatchTrace { + /// Three minutes: about one point of width per second on a watch. + static let windowMs: Double = 3 * 60_000 + + private(set) var frame: TraceFrame? + /// The node the trace follows. + private(set) var node: WatchNow.Node + + @ObservationIgnored private var samples: [WatchNow.Node: [LiveSample]] = [:] + @ObservationIgnored private var size: CGSize = .zero + + init(node: WatchNow.Node) { + self.node = node + } + + /// One frame from the phone. `lit` says whether it came live just now. + func add(_ record: CacheRecord, lit: Bool) { + let from = record.readAtMs - Self.windowMs + for r in record.value.readings { + guard let v = r.signedW else { continue } + var run = samples[r.node, default: []] + // A frame the trace already has, or an older one, adds nothing. + if let last = run.last, last.t >= record.readAtMs { continue } + run.append(LiveSample(t: record.readAtMs, v: v, seen: lit && r.live)) + if let cut = run.firstIndex(where: { $0.t >= from }), cut > 1 { run.removeFirst(cut - 1) } + samples[r.node] = run + } + rebuild(keepScale: true) + } + + func follow(_ node: WatchNow.Node) { + guard node != self.node else { return } + self.node = node + rebuild(keepScale: false) + } + + func resize(_ size: CGSize) { + guard size != self.size, size.width > 0, size.height > 0 else { return } + self.size = size + rebuild(keepScale: false) + } + + private func rebuild(keepScale: Bool) { + guard size.width > 0 else { return } + let now = Date() + let series = LiveSeries.build( + samples[node] ?? [], + sourceGapMs: WatchGlance.liveForMs, + windowMs: Self.windowMs, + nowMs: now.timeIntervalSince1970 * 1000, + size: size, + previous: keepScale ? frame?.series.domain : nil, + floor: node.floorW + ) + frame = TraceFrame(series: series, previous: keepScale ? frame?.series : nil, arrivedAt: now) + } + + /// Each node's colours, as on the phone: one hue each side of zero; the + /// battery stores in cyan and gives in gold; the home is ink. + static func look(_ node: WatchNow.Node) -> TraceLook { + switch node { + case .solar: return TraceLook(up: Theme.solar, down: Theme.solar) + case .battery: return TraceLook(up: Theme.stores, down: Theme.gives) + case .home: return TraceLook(up: Theme.ink, down: Theme.ink) + case .grid: return TraceLook(up: Theme.grid, down: Theme.grid) + case .car: return TraceLook(up: Theme.car, down: Theme.car) + } + } +} diff --git a/appleApp/FTWWatchShared/BlockBars.swift b/appleApp/FTWWatchShared/BlockBars.swift index 4293987..26035a6 100644 --- a/appleApp/FTWWatchShared/BlockBars.swift +++ b/appleApp/FTWWatchShared/BlockBars.swift @@ -1,5 +1,6 @@ import FTWShared import SwiftUI +import WidgetKit /// The hours ahead as the block-glyph scale (▁▂▃▄▅▆▇█): one bar per slot, /// its height the price in eight levels. Ink, stronger for the slot that @@ -8,24 +9,33 @@ import SwiftUI /// app. struct BlockBars: View { let bars: [PriceStrip.Bar] + /// A stretch to stand out, by time: its bars in full ink and the rest + /// faint. Without one, the slot that holds now does. + var highlight: ClosedRange? = nil var body: some View { GeometryReader { geo in ZStack { - path(geo.size, current: false).fill(Theme.ink.opacity(0.55)) - path(geo.size, current: true).fill(Theme.ink) + path(geo.size, strong: false).fill(Theme.ink.opacity(highlight == nil ? 0.55 : 0.3)) + path(geo.size, strong: true).fill(Theme.ink) + .widgetAccentable() } } } - private func path(_ size: CGSize, current: Bool) -> Path { + private func strong(_ b: PriceStrip.Bar) -> Bool { + guard let highlight else { return b.current } + return b.startMs < highlight.upperBound && b.endMs > highlight.lowerBound + } + + private func path(_ size: CGSize, strong wanted: Bool) -> Path { let values = bars.map(\.minor) 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) var p = Path() - for (i, b) in bars.enumerated() where b.current == current { + for (i, b) in bars.enumerated() where strong(b) == wanted { let level = hi - lo < 0.5 ? WatchDial.levels / 2 - 1 : min(WatchDial.levels - 1, Int((b.minor - lo) / (hi - lo) * Double(WatchDial.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/PlanStrip.swift b/appleApp/FTWWatchShared/PlanStrip.swift new file mode 100644 index 0000000..c6b8360 --- /dev/null +++ b/appleApp/FTWWatchShared/PlanStrip.swift @@ -0,0 +1,78 @@ +import FTWShared +import SwiftUI +import WidgetKit + +/// The dial unrolled: the next hours from now on the left, one bar per +/// quarter, its height the price in eight levels and its colour what the +/// battery does (cyan stores, ink gives, ink3 waits), the car's charging as +/// dots under it. A quarter with no price yet is a stub. Still paths, one +/// fill each, for a complication. +struct PlanStrip: View { + let dial: WatchDial + /// How many quarters to draw: 48 is twelve hours. + var quarters = 48 + var grey = false + + var body: some View { + GeometryReader { geo in + let n = min(quarters, dial.quarters.count) + let dotRow: CGFloat = dial.quarters.prefix(n).contains(where: \.car) ? 3 : 0 + let h = geo.size.height - dotRow - (dotRow > 0 ? 1 : 0) + let w = geo.size.width / CGFloat(max(n, 1)) + ZStack(alignment: .topLeading) { + bars(.waits, n: n, w: w, h: h).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink3.opacity(0.6)) + bars(.gives, n: n, w: w, h: h).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink) + bars(.stores, n: n, w: w, h: h).fill(grey ? Theme.ink3.opacity(0.45) : Theme.stores) + .widgetAccentable() + stubs(n: n, w: w, h: h).fill(Theme.ink3.opacity(0.5)) + if dotRow > 0 { + carDots(n: n, w: w, y: geo.size.height - dotRow / 2).fill(grey ? Theme.ink3 : Theme.car) + .widgetAccentable() + } + } + } + .accessibilityHidden(true) + } + + private func bars(_ action: WatchDial.Battery, n: Int, w: CGFloat, h: CGFloat) -> Path { + var p = Path() + for i in 0.. Path { + var p = Path() + for i in 0.. Path { + var p = Path() + for i in 0.. String { + switch kind { + case .charge?: return "battery.100percent.bolt" + case .discharge?: return "battery.50percent" + case .car?: return "car.fill" + case .cheap?: return "arrow.down.circle" + case nil: return "minus" + } + } +} diff --git a/appleApp/FTWWatchUITests/WatchTour.swift b/appleApp/FTWWatchUITests/WatchTour.swift index 8179743..7b223e6 100644 --- a/appleApp/FTWWatchUITests/WatchTour.swift +++ b/appleApp/FTWWatchUITests/WatchTour.swift @@ -57,16 +57,13 @@ final class WatchTour: XCTestCase { shoot("charge-now-receipt") } - /// Now from top to bottom, the modes, and a change of mode with its - /// receipt, then back to the plan. + /// Now, the modes behind its toolbar button, and a change of mode with + /// its receipt. func testNowAndMode() throws { let app = launch("now") XCTAssertTrue(app.staticTexts.firstMatch.waitForExistence(timeout: 15)) sleep(4) shoot("now") - app.swipeUp() - sleep(1) - shoot("now-bottom") let mode = app.buttons["watch.mode"] XCTAssertTrue(mode.waitForExistence(timeout: 5)) mode.tap() @@ -86,6 +83,39 @@ final class WatchTour: XCTestCase { shoot("mode-receipt") } + /// The four pages one after another, as a wrist moves through them: + /// the Crown pages from Now to the plan, steps the hand there, and a + /// swipe goes on to the car and the price, then back. + func testPagingAndCrown() throws { + let app = launch("now") + XCTAssertTrue(app.staticTexts.firstMatch.waitForExistence(timeout: 15)) + sleep(5) + // On Now the Crown turns the pages. + XCUIDevice.shared.rotateDigitalCrown(delta: 0.5) + sleep(2) + // On the plan it turns the hand. + for _ in 0..<16 { + XCUIDevice.shared.rotateDigitalCrown(delta: 0.06) + usleep(200_000) + } + sleep(1) + for _ in 0..<16 { + XCUIDevice.shared.rotateDigitalCrown(delta: -0.06) + usleep(120_000) + } + sleep(1) + app.swipeUp() + sleep(2) + app.swipeUp() + sleep(2) + app.swipeDown() + sleep(1) + app.swipeDown() + sleep(1) + app.swipeDown() + sleep(2) + } + func testPriceAndPlan() throws { var app = launch("price") sleep(5) diff --git a/appleApp/FTWWatchWidgets/CheapPowerWidget.swift b/appleApp/FTWWatchWidgets/CheapPowerWidget.swift index a569248..eaf00f5 100644 --- a/appleApp/FTWWatchWidgets/CheapPowerWidget.swift +++ b/appleApp/FTWWatchWidgets/CheapPowerWidget.swift @@ -39,8 +39,8 @@ struct CheapWindowIntent: WidgetConfigurationIntent { struct CheapEntry: RelevanceEntry { let window: WatchGlance.CheapWindow? - /// "Fetched 13:05" - let fetched: String? + /// The hours ahead, the window among them. + let bars: [PriceStrip.Bar] } struct CheapProvider: RelevanceEntriesProvider { @@ -63,35 +63,45 @@ struct CheapProvider: RelevanceEntriesProvider { let prices = Kept.read().prices let nowMs = Date.now.timeIntervalSince1970 * 1000 let window = WatchGlance.cheapWindows(prices?.value, nowMs: nowMs).first { $0.startMs == configuration.startMs } - let fetched = prices.map { PriceGlance.moment($0, atMs: nowMs).fetchedText } - return CheapEntry(window: window, fetched: fetched ?? nil) + let bars = prices.map { PriceGlance.moment($0, atMs: nowMs).curve } ?? [] + return CheapEntry(window: window, bars: bars) } func placeholder(context: Context) -> CheapEntry { - CheapEntry(window: nil, fetched: nil) + CheapEntry(window: nil, bars: []) } } +/// The card: a glyph, the window's hours and its price, then the hours +/// ahead with the window standing out. No sentence; the glyph and the +/// bars carry it. struct CheapView: View { let entry: CheapEntry var body: some View { - VStack(alignment: .leading, spacing: 1) { - Text(WatchWord.cheapNow.text) - .font(.headline) - .widgetAccentable() - if let w = entry.window { - Text("\(w.range) · \(w.price)") - .font(.body.monospacedDigit()) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 13, weight: .semibold)) + .widgetAccentable() + Text(entry.window?.range ?? "00:00–00:00") + .font(.system(.headline).monospacedDigit()) .lineLimit(1) - .minimumScaleFactor(0.8) + .minimumScaleFactor(0.6) + Spacer(minLength: 2) + Text(entry.window?.short ?? "00") + .font(.system(.body).monospacedDigit().weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .layoutPriority(1) } - Text(entry.fetched?.uppercased() ?? "") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) + BlockBars(bars: entry.bars, highlight: entry.window.map { $0.startMs...$0.endMs }) + .frame(maxHeight: .infinity) } .frame(maxWidth: .infinity, alignment: .leading) .containerBackground(for: .widget) { Theme.ground } .redacted(reason: entry.window == nil ? .placeholder : []) + .accessibilityElement(children: .ignore) + .accessibilityLabel(entry.window.map { "\(WatchWord.cheapNow.text), \($0.range), \($0.price)" } ?? WatchWord.cheapWidget.text) } } diff --git a/appleApp/FTWWatchWidgets/PlanComplication.swift b/appleApp/FTWWatchWidgets/PlanComplication.swift index 2a20fd4..a9a7c87 100644 --- a/appleApp/FTWWatchWidgets/PlanComplication.swift +++ b/appleApp/FTWWatchWidgets/PlanComplication.swift @@ -2,11 +2,12 @@ import FTWShared import SwiftUI import WidgetKit -/// The plan on the watch face. Circular: the dial, a ring of prices with -/// the battery's action, the hand at now and the battery's level in the -/// middle while it is recent. Rectangular: what happens next, the price -/// strip and when the plan was made. Corner: the price now and when it is -/// cheapest. Inline: the price now. +/// The plan on the watch face, as pictures with at most one figure each. +/// Circular: the dial, a ring of prices with the battery's action, the hand +/// at now and the battery's level in the middle while it is recent. +/// Rectangular: what comes next as a glyph and a time, the price now, and +/// the plan unrolled over twelve hours. Corner: the price now with the +/// day's range as a gauge. Inline: the price now. struct PlanComplication: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: WatchKinds.plan, provider: FaceTimeline()) { entry in @@ -78,37 +79,76 @@ struct PlanComplicationView: View { .accessibilityLabel(face.plan.summary) } + /// What comes next as a glyph and one time, then the plan unrolled + /// over twelve hours. No sentence: the strip carries the plan. A plan + /// over an hour old greys and shows when it was made. private var rectangular: some View { - VStack(alignment: .leading, spacing: 1) { - Text(face.plan.nextWhat.map { [face.plan.nextWhen, $0].compactMap { $0 }.joined(separator: " · ") } ?? face.plan.summary) - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.8) - .widgetAccentable() - Text(face.plan.nextUntil ?? face.plan.cheapText ?? "") - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - if !face.price.curve.isEmpty { - BlockBars(bars: face.price.curve) - .frame(height: 10) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: face.plan.isGrey ? "clock" : EventGlyph.name(face.plan.next?.kind)) + .font(.system(size: 13, weight: .semibold)) + .widgetAccentable() + Text(when ?? "–") + .font(.system(.headline, design: .default).monospacedDigit()) + .lineLimit(1) + .minimumScaleFactor(0.6) + Spacer(minLength: 2) + if let price = shortPrice { + Text(price) + .font(.system(.body, design: .default).monospacedDigit().weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .layoutPriority(1) + } + } + if let dial = face.dial { + PlanStrip(dial: dial, quarters: 48, grey: face.plan.isGrey) + .frame(maxHeight: .infinity) } - Text(planAge) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) } .containerBackground(for: .widget) { Theme.ground } + .accessibilityElement(children: .ignore) + .accessibilityLabel(face.plan.summary) + } + + /// "till 13:30" for what is under way, "22:00" for what starts later, + /// or the time an old plan was made. + private var when: String? { + let clock = ZoneClock.of(face.plan.timeZone) + if face.plan.isGrey { return face.plan.readAtMs.map { clock.label($0) } } + guard let e = face.plan.next else { return nil } + return PlanGlance.started(e, at: face.atMs) ? face.plan.nextUntil : clock.label(e.startMs) } + /// The price now in whole minor units: "53". + private var shortPrice: String? { + face.price.current.map { "\(Int(PriceUnits.display($0.totalMinor, face.price.currency).rounded()))" } + } + + /// One figure, the price now, and the day's range as a gauge along the + /// corner: low on the left, dear on the right. private var corner: some View { - Text(face.price.priceText ?? "–") - .font(.system(size: 14, weight: .semibold).monospacedDigit()) + Text(shortPrice ?? "–") + .font(.system(size: 18, weight: .semibold).monospacedDigit()) + .minimumScaleFactor(0.6) .widgetCurvesContent() .widgetLabel { - Text(face.price.priceText == nil ? (face.price.emptyText ?? "") : (face.price.cheapestStartText ?? face.price.unitLabel)) + if let now = face.price.current?.totalMinor, let range = priceRange, range.lowerBound < range.upperBound { + Gauge(value: min(max(now, range.lowerBound), range.upperBound), in: range) { + EmptyView() + } + .gaugeStyle(.accessoryLinear) + .tint(Theme.ink) + } } .containerBackground(for: .widget) { Theme.ground } + .accessibilityLabel(face.price.inlineText) + } + + private var priceRange: ClosedRange? { + let values = face.price.curve.map(\.minor) + guard let lo = values.min(), let hi = values.max() else { return nil } + return lo...hi } /// The inline slot is as narrow as a date on some faces and the system @@ -117,12 +157,4 @@ struct PlanComplicationView: View { Text(face.price.priceText.map { "\($0) \(face.price.unitLabel)" } ?? face.price.inlineText) .containerBackground(for: .widget) { Theme.ground } } - - /// "PLANNED 16:45", or "PLAN FROM 14:05" once old. A complication - /// always says how old its plan is. - private var planAge: String { - guard let at = face.plan.readAtMs else { return face.price.fetchedText?.uppercased() ?? "" } - let clock = ZoneClock.of(face.plan.timeZone).label(at) - return (face.plan.state == .old ? WatchWord.planFrom : WatchWord.planned).format(clock) - } } From 7f7cb7ce05904ec8314e978072aa460c92b6a587 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 17:48:22 +0200 Subject: [PATCH 2/5] Watch W1.1: the car page fits one screen, the dial's line says what comes next, Now waits for live in the tour Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWWatch/CarPage.swift | 34 ++++++++++-------- appleApp/FTWWatch/Phosphor.swift | 5 +-- appleApp/FTWWatch/PlanPage.swift | 9 ++++- appleApp/FTWWatchUITests/WatchTour.swift | 45 +++++++++--------------- 4 files changed, 48 insertions(+), 45 deletions(-) diff --git a/appleApp/FTWWatch/CarPage.swift b/appleApp/FTWWatch/CarPage.swift index 21e6d8c..1023594 100644 --- a/appleApp/FTWWatch/CarPage.swift +++ b/appleApp/FTWWatch/CarPage.swift @@ -54,11 +54,11 @@ private struct CarBlock: View { let ask: (CarPage.Ask) -> Void var body: some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { // "20 % antaget": a level FTW assumed says so. Text(car.levelText ?? "—") - .font(Theme.figure(26)) + .font(Theme.figure(22)) .foregroundStyle(Theme.ink) .lineLimit(1) .minimumScaleFactor(0.7) @@ -67,21 +67,26 @@ private struct CarBlock: View { Meter(cells: cells, hue: Theme.car) } } - Text(car.state) - .font(.footnote.weight(.semibold)) - .foregroundStyle(Theme.ink) - .lineLimit(1) - .minimumScaleFactor(0.8) + // What it does, and when the box said so. + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(car.state) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink) + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer(minLength: 2) + Text(car.asOf) + .font(Theme.mono(.caption2)) + .foregroundStyle(Theme.ink3) + .lineLimit(1) + .fixedSize() + } Text([car.goal, car.next].compactMap { $0 }.joined(separator: " · ")) .font(.caption2) .foregroundStyle(Theme.ink2) .lineLimit(1) - .minimumScaleFactor(0.8) - Text(car.asOf) - .font(Theme.mono(.caption2)) - .foregroundStyle(Theme.ink3) - .lineLimit(1) - Spacer(minLength: 2) + .minimumScaleFactor(0.7) + Spacer(minLength: 0) // Room for the receipt, above the buttons, so nothing moves // when it lands. ZStack(alignment: .topLeading) { @@ -117,9 +122,10 @@ private struct CarBlock: View { Image(systemName: glyph) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(on ? Theme.car : Theme.ink) - .frame(maxWidth: .infinity, minHeight: 34) + .frame(maxWidth: .infinity, minHeight: 26) } .buttonStyle(.bordered) + .controlSize(.small) .disabled(off) .accessibilityLabel(title) } diff --git a/appleApp/FTWWatch/Phosphor.swift b/appleApp/FTWWatch/Phosphor.swift index 6f0a636..6427303 100644 --- a/appleApp/FTWWatch/Phosphor.swift +++ b/appleApp/FTWWatch/Phosphor.swift @@ -75,9 +75,10 @@ struct ReceiptLine: View { .lineLimit(1) .minimumScaleFactor(0.6) Text(receipt.text) - .font(.footnote) + .font(.caption2) .foregroundStyle(tone) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + .minimumScaleFactor(0.8) .contentTransition(.opacity) } .animation(Theme.spring, value: receipt) diff --git a/appleApp/FTWWatch/PlanPage.swift b/appleApp/FTWWatch/PlanPage.swift index 101e8a4..49ec6c5 100644 --- a/appleApp/FTWWatch/PlanPage.swift +++ b/appleApp/FTWWatch/PlanPage.swift @@ -43,7 +43,7 @@ struct PlanPage: View { quarter = 0 focused = true } - Text(i == 0 ? moment.summary : dial.what(i)) + Text(i == 0 ? next(moment) : dial.what(i)) .font(.system(size: 12)) .foregroundStyle(i == 0 && moment.isGrey ? Theme.ink3 : Theme.ink2) .multilineTextAlignment(.center) @@ -94,6 +94,13 @@ struct PlanPage: View { } } + /// What happens next, in one short sentence: "Batteriet laddar ur till + /// 19:30". The whole plan is the dial. + private func next(_ m: PlanGlance.Moment) -> String { + guard let what = m.nextWhat else { return m.summary } + return [what, m.nextUntil].compactMap { $0 }.joined(separator: " ") + } + /// "PLANNED 16:45", or "PLAN FROM 14:05" once it is old. private func planAge(_ plan: CacheRecord?, moment: PlanGlance.Moment) -> String? { guard let plan else { return nil } diff --git a/appleApp/FTWWatchUITests/WatchTour.swift b/appleApp/FTWWatchUITests/WatchTour.swift index 7b223e6..4a30a35 100644 --- a/appleApp/FTWWatchUITests/WatchTour.swift +++ b/appleApp/FTWWatchUITests/WatchTour.swift @@ -83,37 +83,26 @@ final class WatchTour: XCTestCase { shoot("mode-receipt") } - /// The four pages one after another, as a wrist moves through them: - /// the Crown pages from Now to the plan, steps the hand there, and a - /// swipe goes on to the car and the price, then back. - func testPagingAndCrown() throws { + /// The four pages one after another, as a wrist moves through them, + /// with a picture of each: Now after its trace has filled a little, + /// the plan, the car, the price, and back. + func testPaging() throws { let app = launch("now") - XCTAssertTrue(app.staticTexts.firstMatch.waitForExistence(timeout: 15)) - sleep(5) - // On Now the Crown turns the pages. - XCUIDevice.shared.rotateDigitalCrown(delta: 0.5) - sleep(2) - // On the plan it turns the hand. - for _ in 0..<16 { - XCUIDevice.shared.rotateDigitalCrown(delta: 0.06) - usleep(200_000) + XCTAssertTrue(app.staticTexts.firstMatch.waitForExistence(timeout: 30)) + // Live once the phone's frames arrive: the demo's status line says so. + let live = app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS 'DEMO'")).firstMatch + _ = live.waitForExistence(timeout: 60) + sleep(25) + shoot("page-now") + for name in ["page-plan", "page-car", "page-price"] { + app.swipeUp() + sleep(4) + shoot(name) } - sleep(1) - for _ in 0..<16 { - XCUIDevice.shared.rotateDigitalCrown(delta: -0.06) - usleep(120_000) + for _ in 0..<3 { + app.swipeDown() + sleep(2) } - sleep(1) - app.swipeUp() - sleep(2) - app.swipeUp() - sleep(2) - app.swipeDown() - sleep(1) - app.swipeDown() - sleep(1) - app.swipeDown() - sleep(2) } func testPriceAndPlan() throws { From 23bd9a48a96bafe852f715efb598d808d2b2ed22 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 18:49:07 +0200 Subject: [PATCH 3/5] Watch W1.1: the car page fits one screen, and the strip is DialStrip The receipt now takes the place of what the car does, its goal and next window, until the page is left; a settled receipt then goes. The page no longer keeps a room of its own for the receipt, so it fits 46 mm without scrolling and the swipe turns the page. The commands are one row of glyph buttons, 30 pt high. The watch's strip view is now DialStrip, so it does not shadow the PlanStrip value that the phone's plan lane adds to FTWShared. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWWatch/CarPage.swift | 88 ++++++++++++------- appleApp/FTWWatch/WatchModel.swift | 6 ++ .../{PlanStrip.swift => DialStrip.swift} | 2 +- .../FTWWatchWidgets/PlanComplication.swift | 2 +- 4 files changed, 62 insertions(+), 36 deletions(-) rename appleApp/FTWWatchShared/{PlanStrip.swift => DialStrip.swift} (99%) diff --git a/appleApp/FTWWatch/CarPage.swift b/appleApp/FTWWatch/CarPage.swift index 1023594..7919eee 100644 --- a/appleApp/FTWWatch/CarPage.swift +++ b/appleApp/FTWWatch/CarPage.swift @@ -2,10 +2,10 @@ import FTWShared import SwiftUI /// The car on one screen: its level as the 10-cell meter, what it does, -/// its goal and next window, when that was read, the receipt of the last -/// command, and the three commands as one row of buttons. Each asks once -/// more and names itself in full there; the box's answers come back as the -/// receipt. +/// when that was read, its goal and next window, and the three commands as +/// one row of buttons. Each asks once more and names itself in full there. +/// The box's answers come back as the receipt, which takes the place of +/// what the car does until the page is left. /// /// With more than one charger, the one with a car plugged in shows. struct CarPage: View { @@ -42,6 +42,7 @@ struct CarPage: View { } } .navigationTitle(WatchWord.car.text) + .onDisappear { model.leaveCars() } .sheet(item: $asking) { a in Confirm(question: a.question, action: a.action) { model.send(a.op) } } @@ -67,40 +68,19 @@ private struct CarBlock: View { Meter(cells: cells, hue: Theme.car) } } - // What it does, and when the box said so. - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(car.state) - .font(.footnote.weight(.semibold)) - .foregroundStyle(Theme.ink) - .lineLimit(1) - .minimumScaleFactor(0.7) - Spacer(minLength: 2) - Text(car.asOf) - .font(Theme.mono(.caption2)) - .foregroundStyle(Theme.ink3) - .lineLimit(1) - .fixedSize() - } - Text([car.goal, car.next].compactMap { $0 }.joined(separator: " · ")) - .font(.caption2) - .foregroundStyle(Theme.ink2) - .lineLimit(1) - .minimumScaleFactor(0.7) - Spacer(minLength: 0) - // Room for the receipt, above the buttons, so nothing moves - // when it lands. + // One room for what the car does or, after a command, the box's + // receipt, so nothing moves when the receipt lands. ZStack(alignment: .topLeading) { ReceiptLine(receipt: WatchReceipt(commandID: "", op: .mode(""), sent: .waiting, accepted: .waiting, confirmed: .waiting, text: "\n", tone: .working)) .hidden() .accessibilityHidden(true) if let r = model.receipt(for: car.loadpointID) { ReceiptLine(receipt: r) - } else if !model.canSend { - Text(WatchWord.phoneUnreachable.text) - .font(.footnote) - .foregroundStyle(Theme.ink2) + } else { + state } } + Spacer(minLength: 0) let off = !model.canSend || model.sending(car.loadpointID) HStack(spacing: 6) { command("bolt.fill", WatchWord.chargeNow.text, .chargeNow(loadpointID: car.loadpointID), WatchWord.chargeNowAsk.text, off: off, on: car.holding) @@ -112,6 +92,42 @@ private struct CarBlock: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + /// What it does and when the box said so, then its goal and next + /// window, or why the buttons are off. + private var state: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(car.state) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink) + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer(minLength: 2) + Text(car.asOf) + .font(Theme.mono(.caption2)) + .foregroundStyle(Theme.ink3) + .lineLimit(1) + .fixedSize() + } + // Goal and next window on a line each; one line for both when + // the second says why the buttons are off. + if model.canSend { + ForEach([car.goal, car.next].compactMap { $0 }, id: \.self) { line($0) } + } else { + line([car.goal, car.next].compactMap { $0 }.joined(separator: " · ")) + line(WatchWord.phoneUnreachable.text) + } + } + } + + private func line(_ text: String) -> some View { + Text(text) + .font(.caption2) + .foregroundStyle(Theme.ink2) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + /// One command: a glyph on the button, its full name in the question /// and to VoiceOver. `on` marks the one the box last confirmed as /// holding. @@ -119,14 +135,18 @@ private struct CarBlock: View { Button { ask(CarPage.Ask(op: op, question: question, action: title)) } label: { + // Lower than the system's bordered button, so the page needs no + // scrolling; still a whole third of the width to hit. Image(systemName: glyph) - .font(.system(size: 17, weight: .semibold)) + .font(.system(size: 15, weight: .semibold)) .foregroundStyle(on ? Theme.car : Theme.ink) - .frame(maxWidth: .infinity, minHeight: 26) + .frame(maxWidth: .infinity, minHeight: 30) + .background(Theme.raised, in: RoundedRectangle(cornerRadius: 9)) + .contentShape(RoundedRectangle(cornerRadius: 9)) } - .buttonStyle(.bordered) - .controlSize(.small) + .buttonStyle(.plain) .disabled(off) + .opacity(off ? 0.4 : 1) .accessibilityLabel(title) } } diff --git a/appleApp/FTWWatch/WatchModel.swift b/appleApp/FTWWatch/WatchModel.swift index fe377c3..3f77115 100644 --- a/appleApp/FTWWatch/WatchModel.swift +++ b/appleApp/FTWWatch/WatchModel.swift @@ -161,6 +161,12 @@ final class WatchModel { /// A command is on its way for this target. func sending(_ target: String) -> Bool { receipts[target].map { !$0.settled } ?? false } + /// Leaving the car page lets its settled receipts go, so the page shows + /// the car's state on return. A command still under way keeps its receipt. + func leaveCars() { + receipts = receipts.filter { $0.key == Self.modeTarget || !$0.value.settled } + } + /// The receipt shows at once; every mark after "sending" is the box's /// answer as the path brings it back. func send(_ op: WatchCommand.Op) { diff --git a/appleApp/FTWWatchShared/PlanStrip.swift b/appleApp/FTWWatchShared/DialStrip.swift similarity index 99% rename from appleApp/FTWWatchShared/PlanStrip.swift rename to appleApp/FTWWatchShared/DialStrip.swift index c6b8360..17dedef 100644 --- a/appleApp/FTWWatchShared/PlanStrip.swift +++ b/appleApp/FTWWatchShared/DialStrip.swift @@ -7,7 +7,7 @@ import WidgetKit /// battery does (cyan stores, ink gives, ink3 waits), the car's charging as /// dots under it. A quarter with no price yet is a stub. Still paths, one /// fill each, for a complication. -struct PlanStrip: View { +struct DialStrip: View { let dial: WatchDial /// How many quarters to draw: 48 is twelve hours. var quarters = 48 diff --git a/appleApp/FTWWatchWidgets/PlanComplication.swift b/appleApp/FTWWatchWidgets/PlanComplication.swift index a9a7c87..2d3b7bd 100644 --- a/appleApp/FTWWatchWidgets/PlanComplication.swift +++ b/appleApp/FTWWatchWidgets/PlanComplication.swift @@ -102,7 +102,7 @@ struct PlanComplicationView: View { } } if let dial = face.dial { - PlanStrip(dial: dial, quarters: 48, grey: face.plan.isGrey) + DialStrip(dial: dial, quarters: 48, grey: face.plan.isGrey) .frame(maxHeight: .infinity) } } From c7bbc3a0307eca3880992b85999eab3ffe9b3d3f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 19:35:17 +0200 Subject: [PATCH 4/5] Watch W1.1: Now and the car fit 41 mm Now's trace takes the height that is left, at least 24 pt, so the page fits 41 mm without scrolling and the swipe turns the page. On 46 mm the trace is as tall as before. The car's meter goes under its level when both do not fit on one line, as with "20 % antaget", rather than cutting the word. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWWatch/CarPage.swift | 36 ++++++++++++++++++++++++--------- appleApp/FTWWatch/NowPage.swift | 3 ++- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/appleApp/FTWWatch/CarPage.swift b/appleApp/FTWWatch/CarPage.swift index 7919eee..5c8912d 100644 --- a/appleApp/FTWWatch/CarPage.swift +++ b/appleApp/FTWWatch/CarPage.swift @@ -56,16 +56,17 @@ private struct CarBlock: View { var body: some View { VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - // "20 % antaget": a level FTW assumed says so. - Text(car.levelText ?? "—") - .font(Theme.figure(22)) - .foregroundStyle(Theme.ink) - .lineLimit(1) - .minimumScaleFactor(0.7) - Spacer(minLength: 0) - if let cells = car.cells { - Meter(cells: cells, hue: Theme.car) + // The level beside its meter, or the meter under it where the + // width runs out, as with "20 % antaget". + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + level + Spacer(minLength: 0) + meter + } + VStack(alignment: .leading, spacing: 3) { + level + meter } } // One room for what the car does or, after a command, the box's @@ -92,6 +93,21 @@ private struct CarBlock: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + /// "20 % antaget": a level FTW assumed says so. + private var level: some View { + Text(car.levelText ?? "—") + .font(Theme.figure(22)) + .foregroundStyle(Theme.ink) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + + @ViewBuilder private var meter: some View { + if let cells = car.cells { + Meter(cells: cells, hue: Theme.car) + } + } + /// What it does and when the box said so, then its goal and next /// window, or why the buttons are off. private var state: some View { diff --git a/appleApp/FTWWatch/NowPage.swift b/appleApp/FTWWatch/NowPage.swift index 0e3b3ed..2a482b6 100644 --- a/appleApp/FTWWatch/NowPage.swift +++ b/appleApp/FTWWatch/NowPage.swift @@ -36,8 +36,9 @@ struct NowPage: View { if let hero { HeroFigure(figure: hero, way: model.now?.value.reading(hero.node)?.way ?? .resting) } + // Whatever height is left, so Now fits 41 mm as well as 46. TraceView(trace: model.trace, live: lit) - .frame(minHeight: 40, maxHeight: .infinity) + .frame(minHeight: 24, maxHeight: .infinity) Grid(alignment: .leading, horizontalSpacing: 6, verticalSpacing: 3) { ForEach(Array(stride(from: 0, to: rest.count, by: 2)), id: \.self) { i in GridRow { From 06c185acd91ef4c06e111b80aceea6f0db0994b5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:31:24 +0200 Subject: [PATCH 5/5] Watch W1.1: the car's goal stays off a watch-sized dial Since #53 the watch's plan page draws the shared face at the app size, and on a dial about 100 pt across the goal ("80 %") landed on the hand's time and price in the middle. The goal now shows only on a dial at least 200 pt across, which the phone's always is. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWCharts/PlanDialFace.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/appleApp/FTWCharts/PlanDialFace.swift b/appleApp/FTWCharts/PlanDialFace.swift index d3e04e2..cf6ceea 100644 --- a/appleApp/FTWCharts/PlanDialFace.swift +++ b/appleApp/FTWCharts/PlanDialFace.swift @@ -18,7 +18,8 @@ import WidgetKit /// 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. + /// The phone's plan view and the watch app: rim, hours, and the + /// car's goal where the dial is large enough. case app /// A widget or a complication: the marks alone. case small @@ -45,7 +46,10 @@ struct PlanDialFace: View, Equatable { 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) } + // The goal sits inside the car's arc. On a watch that is + // where the hand's time and price are, so it shows only on + // a dial as large as the phone's. + if size == .app, g.side >= 200 { CarGoals(g: g, dial: dial, grey: grey) } } .frame(width: geo.size.width, height: geo.size.height) }