diff --git a/appleApp/FTWCharts/LiveTrace.swift b/appleApp/FTWCharts/LiveTrace.swift index b0a36bc..a559877 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/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) } diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift index 4be77bc..cf769c6 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 6ee67e0..5bb1a77 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..5c8912d 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, +/// 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 { let model: WatchModel @State private var asking: Ask? @@ -19,27 +23,26 @@ 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) + .onDisappear { model.leaveCars() } .sheet(item: $asking) { a in Confirm(question: a.question, action: a.action) { model.send(a.op) } } @@ -52,63 +55,114 @@ private struct CarBlock: View { let ask: (CarPage.Ask) -> Void var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(car.levelText ?? "—") - .font(Theme.figure(26)) - .foregroundStyle(Theme.ink) - .minimumScaleFactor(0.7) - .lineLimit(1) - Spacer(minLength: 0) - if let cells = car.cells { - Meter(cells: cells, hue: Theme.car) + VStack(alignment: .leading, spacing: 2) { + // 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 } } - 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) - } - Text(car.asOf) - .font(Theme.mono(.caption2)) - .foregroundStyle(Theme.ink3) - - // Room for the receipt, above the buttons, so nothing moves - // when it lands and it shows where the tap was. + // 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 } } - 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) + 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) .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) + /// "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) + } } -} -extension Array { - var nilIfEmpty: [Element]? { isEmpty ? nil : self } + /// 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. + 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: { + // 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: 15, weight: .semibold)) + .foregroundStyle(on ? Theme.car : Theme.ink) + .frame(maxWidth: .infinity, minHeight: 30) + .background(Theme.raised, in: RoundedRectangle(cornerRadius: 9)) + .contentShape(RoundedRectangle(cornerRadius: 9)) + } + .buttonStyle(.plain) + .disabled(off) + .opacity(off ? 0.4 : 1) + .accessibilityLabel(title) + } } diff --git a/appleApp/FTWWatch/NowPage.swift b/appleApp/FTWWatch/NowPage.swift new file mode 100644 index 0000000..2a482b6 --- /dev/null +++ b/appleApp/FTWWatch/NowPage.swift @@ -0,0 +1,173 @@ +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) + } + // Whatever height is left, so Now fits 41 mm as well as 46. + TraceView(trace: model.trace, live: lit) + .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 { + 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..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) @@ -126,3 +127,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/PlanPage.swift b/appleApp/FTWWatch/PlanPage.swift index ff5c6d0..da137f9 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/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..3f77115 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 @@ -144,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/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 a21f589..6ac2d1b 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(PlanDial.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 ? 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/DialStrip.swift b/appleApp/FTWWatchShared/DialStrip.swift new file mode 100644 index 0000000..7070880 --- /dev/null +++ b/appleApp/FTWWatchShared/DialStrip.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 DialStrip: View { + let dial: PlanDial + /// 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: PlanDial.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..4a30a35 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,28 @@ final class WatchTour: XCTestCase { shoot("mode-receipt") } + /// 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: 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) + } + for _ in 0..<3 { + 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 18f4439..be63527 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 { + DialStrip(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) - } }