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
512 changes: 264 additions & 248 deletions appleApp/FTW/Car/CarView.swift

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions appleApp/FTW/Car/GoalEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,29 +95,33 @@ struct GoalEditor: View {
Form {
Section(L("Charge to")) {
Stepper(value: $socPct, in: 10...100, step: 5) {
Text(AppWords.percent(socPct)).font(Theme.percent(.title2))
Text(AppWords.percent(socPct)).font(Theme.figure(22))
}
}
.listRowBackground(Theme.raised)
Section(L("Ready by")) {
DatePicker(L("Ready by"), selection: $time, displayedComponents: .hourAndMinute)
.datePickerStyle(.wheel)
.labelsHidden()
.environment(\.timeZone, model.zone)
.frame(maxWidth: .infinity)
}
.listRowBackground(Theme.raised)
Section(L("Repeat")) {
Picker(L("Repeat"), selection: $days) {
ForEach(choices, id: \.self) { Text($0.label).tag($0) }
}
.pickerStyle(.inline)
.labelsHidden()
}
.listRowBackground(Theme.raised)
Section {
Text(preview).font(.subheadline).foregroundStyle(Theme.fgDim)
Text(preview).font(.subheadline).foregroundStyle(Theme.ink2)
if let failure {
Text(failure).font(.subheadline).foregroundStyle(Theme.alarm)
}
}
.listRowBackground(Theme.raised)
if lp.schedule != nil {
Section {
Button(L("Remove goal"), role: .destructive) {
Expand All @@ -130,8 +134,12 @@ struct GoalEditor: View {
}
.disabled(saving)
}
.listRowBackground(Theme.raised)
}
}
// FTW's ground under the sheet, its rows raised.
.scrollContentBackground(.hidden)
.background(Theme.ground)
.navigationTitle(L("Goal"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
Expand Down
6 changes: 4 additions & 2 deletions appleApp/FTW/UI/CellMeter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import SwiftUI

/// A level in tenths, one of the six TUI details: ten cells with 1.2 pt
/// gaps, as many lit as the level has tenths, readable at 4 pt. Lit cells
/// glow only while live, and only in the dark.
/// glow only while live, and only in the dark. A level that is a fact but
/// not a live reading, such as the car's, takes its colour without glow.
struct CellMeter: View {
let pct: Double
let tint: Color
let live: Bool
var glows = true
@Environment(\.colorScheme) private var scheme
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency

Expand All @@ -18,7 +20,7 @@ struct CellMeter: View {
for i in 0..<10 {
let cell = CGRect(x: CGFloat(i) * (w + gap), y: 0, width: w, height: size.height)
if i < lit {
if live && scheme == .dark && !reduceTransparency {
if live && glows && scheme == .dark && !reduceTransparency {
g.fill(Path(roundedRect: cell.insetBy(dx: -1, dy: -1.5), cornerRadius: 1.5), with: .color(tint.opacity(0.25)))
}
g.fill(Path(roundedRect: cell, cornerRadius: 0.75), with: .color(live ? tint : Theme.ink3))
Expand Down
2 changes: 1 addition & 1 deletion appleApp/FTW/UI/NodeHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ struct AheadChartArea: View {

var body: some View {
let strip: CGFloat = 18
let frame = ChartFrame(top: tag * 1.35 + 8, bottom: 6 + strip + axis * 1.4 + 4, trailing: axis * 3.2)
let frame = ChartFrame(top: tag * 1.35 + 8, bottom: 6 + strip + axis * 1.4 + 4, trailing: axis * 3.8)
let made = build(frame.plot(in: size).size)
ZStack {
Color.clear
Expand Down
17 changes: 12 additions & 5 deletions appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ final class NodeTraceModel {
/// "1,8 kW" at 60 pt, the way it flows under it in its colour, and the
/// node's second fact on the right. Under a finger on the live trace it
/// gives the reading there and its time, and comes back with the spring.
private struct NodeHero: View {
/// The car view opens on it too.
struct NodeHero: View {
let site: SiteModel
let node: EnergyNode
let home: HomeModels
Expand Down Expand Up @@ -367,7 +368,7 @@ private struct NodeHero: View {
/// The fact beside the figure that only this node has: the battery's level
/// as the 10-cell meter, the car's level and where it comes from, what the
/// grid costs an hour, the sun against its forecast, or the home's sources.
private struct SecondFact: View {
struct SecondFact: View {
let site: SiteModel
let node: EnergyNode
let home: HomeModels
Expand All @@ -388,11 +389,17 @@ private struct SecondFact: View {
.frame(width: 74, height: 4)
}
case .car:
// Never worked out from power: the level as the car, this
// phone or the box last gave it, with where from and how old.
if let lp = home.loadpoints.points.first, let level = home.loadpoints.level(lp) {
let guessed = level.origin == .assumed || level.origin == .unconfirmed
Text(AppWords.percent(level.pct))
.font(Theme.figure(levelSize))
.foregroundStyle(level.origin == .assumed || level.origin == .unconfirmed ? Theme.ink3 : Theme.ink)
Text(EVText.originShort(level, zone: home.loadpoints.zone))
.foregroundStyle(guessed ? Theme.ink3 : Theme.ink)
.contentTransition(.numericText(value: level.pct))
CellMeter(pct: level.pct, tint: Theme.car, live: !guessed, glows: false)
.frame(width: 74, height: 4)
Text(EVText.originAged(level, nowMs: site.minuteMs, zone: home.loadpoints.zone))
.font(.footnote)
.foregroundStyle(Theme.ink2)
}
Expand Down Expand Up @@ -460,7 +467,7 @@ private struct ShareBar: View {
/// Over the chart: whether this node's reading is live, and from which
/// device. `● LIVE · SUNGROW`, or `○ AS OF 14:05`: a clock time, never a
/// count of seconds.
private struct NodeStatusRow: View {
struct NodeStatusRow: View {
let site: SiteModel
let node: EnergyNode
@ScaledMetric(relativeTo: .caption2) private var size: CGFloat = 10.5
Expand Down
97 changes: 97 additions & 0 deletions appleApp/FTW/UI/ReceiptView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import FTWKit
import SwiftUI

/// The room a receipt takes, its marks and two lines of text, laid out as
/// `ReceiptView` lays them out and left empty, so a receipt that arrives
/// moves nothing.
struct ReceiptRoom: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) { marks }
VStack(alignment: .leading, spacing: 4) { marks }
}
Text(verbatim: "\n")
.font(.footnote.weight(.medium))
}
.padding(.vertical, 4)
.hidden()
.accessibilityHidden(true)
}

@ViewBuilder private var marks: some View {
ForEach([L("Sent"), L("Received"), L("Done")], id: \.self) { label in
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
Text(label).font(.caption.weight(.semibold))
}
}
}
}

/// The receipt under a control: sent, received, done, and one sentence.
struct ReceiptView: View {
let outcome: EVText.Outcome

var body: some View {
VStack(alignment: .leading, spacing: 6) {
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) { marks }
VStack(alignment: .leading, spacing: 4) { marks }
}
Text(outcome.text)
.font(.footnote.weight(.medium))
.foregroundStyle(color)
.contentTransition(.opacity)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
.animation(Theme.spring, value: outcome)
.accessibilityElement(children: .combine)
}

@ViewBuilder private var marks: some View {
mark(L("Sent"), outcome.sent)
mark(L("Received"), outcome.received)
mark(L("Done"), outcome.done)
}

private func mark(_ label: String, _ state: EVText.Outcome.Mark) -> some View {
HStack(spacing: 4) {
Image(systemName: symbol(state))
.foregroundStyle(tint(state))
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.pulse, options: .repeating, isActive: state == .active)
Text(label)
.font(.caption.weight(.semibold))
.foregroundStyle(state == .waiting ? Theme.fgMuted : Theme.fg)
}
}

private func symbol(_ state: EVText.Outcome.Mark) -> String {
switch state {
case .done: return "checkmark.circle.fill"
case .active: return "circle.dotted.circle"
case .waiting: return "circle"
case .failed: return "xmark.circle.fill"
}
}

private func tint(_ state: EVText.Outcome.Mark) -> Color {
switch state {
case .done: return Theme.live
case .active: return Theme.accent
case .waiting: return Theme.fgMuted
case .failed: return Theme.alarm
}
}

private var color: Color {
switch outcome.tone {
case .working: return Theme.fgDim
case .done: return Theme.live
case .warning: return Theme.alarm
case .failed: return Theme.alarm
}
}
}
33 changes: 32 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Format/EV.swift
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,16 @@ public enum EVText {
}
}

/// "from the car · 2 min ago": where the level comes from and how old it
/// is, under the car's level. Minutes, never seconds; a level with no
/// time of its own says only where it comes from.
public static func originAged(_ l: Level, nowMs: Double, zone: TimeZone) -> String {
let from = originShort(l, zone: zone)
guard let at = l.atMs, l.origin == .car || l.origin == .you else { return from }
let age = max(0, nowMs - at)
return from + " · " + PowerFormat.age(age < 60_000 ? 0 : (age / 60_000).rounded(.down) * 60_000)
}

/// The chips, and a step of five from wherever the level stands.
public static let levelPresets: [Double] = [20, 40, 60, 80]

Expand Down Expand Up @@ -927,12 +937,17 @@ public enum EVText {
public var ask: Ask
/// The box said it has the intent (`cmd.ack`).
public var acked: Bool
/// After that, lane 0 showed the car doing what was asked: drawing
/// for Charge now, still for Pause, as the plan says for Back to the
/// plan. Never for a level.
public var measured: Bool
/// What the box said happened (`cmd.result`), or why not.
public var result: Result?

public init(ask: Ask, acked: Bool = false, result: Result? = nil) {
public init(ask: Ask, acked: Bool = false, measured: Bool = false, result: Result? = nil) {
self.ask = ask
self.acked = acked
self.measured = measured
self.result = result
}

Expand Down Expand Up @@ -980,6 +995,22 @@ public enum EVText {
return Outcome(sent: .done, received: received, done: done, text: outcomeText(step, lp), tone: tone)
}

/// The receipt log for one command: SENT at once, ACCEPTED on the box's
/// `cmd.ack`, MEASURED once lane 0 shows the car doing what was asked,
/// CONFIRMED on `cmd.result`. A refusal, or a change that came first,
/// takes the last slot with the reason. A level has no MEASURED.
public static func receipt(_ step: Step, _ lp: Loadpoint?) -> ReceiptLog {
var r = ReceiptLog(accepted: step.acked, measured: step.measured)
if case .level = step.ask { r.slots = [.sent, .accepted, .confirmed] }
switch step.result {
case .applied?: r.confirmed = true
case .refused(let why)?: r.refused = why
case .superseded?: r.refused = outcomeText(step, lp)
case .unconfirmed?, nil: break
}
return r
}

static func outcomeText(_ step: Step, _ lp: Loadpoint?) -> String {
if case .refused(let why)? = step.result { return why }
switch step.ask {
Expand Down
74 changes: 61 additions & 13 deletions appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -469,15 +469,41 @@ public final class LoadpointsModel: Activatable {
lp.manualActive && !EVText.isPaused(lp)
}

/// MEASURED on the receipt: lane 0 shows the car drawing power, live.
/// Lane 0 has one figure for every charger, so it lights the Live
/// Activity only when the home has one up.
/// MEASURED on the receipt: lane 0 shows the car doing what was asked,
/// live, once the box has the command. Lane 0 has one car figure for
/// every charger, so it lights the Live Activity only when the home has
/// one up, and the car view's receipt only for a home with one charger.
private func noticeDrawing() {
guard ReceiptLog.measures(evW: site.session.fields[Contract.FID.evW], live: site.isLive),
let host = activityHost else { return }
let ids = host.loadpointIDs(home: activityHome)
guard ids.count == 1 else { return }
showChargeNow(ids[0], .measured(atMs: site.scheduler.nowMs))
let evW = site.session.fields[Contract.FID.evW]
let live = site.isLive
if ReceiptLog.measures(evW: evW, live: live), let host = activityHost {
let ids = host.loadpointIDs(home: activityHome)
if ids.count == 1 { showChargeNow(ids[0], .measured(atMs: site.scheduler.nowMs)) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require one charger before lighting MEASURED

When a home has multiple chargers but only one Charge now Live Activity, ids.count == 1 is still true even though lane 0 contains aggregate car power. Power drawn by another charger can therefore mark this activity as MEASURED while its own car remains idle. Check the home's charger count, as the car-view receipt does below, rather than the number of active Live Activities.

Useful? React with 👍 / 👎.

}
guard live, let evW, points.count == 1, let lp = points.first,
let step = holdStep[lp.id], step.acked, !step.measured else { return }
switch step.result {
case .refused?, .superseded?: return
case .applied?, .unconfirmed?, nil: break
}
let planned = (windows[lp.id] ?? lp.planWindows).contains { $0.fromMs <= site.nowMs && site.nowMs < $0.toMs }
guard Self.measures(step.ask, evW: evW, planCharges: planned) else { return }
holdStep[lp.id]?.measured = true
// Its last slot has lit: a success fades a while after this.
if case .applied? = step.result { fadeLater("hold \(lp.id)", \.holdStep, lp.id, afterMs: Self.doneShownMs) }
}

/// Whether lane 0's car power is what the command asked for: drawing
/// for Charge now, still for Pause, and for Back to the plan whatever
/// the plan says for this moment.
nonisolated static func measures(_ ask: EVText.Step.Ask, evW: Double, planCharges: Bool) -> Bool {
let drawing = evW > ReceiptLog.drawingW
switch ask {
case .chargeNow: return drawing
case .pause: return !drawing
case .resume: return drawing == planCharges
case .level: return false
}
}

/// The Live Activity's home key: the site, or the demo's own.
Expand Down Expand Up @@ -543,16 +569,28 @@ public final class LoadpointsModel: Activatable {
guard self[keyPath: path][id]?.ask == ask else { return }
self[keyPath: path][id]?.result = result
// A success fades once the screen shows its effect; a refusal stays
// until the next tap.
// until the next tap. A charge command waits up to a minute for
// MEASURED, which comes when the car has started or stopped.
if case .applied = result {
clearing[key] = site.scheduler.after(Self.doneShownMs) { [weak self] in
guard let self, self[keyPath: path][id]?.result == result else { return }
self[keyPath: path][id] = nil
}
let waits = tag == "hold" && self[keyPath: path][id]?.measured == false
fadeLater(key, path, id, afterMs: waits ? Self.measureWaitMs : Self.doneShownMs)
}
}

/// A settled success leaves the screen after `afterMs`, unless another
/// command took its place.
private func fadeLater(_ key: String, _ path: ReferenceWritableKeyPath<LoadpointsModel, [String: EVText.Step]>, _ id: String, afterMs: Double) {
guard let step = self[keyPath: path][id], case .applied? = step.result else { return }
clearing[key]?.cancel()
clearing[key] = site.scheduler.after(afterMs) { [weak self] in
guard let self, let now = self[keyPath: path][id], now.ask == step.ask, case .applied? = now.result else { return }
self[keyPath: path][id] = nil
}
}

static let doneShownMs: Double = 20_000
/// How long a confirmed charge command waits for lane 0 to show it.
static let measureWaitMs: Double = 60_000

// MARK: The goal

Expand Down Expand Up @@ -633,6 +671,16 @@ public final class LoadpointsModel: Activatable {
holdStep[lp.id].map { EVText.outcome($0, lp) }
}

/// The receipt log for the last level and the last charge command:
/// `SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED`, each lit on its evidence.
public func receipt(level lp: Loadpoint) -> ReceiptLog? {
levelStep[lp.id].map { EVText.receipt($0, lp) }
}

public func receipt(hold lp: Loadpoint) -> ReceiptLog? {
holdStep[lp.id].map { EVText.receipt($0, lp) }
}

public func holdSending(_ lp: Loadpoint) -> Bool {
holdStep[lp.id].map { !$0.settled } ?? false
}
Expand Down
Loading
Loading