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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion appleApp/FTWCharts/LiveTrace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions appleApp/FTWCharts/PlanDialFace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
6 changes: 5 additions & 1 deletion appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
178 changes: 116 additions & 62 deletions appleApp/FTWWatch/CarPage.swift
Original file line number Diff line number Diff line change
@@ -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?
Expand All @@ -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) }
}
Expand All @@ -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)
}
}
Loading
Loading