diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index 1a5b53c..886f31d 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -2,16 +2,23 @@ import FTWDemo import FTWKit import SwiftUI -/// The car: how full it is and who says so, the goal and whether it makes -/// it, and Plan, Charge now and Pause in a bar at the foot, where the thumb -/// is and in the same places whatever the car does. Every tap shows "Sent" -/// at once, then the box's receipt, then the outcome; the screen shows -/// intent at once and a result only once the box has confirmed it. +/// The car, in the node view's material: the power now as the hero, with +/// the car's level, its meter and where the level comes from beside it; +/// whether that is live and from which charger; the car's plan for the +/// hours ahead with the price under it; then the level and the goal. Plan, +/// Charge now and Pause sit in a glass bar at the foot, where the thumb is +/// and in the same places whatever the car does, with the receipt log +/// under them: SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED, each slot lit on +/// its own evidence. The screen shows intent at once and a result only +/// once the box has confirmed it. struct CarView: View { let app: AppModel let home: HomeModels let id: String? @State private var editingGoal = false + @State private var scrub = ScrubModel() + @State private var trace = NodeTraceModel() + @State private var text = PeriodText() private var model: LoadpointsModel { home.loadpoints } @@ -20,26 +27,55 @@ struct CarView: View { } var body: some View { - List { - if let lp { - StatusSection(lp: lp, model: model, site: home.site) - // Drawn from what the last launch kept; inert until the box - // has spoken in this session. - LevelSection(lp: lp, model: model, canControl: home.site.canConfigure) - .disabled(!home.site.heardFromBox) - GoalSection(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal) - .disabled(!home.site.heardFromBox) - if app.isDemo { DemoSection(lp: lp, model: model) } - } else if model.loaded { - Section { Text(L("This box has no charger.")) } - .listRowBackground(Theme.surfaceRaised) - } else { - PlaceholderSections() + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let lp { + // The hours ahead are a plan, not a reading: a finger on + // the plan leaves the hero on now. + NodeHero(site: home.site, node: .car, home: home, trace: trace, scrub: scrub, timebase: .ahead, text: text) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .padding(.horizontal, 20) + // A scaled metric reads the size where it is placed: + // the caps sit here, as in the node view. + NodeStatusRow(site: home.site, node: .car) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, 20) + .padding(.top, 16) + StatusLines(lp: lp, model: model, site: home.site) + .padding(.horizontal, 20) + .padding(.top, 6) + CarPlan(lp: lp, model: model, home: home, scrub: scrub, text: text) + .padding(.top, 24) + // Drawn from what the last launch kept; inert until the + // box has spoken in this session. + LevelBlock(lp: lp, model: model, canControl: home.site.canConfigure) + .disabled(!home.site.heardFromBox) + .padding(.horizontal, 20) + .padding(.top, 28) + GoalBlock(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal) + .disabled(!home.site.heardFromBox) + .padding(.horizontal, 20) + .padding(.top, 28) + if app.isDemo { + DemoBlock(lp: lp, model: model) + .padding(.horizontal, 20) + .padding(.top, 28) + } + } else if model.loaded { + Text(L("This box has no charger.")) + .foregroundStyle(Theme.ink2) + .padding(20) + } else { + Placeholder() + .padding(.horizontal, 20) + } } + .padding(.top, 8) + .padding(.bottom, 24) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) } - .listStyle(.insetGrouped) - .scrollContentBackground(.hidden) - .background(Theme.surface) + .background(Theme.ground) .safeAreaBar(edge: .bottom) { if let lp, lp.pluggedIn, home.site.canConfigure { ControlBar(lp: lp, model: model) @@ -54,11 +90,13 @@ struct CarView: View { .onAppear { model.activate() model.watchPlan() + home.prices.activate() LaunchClock.mark("first content") } .onDisappear { model.unwatchPlan() model.deactivate() + home.prices.deactivate() } .sheet(isPresented: $editingGoal) { if let lp { GoalEditor(model: model, lp: lp) } @@ -68,30 +106,34 @@ struct CarView: View { // MARK: What it does now -private struct StatusSection: View { +/// Under the status line: what the charger does in words, this plug-in's +/// energy, and when the box last said so once that is old. +private struct StatusLines: View { let lp: Loadpoint let model: LoadpointsModel let site: SiteModel var body: some View { - Section { - VStack(alignment: .leading, spacing: 4) { - Text(EVText.status(lp, zone: model.zone)) - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.fg) + VStack(alignment: .leading, spacing: 2) { + Text(EVText.status(lp, zone: model.zone)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.ink) + .contentTransition(.opacity) + .fixedSize(horizontal: false, vertical: true) + if let session = EVText.session(lp) { + Text(session) + .font(.subheadline) + .foregroundStyle(Theme.ink2) .contentTransition(.numericText()) + } + if let note { + Text(note.text) + .font(.footnote) + .foregroundStyle(note.warn ? Theme.alarm : Theme.ink2) .fixedSize(horizontal: false, vertical: true) - if let session = EVText.session(lp) { - Hint(session).contentTransition(.numericText()) - } - if let note { - Hint(note.text, tone: note.warn ? Theme.alarm : Theme.fgMuted) - } } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) } - .listRowBackground(Theme.surfaceRaised) + .accessibilityElement(children: .combine) } /// When the box said it: plainly when out of date, quietly when it is @@ -99,14 +141,58 @@ private struct StatusSection: View { private var note: (text: String, warn: Bool)? { guard let at = model.readAtMs else { return model.stale ? (L("Waiting for your box."), true) : nil } if model.stale { return (model.error ?? AppWords.format("Last known %@.", AppWords.clock(at, zone: model.zone)), true) } - if site.nowMs - at > 60_000 { return (AppWords.format("Read at %@", AppWords.clock(at, zone: model.zone)), false) } + if site.minuteMs - at > 60_000 { return (AppWords.format("Read at %@", AppWords.clock(at, zone: model.zone)), false) } return nil } } +// MARK: The plan + +/// The car's plan for the hours ahead, as the car node's Ahead draws it: +/// its charging as ghost bars, the price under the same slots in eight +/// levels, and a finger that reads each slot. Under it, the plan in one +/// sentence, or the slot under the finger. +private struct CarPlan: View { + let lp: Loadpoint + let model: LoadpointsModel + let home: HomeModels + let scrub: ScrubModel + let text: PeriodText + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Kicker(L("Plan")) + .padding(.horizontal, 20) + AheadChartArea(site: home.site, node: .car, home: home, scrub: scrub, text: text) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .frame(height: 170) + .padding(.horizontal, 4) + ZStack(alignment: .topLeading) { + Text(verbatim: "\n") + Text(line).accessibilityIdentifier("car.plan.line") + } + .font(.subheadline) + .foregroundStyle(Theme.ink2) + .lineLimit(typeSize.isAccessibilitySize ? 5 : 2, reservesSpace: true) + .frame(maxWidth: .infinity, alignment: .leading) + .transaction { $0.animation = nil } + .padding(.horizontal, 20) + } + } + + private var line: String { + if let under = text.text(scrub) { return under } + return PlanText.car(model.windows[lp.id] ?? lp.planWindows, nowMs: home.site.minuteMs, zone: model.zone) + } +} + // MARK: How full is it? -private struct LevelSection: View { +/// How full the car is, told by tap. The level itself is the hero's; here +/// is where it comes from, the chips that tell FTW, and the level's +/// receipt. +private struct LevelBlock: View { let lp: Loadpoint let model: LoadpointsModel let canControl: Bool @@ -115,26 +201,20 @@ private struct LevelSection: View { var body: some View { let level = model.level(lp) let assumed = level?.origin == .assumed || level?.origin == .unconfirmed - Section { + VStack(alignment: .leading, spacing: 10) { + Kicker(L("How full is the car?")) if let level { - VStack(alignment: .leading, spacing: 6) { - ViewThatFits(in: .horizontal) { - HStack(alignment: .firstTextBaseline, spacing: 10) { figure(level, assumed: assumed) } - VStack(alignment: .leading, spacing: 6) { figure(level, assumed: assumed) } - } - // The badge says FTW assumes; the line says what to do. - Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) - .font(.subheadline) - .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) + // The hero says FTW assumes; the line says what to do. + Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) + .font(.subheadline) + .foregroundStyle(assumed ? Theme.ink : Theme.ink2) + .fixedSize(horizontal: false, vertical: true) if canControl { if level.origin == .car, !correcting { Button(L("Correct the level")) { withAnimation(Theme.spring) { correcting = true } } + .buttonStyle(.glass) } else { LevelChips(current: model.chosenLevel(lp) ?? level.pct) { pct in Task { await model.setLevel(lp, pct: pct) } @@ -144,42 +224,25 @@ private struct LevelSection: View { } else if !lp.pluggedIn { Text(L("Plug in the car to tell FTW its level.")) .font(.subheadline) - .foregroundStyle(Theme.fg) + .foregroundStyle(Theme.ink) } else { - Text(L("Level unknown")).font(.headline) + Text(L("Level unknown")).font(.headline).foregroundStyle(Theme.ink) } - if let outcome = model.outcome(level: lp) { - ReceiptView(outcome: outcome) + if let receipt = model.receipt(level: lp), let outcome = model.outcome(level: lp) { + CommandReceipt(receipt: receipt, text: outcome.text, tone: outcome.tone) + .transition(.opacity) } - } header: { - Text(L("How full is the car?")) - } footer: { if level?.origin == .car, !correcting, canControl { Text(L("Only correct it if the car shows something else.")) + .font(.footnote) + .foregroundStyle(Theme.ink3) } } - .listRowBackground(Theme.surfaceRaised) + .animation(Theme.spring, value: model.receipt(level: lp)) .sensoryFeedback(.selection, trigger: model.chosenLevel(lp)) .sensoryFeedback(trigger: model.outcome(level: lp)?.tone) { _, tone in Self.feedback(tone) } } - @ViewBuilder private func figure(_ level: EVText.Level, assumed: Bool) -> some View { - Text(AppWords.percent(level.pct)) - .font(Theme.percent(.largeTitle, weight: .bold)) - .foregroundStyle(assumed ? Theme.fgMuted : Theme.fg) - .contentTransition(.numericText(value: level.pct)) - .animation(Theme.spring, value: level.pct) - if assumed { - Text(L("FTW assumes")) - .font(.caption.weight(.semibold)) - .foregroundStyle(Theme.onAccent) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Theme.ink2, in: Capsule()) - .fixedSize() - } - } - static func feedback(_ tone: EVText.Outcome.Tone?) -> SensoryFeedback? { switch tone { case .done?: return .success @@ -218,23 +281,22 @@ struct LevelChips: View { @ViewBuilder private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { let title = Text(text) - .font(Theme.percent(.body)) + .font(.body.weight(.semibold).monospacedDigit()) .lineLimit(1) .minimumScaleFactor(0.6) - .padding(.vertical, 6) + .padding(.vertical, 4) .frame(maxWidth: .infinity) + // The chosen one prominent, in ink; the others in plain glass. Group { if selected { - Button(action: action) { title.foregroundStyle(Theme.onAccent) } - .buttonStyle(.borderedProminent) - .tint(Theme.accentFill) + Button(action: action) { title.foregroundStyle(Theme.ground) } + .buttonStyle(.glassProminent) + .tint(Theme.ink) } else { - Button(action: action) { title.foregroundStyle(Theme.fg) } - .buttonStyle(.bordered) - .tint(Theme.fgMuted) + Button(action: action) { title.foregroundStyle(Theme.ink) } + .buttonStyle(.glass) } } - .buttonBorderShape(.roundedRectangle(radius: Theme.radiusSmall)) .accessibilityLabel(label) .accessibilityAddTraits(selected ? .isSelected : []) } @@ -242,7 +304,7 @@ struct LevelChips: View { // MARK: The goal -private struct GoalSection: View { +private struct GoalBlock: View { let lp: Loadpoint let model: LoadpointsModel let site: SiteModel @@ -250,58 +312,55 @@ private struct GoalSection: View { @Binding var editing: Bool var body: some View { - Section { + VStack(alignment: .leading, spacing: 8) { + Kicker(L("Goal")) if model.fromCache { // The cache keeps no goal; say nothing rather than "none". Text(EVText.suggestedGoalText()) .font(.headline) .redacted(reason: .placeholder) } else if let s = lp.schedule { - VStack(alignment: .leading, spacing: 4) { - Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) - .font(.headline) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { - Hint(next) - } + Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.minuteMs / 1000), zone: model.zone)) + .font(.headline) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + if let next = EVText.nextGoal(lp, nowMs: site.minuteMs, zone: model.zone) { + Hint(next, tone: Theme.ink2) } - .padding(.vertical, 2) if lp.pluggedIn, let r = model.readiness(lp) { - Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) - } - if lp.pluggedIn, let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { - Hint(plan) + Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.minuteMs, zone: model.zone)) } if canControl { Button(L("Change")) { editing = true } + .buttonStyle(.glass) } } else { Text(L("No goal yet. Without one, FTW only charges from spare solar.")) .font(.subheadline) - .foregroundStyle(Theme.fg) + .foregroundStyle(Theme.ink) .fixedSize(horizontal: false, vertical: true) if canControl { - Button { - let g = EVText.suggestedGoal - Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } - } label: { - Text(AppWords.format("Use %@", EVText.suggestedGoalText())) - .frame(maxWidth: .infinity) - .foregroundStyle(Theme.onAccent) + HStack(spacing: 8) { + Button { + let g = EVText.suggestedGoal + Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } + } label: { + Text(AppWords.format("Use %@", EVText.suggestedGoalText())) + .lineLimit(2) + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .tint(Theme.ink) + Button(L("Change")) { editing = true } + .buttonStyle(.glass) } - .buttonStyle(.borderedProminent) .controlSize(.large) - Button(L("Change")) { editing = true } } } if let save = model.goalSave[lp.id], !editing { - Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.alarm }; return Theme.fgDim }()) + Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.alarm }; return Theme.ink2 }()) } - } header: { - Text(L("Goal")) } - .listRowBackground(Theme.surfaceRaised) .sensoryFeedback(trigger: model.goalSave[lp.id]) { _, save in switch save { case .saved?, .removed?: return .success @@ -321,11 +380,13 @@ struct Readiness: View { Label { Text(text) .font(.subheadline.weight(.medium)) - .foregroundStyle(Theme.fg) + .foregroundStyle(Theme.ink) .fixedSize(horizontal: false, vertical: true) } icon: { - Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") - .foregroundStyle(short ? Theme.alarm : Theme.live) + // A plan is not a confirmation: in ink when it makes it, alarm + // when it does not. + Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle") + .foregroundStyle(short ? Theme.alarm : Theme.ink2) .contentTransition(.symbolEffect(.replace)) } .accessibilityElement(children: .combine) @@ -339,10 +400,10 @@ struct Readiness: View { // MARK: Charge now, Pause, Back to the plan -/// Plan · Charge now · Pause, always in these places. The one the box -/// reports is chosen; the one asked for turns while it travels; a tap on -/// the chosen one does nothing. Under them, the receipt has a row of its -/// own, of fixed height, so nothing moves when it arrives. +/// Plan · Charge now · Pause, always in these places, in one glass group. +/// The one the box reports is chosen; the one asked for turns while it +/// travels; a tap on the chosen one does nothing. Under them the receipt +/// log has room of its own, reserved, so nothing moves when it arrives. private struct ControlBar: View { let lp: Loadpoint let model: LoadpointsModel @@ -389,48 +450,55 @@ private struct ControlBar: View { var body: some View { let current = self.current let asked = self.asked - VStack(alignment: .leading, spacing: 8) { + let receipt = model.receipt(hold: lp) + let outcome = model.outcome(hold: lp) + let shown = receipt != nil && outcome != nil + VStack(alignment: .leading, spacing: 10) { // Side by side; at the accessibility sizes one under the other, // so no name is cut. let stacked = typeSize.isAccessibilitySize let layout = stacked ? AnyLayout(VStackLayout(spacing: 6)) : AnyLayout(HStackLayout(spacing: 8)) - layout { - ForEach(Choice.allCases) { c in - button(c, chosen: asked == nil && c == current, waiting: asked == c, stacked: stacked) + GlassEffectContainer(spacing: 8) { + layout { + ForEach(Choice.allCases) { c in + button(c, chosen: asked == nil && c == current, waiting: asked == c, stacked: stacked) + } } } + // Both stay in place and only fade, so a receipt that changes + // four times in a few seconds never leaves a half-removed hint + // behind it. ZStack(alignment: .topLeading) { - ReceiptRoom() - if let outcome = model.outcome(hold: lp) { - ReceiptView(outcome: outcome) - .transition(.opacity) - } else { - Text(L("Charge now runs until you go back to the plan or unplug.")) - .font(.footnote) - .foregroundStyle(Theme.fgMuted) - .fixedSize(horizontal: false, vertical: true) - .transition(.opacity) - } + ReceiptSpace() + Text(L("Charge now runs until you go back to the plan or unplug.")) + .font(.footnote) + .foregroundStyle(Theme.ink2) + .fixedSize(horizontal: false, vertical: true) + .opacity(shown ? 0 : 1) + .accessibilityHidden(shown) + CommandReceipt(receipt: receipt ?? ReceiptLog(), text: outcome?.text ?? " ", tone: outcome?.tone ?? .working) + .opacity(shown ? 1 : 0) + .accessibilityHidden(!shown) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) + .animation(Theme.spring, value: shown) } .padding(.horizontal, 16) .padding(.vertical, 10) - .animation(Theme.spring, value: model.outcome(hold: lp)) .animation(Theme.spring, value: current) // Commands answer once, when the box says what it did. - .sensoryFeedback(trigger: model.outcome(hold: lp)?.tone) { _, tone in LevelSection.feedback(tone) } + .sensoryFeedback(trigger: model.outcome(hold: lp)?.tone) { _, tone in LevelBlock.feedback(tone) } // A bar of controls: whole words before the largest letters. .dynamicTypeSize(...DynamicTypeSize.accessibility2) } @ViewBuilder private func button(_ c: Choice, chosen: Bool, waiting: Bool, stacked: Bool) -> some View { - // The chosen one in the brand fill; the others in plain glass. + // The chosen one prominent, in ink; the others in plain glass. if chosen { plain(c, chosen: true, waiting: waiting, stacked: stacked) .buttonStyle(.glassProminent) - .tint(Theme.accentFill) + .tint(Theme.ink) } else { plain(c, chosen: false, waiting: waiting, stacked: stacked) .buttonStyle(.glass) @@ -460,7 +528,7 @@ private struct ControlBar: View { .minimumScaleFactor(0.6) } .font(.subheadline.weight(.semibold)) - .foregroundStyle(chosen ? Theme.onAccent : Theme.fg) + .foregroundStyle(chosen ? Theme.ground : Theme.ink) .frame(maxWidth: .infinity, minHeight: 44) .padding(.vertical, 4) } @@ -471,132 +539,80 @@ private struct ControlBar: View { } } -// MARK: The demo's own cable - -private struct DemoSection: View { - let lp: Loadpoint - let model: LoadpointsModel +/// The receipt log and one sentence under it: what the box did, or, in +/// alarm red, why not. +struct CommandReceipt: View { + let receipt: ReceiptLog + let text: String + let tone: EVText.Outcome.Tone var body: some View { - Section { - Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { - SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) - model.reread() - } - } header: { - Text(L("Demo")) - } footer: { - Text(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) + VStack(alignment: .leading, spacing: 4) { + ReceiptRow(receipt: receipt) + .accessibilityIdentifier("car.receipt") + Text(text) + .font(.footnote.weight(.medium)) + .foregroundStyle(color) + .contentTransition(.opacity) + .fixedSize(horizontal: false, vertical: true) + .animation(.easeInOut(duration: 0.2), value: text) } - .listRowBackground(Theme.surfaceRaised) + .accessibilityElement(children: .combine) } -} -/// Before the first answer and with nothing cached: the shape of the -/// screen, not a spinner. -private struct PlaceholderSections: View { - var body: some View { - Group { - Section { Text("Plugged in · charges at 01:00").font(.title3.weight(.semibold)) } - Section { Text("20 %").font(Theme.percent(.largeTitle, weight: .bold)) } - Section { Text("80 % by 07:00 on weekdays").font(.headline) } + private var color: Color { + switch tone { + case .working: return Theme.ink2 + case .done: return Theme.live + case .warning, .failed: return Theme.alarm } - .listRowBackground(Theme.surfaceRaised) - .redacted(reason: .placeholder) } } -/// 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 { +/// The room a receipt takes, its line and two lines of text, laid out as +/// `CommandReceipt` lays them out and left empty. +private struct ReceiptSpace: 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)) + VStack(alignment: .leading, spacing: 4) { + ReceiptRow(receipt: ReceiptLog()) + 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 +// MARK: The demo's own cable + +private struct DemoBlock: View { + let lp: Loadpoint + let model: LoadpointsModel var body: some View { - VStack(alignment: .leading, spacing: 6) { - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { marks } - VStack(alignment: .leading, spacing: 4) { marks } + VStack(alignment: .leading, spacing: 8) { + Kicker(L("Demo")) + Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { + SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) + model.reread() } - 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 + .buttonStyle(.glass) + Text(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) + .font(.footnote) + .foregroundStyle(Theme.ink3) } } +} - 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 +/// Before the first answer and with nothing cached: the shape of the +/// screen, not a spinner. +private struct Placeholder: View { + var body: some View { + VStack(alignment: .leading, spacing: 24) { + Text("0,0 kW").font(Theme.figure(60)) + Text("Plugged in · charges at 01:00").font(.subheadline) + Text("80 % by 07:00 on weekdays").font(.headline) } + .foregroundStyle(Theme.ink3) + .redacted(reason: .placeholder) } } diff --git a/appleApp/FTW/Car/GoalEditor.swift b/appleApp/FTW/Car/GoalEditor.swift index d8c8795..c6ed00a 100644 --- a/appleApp/FTW/Car/GoalEditor.swift +++ b/appleApp/FTW/Car/GoalEditor.swift @@ -95,9 +95,10 @@ 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) @@ -105,6 +106,7 @@ struct GoalEditor: View { .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) } @@ -112,12 +114,14 @@ struct GoalEditor: View { .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) { @@ -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 { diff --git a/appleApp/FTW/UI/CellMeter.swift b/appleApp/FTW/UI/CellMeter.swift index 831c522..3f436f9 100644 --- a/appleApp/FTW/UI/CellMeter.swift +++ b/appleApp/FTW/UI/CellMeter.swift @@ -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 @@ -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)) diff --git a/appleApp/FTW/UI/NodeHistory.swift b/appleApp/FTW/UI/NodeHistory.swift index 482b5a5..d02fb13 100644 --- a/appleApp/FTW/UI/NodeHistory.swift +++ b/appleApp/FTW/UI/NodeHistory.swift @@ -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 diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 5fec267..fe27e43 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -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 @@ -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 @@ -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) } @@ -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 diff --git a/appleApp/FTW/UI/ReceiptView.swift b/appleApp/FTW/UI/ReceiptView.swift new file mode 100644 index 0000000..cf582f3 --- /dev/null +++ b/appleApp/FTW/UI/ReceiptView.swift @@ -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 + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 5afc1f3..882b59a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -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] @@ -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 } @@ -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 { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 40c8c9a..e7f7054 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -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)) } + } + 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. @@ -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, _ 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 @@ -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 } diff --git a/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift b/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift index ebf993d..ccc5a2a 100644 --- a/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift +++ b/appleApp/FTWKit/Sources/FTWShared/ReceiptLog.swift @@ -25,13 +25,17 @@ public struct ReceiptLog: Equatable, Hashable, Codable, Sendable { public var confirmed: Bool /// The box's reason, ready to show. Set, it takes the last slot. public var refused: String? + /// The slots this command has. A level is not measured on lane 0, so + /// its receipt has no MEASURED. + public var slots: [Slot] - public init(sent: Bool = true, accepted: Bool = false, measured: Bool = false, confirmed: Bool = false, refused: String? = nil) { + public init(sent: Bool = true, accepted: Bool = false, measured: Bool = false, confirmed: Bool = false, refused: String? = nil, slots: [Slot] = Slot.allCases) { self.sent = sent self.accepted = accepted self.measured = measured self.confirmed = confirmed self.refused = refused + self.slots = slots } /// Above this the car draws power: the threshold Now uses for a beam. @@ -57,7 +61,7 @@ public struct ReceiptLog: Equatable, Hashable, Codable, Sendable { /// padded to the width its slot reserves, so a slot that lights or /// turns to REFUSED never moves the others. public var marks: [Mark] { - Slot.allCases.map { slot in + slots.map { slot in if slot == .confirmed, refused != nil { return Mark(slot: slot, text: Self.reserved(.confirmed, ReceiptWord.refused.text), state: .refused) } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift index 83673f2..da39bbe 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -466,6 +466,78 @@ import FTWDemo } } + /// Charge now walks the receipt log in the order its evidence comes: + /// SENT at the tap, ACCEPTED on the box's ack, CONFIRMED on its result, + /// and MEASURED only once lane 0 shows the car drawing, here four + /// seconds later as the charger ramps. It fades 20 s after its last + /// slot lit. + @Test func chargeNowWalksTheReceiptOnItsEvidence() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.setCarPluggedIn(true) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + #expect(car.pluggedIn) + #expect(box.house.evWatts == 0) + @MainActor func marks() -> [ReceiptLog.Mark.State]? { home.loadpoints.receipt(hold: car)?.marks.map(\.state) } + + let charge = Task { await home.loadpoints.chargeNow(car) } + for _ in 0..<10 { await Task.yield() } + #expect(marks() == [.lit, .waiting, .waiting, .waiting]) + await rig.run(400) + #expect(marks() == [.lit, .lit, .waiting, .waiting]) + await rig.run(1_500) + await charge.value + #expect(marks() == [.lit, .lit, .waiting, .lit]) + await rig.run(5_000, step: 100) + #expect(marks() == [.lit, .lit, .lit, .lit]) + #expect(home.loadpoints.receipt(hold: car)?.line == "SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED") + await rig.run(LoadpointsModel.doneShownMs + 1_500, step: 500) + #expect(home.loadpoints.holdStep[car.id] == nil) + } + } + + /// The box's refusal takes the last slot with its reason, and a level's + /// receipt has no MEASURED. + @Test func aRefusedLevelSaysWhyInTheLastSlot() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.setCarPluggedIn(false) + home.loadpoints.activate() + await rig.run(1_500) + let out = try #require(home.loadpoints.points.first) + let refused = Task { await home.loadpoints.setLevel(out, pct: 60) } + await rig.run(2_000) + await refused.value + let receipt = try #require(home.loadpoints.receipt(level: out)) + #expect(receipt.line == "SENT ▸ ACCEPTED ▸ REFUSED") + #expect(receipt.refused == "Plug the car in first. FTW takes a level only while the car is plugged in.") + #expect(receipt.marks.last?.state == .refused) + } + } + + /// MEASURED means lane 0 shows what was asked: drawing for Charge now, + /// still for Pause, the plan's own for Back to the plan; never a level. + @Test func measuredMeansWhatWasAsked() { + #expect(LoadpointsModel.measures(.chargeNow, evW: 7_400, planCharges: false)) + #expect(!LoadpointsModel.measures(.chargeNow, evW: 42, planCharges: false)) + #expect(LoadpointsModel.measures(.pause, evW: 0, planCharges: true)) + #expect(!LoadpointsModel.measures(.pause, evW: 7_400, planCharges: false)) + #expect(LoadpointsModel.measures(.resume, evW: 0, planCharges: false)) + #expect(LoadpointsModel.measures(.resume, evW: 11_000, planCharges: true)) + #expect(!LoadpointsModel.measures(.resume, evW: 0, planCharges: true)) + #expect(!LoadpointsModel.measures(.level(60), evW: 7_400, planCharges: true)) + let step = EVText.Step(ask: .chargeNow, acked: true, result: .superseded(observed: nil)) + #expect(EVText.receipt(step, nil).refused == "Something else changed charging first. Try again.") + } + /// The demo answers "Is FTW in control?" from the stream alone. @Test func theDemoSaysFTWIsInControl() async throws { let rig = Rig() diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift index bb9f9a3..5a3a3ce 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ReceiptLogTests.swift @@ -36,6 +36,14 @@ import Testing #expect(ReceiptLog().marks.map(\.text).joined().count == ReceiptLog(refused: "no").marks.map(\.text).joined().count) } + /// A level has no MEASURED: lane 0 says nothing about a car's level. + @Test func aLevelHasThreeSlots() { + let level = ReceiptLog(accepted: true, confirmed: true, slots: [.sent, .accepted, .confirmed]) + #expect(level.line == "SENT ▸ ACCEPTED ▸ CONFIRMED") + #expect(level.marks.map(\.state) == [.lit, .lit, .lit]) + #expect(ReceiptLog(refused: "Plug the car in first.", slots: [.sent, .accepted, .confirmed]).line == "SENT ▸ ACCEPTED ▸ REFUSED") + } + /// Lane 0 is evidence only while live and above the beam threshold. @Test func measuredNeedsALiveReadingAboveTheThreshold() { #expect(ReceiptLog.drawingW == 42)