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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ never their graphics or brands.
| A plain "what happens next" timeline with times | Flighty | Now's plan sentence, plan view, plan widget |
| Price as bars by level, with the cheap window marked | Tibber, Octopus | Price chart: ink bars by thirds and a cheapest bracket, never red and green |
| Calm lists, one prominent action, a quiet second one | Things, Apple Home | Pairing, mode sheet |
| A control with a few stops: a tick at each, one command when the finger lets go | Camera's zoom control | The planning style scale, in the mode sheet and under the plan |
| Liquid Glass only where the system puts it (bars, sheets, buttons) | iOS 26 | Toolbars, sheets, glass buttons; no custom glass panels |
| A trace that burns at its head and fades when the signal stops | Oscilloscopes, CRT terminals | Beams on Now, the live trace, the status dot |
| One mono status line, and an inverse-video tag for what is selected | Terminal tools | The status line under each title, the scrub tag |
Expand Down
88 changes: 14 additions & 74 deletions appleApp/FTW/UI/ModeMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,27 @@ import FTWKit
import SwiftUI

/// How the home is run, in the bar beside the title: `Balanced ⌄`. A glass
/// menu with the plan, its planning style and whether the battery may sell,
/// and the manual modes. The title sits at the leading edge, so a longer
/// name moves only the button's own edge; the button sizes to its name
/// rather than holding the longest one's width, which would leave the status
/// line too little room on a small phone.
/// button that opens the mode sheet, sized to its content: the planning
/// style as a scale with five stops, whether the battery may sell, and the
/// manual modes. The title sits at the leading edge, so a longer name moves
/// only the button's own edge; the button sizes to its name rather than
/// holding the longest one's width, which would leave the status line too
/// little room on a small phone.
///
/// The choice shows at once; the status line follows the box's receipt,
/// and the outcome alone plays a haptic. Drawn from the cache, it stays
/// inert until the box has spoken in this session. A phone that may not
/// change the mode sees the name and no menu.
/// change the mode sees the name and no button.
struct ModeMenu: View {
let mode: ModeModel
/// Opens the mode sheet. The root view presents it: a sheet held by a
/// view in the bar does not open again after it closed itself.
let open: () -> Void

var body: some View {
Group {
if mode.mayControl {
Menu {
choices
} label: {
Button(action: open) {
label(chevron: true)
}
.disabled(!mode.canControl)
Expand All @@ -32,7 +34,9 @@ struct ModeMenu: View {
.accessibilityValue(name ?? "")
.accessibilityHint(mode.mayControl ? L("Change mode") : "")
.accessibilityIdentifier("now.mode")
// A command answers once, when the box says what it did.
// A command answers once, when the box says what it did, also when
// the sheet was closed first. With the sheet open the two play as
// one.
.sensoryFeedback(trigger: mode.outcome?.tone) { _, tone in ModeSheet.feedback(tone) }
}

Expand Down Expand Up @@ -61,68 +65,4 @@ struct ModeMenu: View {
// Chrome: the bar holds its shape at every text size.
.dynamicTypeSize(...DynamicTypeSize.xLarge)
}

@ViewBuilder private var choices: some View {
Section(L("How your home is run")) {
choice(L("Plan"), help: ModeWords.help(mode.info(mode.prefs?.mappedMode ?? ModeModel.planFallback)), selected: ModeWords.isPlan(mode.shown ?? ""), id: "plan") {
await mode.usePlan()
}
Picker(selection: styleBinding) {
ForEach(ModeModel.styles) { style in
Text(L(style.name)).tag(style.key)
}
} label: {
Label(L("Planning style"), systemImage: "slider.horizontal.3")
Text(mode.shownStyle.map { L($0.name) } ?? "")
}
.pickerStyle(.menu)
.disabled(mode.prefs == nil || !ModeWords.isPlan(mode.shown ?? ""))
Toggle(isOn: exportBinding) {
Label(L("Let the battery sell to the grid when it pays"), systemImage: "arrow.up.forward")
}
.disabled(mode.prefs == nil || !ModeWords.isPlan(mode.shown ?? ""))
}
if !mode.manualModes.isEmpty {
Section(L("Manual")) {
ForEach(mode.manualModes) { m in
choice(ModeWords.name(m), help: ModeWords.help(m), selected: mode.shown == m.key, id: m.key) {
await mode.set(m.key)
}
}
}
}
}

private func choice(_ title: String, help: String, selected: Bool, id: String, _ run: @escaping () async -> Void) -> some View {
Button {
guard !selected, !mode.sending else { return }
Task { await run() }
} label: {
if selected {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
if !help.isEmpty { Text(help) }
}
.accessibilityAddTraits(selected ? .isSelected : [])
.accessibilityIdentifier("mode.menu.\(id)")
}

private var styleBinding: Binding<String> {
Binding(
get: { mode.shownStyle?.key ?? ModeModel.styles[2].key },
set: { key in
guard let style = ModeModel.styles.first(where: { $0.key == key }) else { return }
Task { await mode.setStyle(style) }
}
)
}

private var exportBinding: Binding<Bool> {
Binding(
get: { mode.shownExport ?? false },
set: { on in Task { await mode.setExport(on) } }
)
}
}
47 changes: 20 additions & 27 deletions appleApp/FTW/UI/ModeSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,27 @@ import SwiftUI
/// at once; there is no Save. A tap answers with a haptic, the row waits
/// with a turning mark while the box's mode keeps its tick, and the receipt
/// sits in a bar of fixed height at the foot, so no row moves under the
/// finger. A mode the box applied closes the sheet.
/// finger. A mode the box applied closes the sheet. The style is a scale
/// with five stops, sent when the finger lets go; the sheet stays, and says
/// what the new plan moved.
struct ModeSheet: View {
let mode: ModeModel
/// Opened as the home's settings, from the title's menu: the same
/// choices, with this phone's own settings under them.
var app: AppModel?
/// The plan, to say what a change of style moved.
var plan: PlanShare?
/// The change the sheet opened on, which is not its own to close for:
/// a sheet opened again just after a mode was applied stays open.
@State private var openedOn: ModeModel.Change?
@Environment(\.dismiss) private var dismiss

init(mode: ModeModel, app: AppModel? = nil, plan: PlanShare? = nil) {
self.mode = mode
self.app = app
self.plan = plan
_openedOn = State(initialValue: mode.change)
}
/// The sheet's height for its content, measured once laid out.
@State private var fitted: CGFloat = 520
@State private var detent: PresentationDetent = .height(520)
Expand Down Expand Up @@ -65,7 +79,7 @@ struct ModeSheet: View {
/// a moment later the sheet goes. A style or a sale stays, since the
/// reader may change the other too.
private func closeOnceApplied() async {
guard let change = mode.change, change.result == .applied, case .mode = change.ask else { return }
guard let change = mode.change, change != openedOn, change.result == .applied, case .mode = change.ask else { return }
try? await Task.sleep(for: .milliseconds(900))
guard !Task.isCancelled else { return }
dismiss()
Expand Down Expand Up @@ -93,27 +107,16 @@ struct ModeSheet: View {
await mode.usePlan()
}
// The plan's own settings stay in place whatever runs, so the
// list never changes length under the finger.
Picker(L("Planning style"), selection: styleBinding) {
ForEach(ModeModel.styles) { style in
Text(L(style.name)).tag(style.key)
}
}
.pickerStyle(.menu)
.disabled(!canSet || mode.prefs == nil || !onPlan)
// list never changes length under the finger. The scale rests
// by itself while a manual mode runs.
StyleScale(mode: mode, moved: plan?.moved)
.padding(.vertical, 6)
Toggle(L("Let the battery sell to the grid when it pays"), isOn: exportBinding)
.disabled(!canSet || mode.prefs == nil || !onPlan)
// On is a state: lime, where the ink tint would hide the knob.
.tint(Theme.live)
} header: {
Text(L("How your home is run"))
} footer: {
// The style's own help whatever runs: a footer that changed
// with the mode would move the rows below it under the finger.
if let style = mode.shownStyle {
Text(L(style.help))
.contentTransition(.opacity)
}
}
}

Expand Down Expand Up @@ -188,16 +191,6 @@ struct ModeSheet: View {
.accessibilityIdentifier("mode.choice.\(id)")
}

private var styleBinding: Binding<String> {
Binding(
get: { mode.shownStyle?.key ?? ModeModel.styles[2].key },
set: { key in
guard let style = ModeModel.styles.first(where: { $0.key == key }) else { return }
Task { await mode.setStyle(style) }
}
)
}

private var exportBinding: Binding<Bool> {
Binding(
get: { mode.shownExport ?? false },
Expand Down
2 changes: 1 addition & 1 deletion appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ struct NodeView: View {
if down { trace.hold(atMs: Date().timeIntervalSince1970 * 1000) } else { trace.release() }
}
.sheet(isPresented: $choosingMode) {
ModeSheet(mode: models.mode)
ModeSheet(mode: models.mode, plan: models.planShare)
}
.fullScreenCover(isPresented: $enlarged) {
ChartFullScreen(site: site, node: node, home: home, timebase: $timebase, series: $series, timebases: timebases)
Expand Down
7 changes: 5 additions & 2 deletions appleApp/FTW/UI/NowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,16 @@ private struct PlanSection: View {
let window = prices.prices
let current = window.flatMap { w in w.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } }
let shown = models.planShare.shown(nowMs: minute)
let hasCar = !loadpoints.points.isEmpty
let strip = PlanStrip.build(shown.entry, moment: shown.moment, hasCar: hasCar)
NavigationLink(value: HomeRoute.plan) {
PlanStripView(
strip: PlanStrip.build(shown.entry, moment: shown.moment, hasCar: !loadpoints.points.isEmpty),
strip: strip,
sentence: shown.moment.nextText,
placeholder: shown.moment.placeholder,
priceNow: current.map { PriceStrip.text($0.totalMinor, window?.currency) },
priceUnit: "\(PriceUnits.unit(window?.currency).label.uppercased()) \(PlanGlance.nowWord)"
priceUnit: "\(PriceUnits.unit(window?.currency).label.uppercased()) \(PlanGlance.nowWord)",
fade: StripFade.make(models.planShare.before, strip: strip, nowMs: minute, hasCar: hasCar)
)
.equatable()
}
Expand Down
9 changes: 9 additions & 0 deletions appleApp/FTW/UI/PlanDialCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ struct PlanDialCard: View {
let dial: PlanDial
/// The plan is old or the box could not plan.
let grey: Bool
/// The dial before the person's last change, while its plan stands.
var fade: DialFade?

@State private var scrub = ScrubModel()
/// The quarter the finger was last on, so it cannot wind past the seam.
Expand All @@ -33,6 +35,13 @@ struct PlanDialCard: View {
ZStack {
PlanDialFace(dial: dial, grey: grey)
.equatable()
if let fade {
MovedFade(fresh: fade.fresh, mask: QuarterWedges(dial: fade.old, quarters: fade.moved)) {
PlanDialFace(dial: fade.old, grey: grey)
.equatable()
}
.id(fade.atMs)
}
DialHand(dial: dial, model: scrub)
DialCentre(dial: dial, model: scrub, spoken: spoken)
}
Expand Down
106 changes: 106 additions & 0 deletions appleApp/FTW/UI/PlanFade.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import FTWKit
import SwiftUI

/// The quarters a change moved, as the old plan drew them, over the new
/// plan: on the ground's colour, masked to those quarters, and faded out in
/// 0.2 s. Only the moved quarters cross-fade; the rest never change. Drawn
/// once, as the new plan arrives; a view opened later shows the new plan
/// at once.
struct MovedFade<Content: View, Mask: Shape>: View {
let mask: Mask
let content: Content
@State private var shown: Bool

init(fresh: Bool, mask: Mask, @ViewBuilder content: () -> Content) {
self.mask = mask
self.content = content()
_shown = State(initialValue: fresh)
}

var body: some View {
ZStack {
Theme.ground
content
}
.mask(mask)
.opacity(shown ? 1 : 0)
.allowsHitTesting(false)
.accessibilityHidden(true)
.onAppear {
guard shown else { return }
withAnimation(.easeOut(duration: 0.2)) { shown = false }
}
}
}

/// The strip before a change, and the quarters the change moved.
struct StripFade: Equatable {
let old: PlanStrip
let moved: [Int]
let atMs: Double
let fresh: Bool

/// Nil when the change moved nothing the strip draws.
static func make(_ before: (entry: PlanEntry, atMs: Double, fresh: Bool)?, strip: PlanStrip, nowMs: Double, hasCar: Bool) -> StripFade? {
guard let before else { return nil }
let old = PlanStrip.build(before.entry, moment: PlanGlance.live(before.entry, nowMs: nowMs), hasCar: hasCar)
let moved = strip.moved(from: old)
return moved.isEmpty ? nil : StripFade(old: old, moved: moved, atMs: before.atMs, fresh: before.fresh)
}
}

/// The dial before a change, and the quarters the change moved.
struct DialFade: Equatable {
let old: PlanDial
let moved: [Int]
let atMs: Double
let fresh: Bool

static func make(_ before: (entry: PlanEntry, atMs: Double, fresh: Bool)?, dial: PlanDial, nowMs: Double) -> DialFade? {
guard let before else { return nil }
let old = PlanDial(plan: before.entry, prices: nil, nowMs: nowMs)
let moved = dial.moved(from: old)
return moved.isEmpty ? nil : DialFade(old: old, moved: moved, atMs: before.atMs, fresh: before.fresh)
}
}

/// The strip's columns for some quarters, top to bottom.
struct QuarterColumns: Shape {
let grid: SlotGrid
let quarters: [Int]

func path(in rect: CGRect) -> Path {
var p = Path()
for i in quarters {
let x0 = grid.x(atMs: grid.startMs(i), width: rect.width)
let x1 = grid.x(atMs: grid.endMs(i), width: rect.width)
p.addRect(CGRect(x: rect.minX + x0 - 0.5, y: rect.minY, width: x1 - x0 + 1, height: rect.height))
}
return p
}
}

/// The dial's wedges for some quarters, from the middle out past the rim.
struct QuarterWedges: Shape {
let dial: PlanDial
let quarters: [Int]

func path(in rect: CGRect) -> Path {
let c = CGPoint(x: rect.midX, y: rect.midY)
let r = max(rect.width, rect.height)
func at(_ a: Double) -> CGPoint { CGPoint(x: c.x + r * sin(a), y: c.y - r * cos(a)) }
var p = Path()
for i in quarters {
let a0 = dial.angle(dial.grid.startMs(i))
var a1 = dial.angle(dial.grid.endMs(i))
if a1 < a0 { a1 += 2 * .pi }
// A hair wider, so two wedges side by side leave no seam.
p.move(to: c)
p.addLine(to: at(a0 - 0.004))
p.addLine(to: at((a0 + a1) / 2))
p.addLine(to: at(a1 + 0.004))
p.closeSubpath()
}
return p
}
}
Loading
Loading