From 0b5b4124dd0870f2390c96398fa4af4c5141941f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 18:38:49 +0200 Subject: [PATCH 1/2] Apple Watch: Now opens alive (UX pass 3, U3) With its reply to a look the phone sends each node's last three minutes (WatchNews.recent, packed, 8 KB at most); the watch draws them in ink and keeps them. A short status line that never shrinks or cuts, the space rule in WatchRoot, direction marks, 30 pt rows, the mode by name, a horizontal drag that reads the trace, the forecast while the watch looks, the mode receipt back on Now, the redacted shape before the first reading, and a cache where a nil record keeps the old one and only a sign-out forgets. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Watch/WatchBridge.swift | 13 +- .../Sources/FTWKit/State/WatchShare.swift | 47 ++- .../Resources/sv.lproj/Watch.strings | 3 + .../Sources/FTWShared/Watch/WatchGlance.swift | 122 ++++++-- .../Sources/FTWShared/Watch/WatchWire.swift | 179 +++++++++++- .../Sources/FTWShared/Watch/WatchWords.swift | 3 + .../Tests/FTWKitTests/WatchShareTests.swift | 48 ++++ .../Tests/FTWSharedTests/WatchTests.swift | 236 ++++++++++++++- appleApp/FTWWatch/NowPage.swift | 268 ++++++++++++++---- appleApp/FTWWatch/Phosphor.swift | 63 +++- appleApp/FTWWatch/WatchCache.swift | 10 +- appleApp/FTWWatch/WatchModel.swift | 92 +++++- appleApp/FTWWatch/WatchRoot.swift | 28 +- appleApp/FTWWatch/WatchTrace.swift | 72 ++++- appleApp/FTWWatchUITests/WatchTour.swift | 58 ++++ 15 files changed, 1102 insertions(+), 140 deletions(-) diff --git a/appleApp/FTW/Watch/WatchBridge.swift b/appleApp/FTW/Watch/WatchBridge.swift index 2c8aa6a..25b37f4 100644 --- a/appleApp/FTW/Watch/WatchBridge.swift +++ b/appleApp/FTW/Watch/WatchBridge.swift @@ -86,7 +86,9 @@ final class WatchBridge: NSObject { Self.log.info("no watch to hand the context to: paired \(session.isPaired, privacy: .public), FTW on it \(session.isWatchAppInstalled, privacy: .public)") return } - let ctx = WatchShare.context(app.home, isDemo: app.isDemo, receipt: lastReceipt, nowMs: nowMs) + // No home and no paired site is a sign-out; no home with a site is + // a phone still opening it, which the watch leaves be. + let ctx = WatchShare.context(app.home, isDemo: app.isDemo, signedOut: app.home == nil && app.sites.current() == nil, receipt: lastReceipt, nowMs: nowMs) if let lastSent, lastSent.sameAs(ctx) { return } do { let data = try ctx.packed() @@ -130,7 +132,11 @@ final class WatchBridge: NSObject { private func startLooking() { send() // Asked again while it looks: keep the session, and only the clock - // that lets it go starts over. + // that lets it go starts over. The watch was away meanwhile, so it + // gets the last three minutes again. + if look != nil, let home = app?.home { + send(news: .recent(WatchShare.recent(home, nowMs: nowMs))) + } if look == nil, let runner { look = Task { [weak self] in let time = BackgroundTime("FTW watch looks") { self?.stopLooking() } @@ -167,6 +173,9 @@ final class WatchBridge: NSObject { done() } var lastAt: Double? + // The reply to the look: the last three minutes first, so the trace + // opens whole, then Now as it stands. + send(news: .recent(WatchShare.recent(home, nowMs: nowMs))) send(news: WatchShare.now(home).map(WatchNews.now)) // Ends when the look is let go; a watch out of reach meanwhile gets // nothing, and its reachability ends the look. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift index a361445..9412768 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift @@ -14,8 +14,11 @@ import Observation public enum WatchShare { // MARK: What the watch shows - public static func context(_ home: HomeModels?, isDemo: Bool, receipt: WatchReceipt? = nil, nowMs: Double) -> WatchContext { - guard let home else { return .empty(sentAtMs: nowMs) } + /// `signedOut` says why there is no home: the phone signed out or was + /// never paired, so the watch forgets; else the phone is still opening + /// its home and the watch keeps what it has. + public static func context(_ home: HomeModels?, isDemo: Bool, signedOut: Bool = false, receipt: WatchReceipt? = nil, nowMs: Double) -> WatchContext { + guard let home else { return signedOut ? .signOut(sentAtMs: nowMs) : .opening(sentAtMs: nowMs) } let key = homeKey(home) return WatchContext( home: key, @@ -31,22 +34,44 @@ public enum WatchShare { } /// The models the watch's pages show, read as if a screen showing them - /// had appeared: the prices, the chargers, the plan and the mode. Call - /// the returned closure when the watch looks away. + /// had appeared: the prices, the chargers, the plan with the forecast + /// that gives the dial its sun, and the mode. Call the returned closure + /// when the watch looks away. public static func looking(_ home: HomeModels) -> @MainActor () -> Void { let (prices, chargers, plan, mode) = (home.prices, home.loadpoints, home.nodes.plan, home.nodes.mode) prices.activate() chargers.activate() - plan.activate() + plan.activate(forecast: true) mode.activate() return { prices.deactivate() chargers.deactivate() - plan.deactivate() + plan.deactivate(forecast: true) mode.deactivate() } } + /// The last three minutes of each node as this phone recorded them, for + /// the reply to `.look`: the watch's trace opens whole, in ink. + public static func recent(_ home: HomeModels, nowMs: Double) -> WatchRecent { + let since = nowMs - WatchRecent.windowMs + var samples = [WatchNow.Node: [LiveSample]]() + for node in EnergyNode.allCases { + samples[watchNode(node)] = home.nodes.live.samples(node, sinceMs: since) + } + return WatchRecent.make(samples, nowMs: nowMs) + } + + static func watchNode(_ node: EnergyNode) -> WatchNow.Node { + switch node { + case .solar: return .solar + case .battery: return .battery + case .home: return .home + case .grid: return .grid + case .car: return .car + } + } + /// The site, or the demo's own key, as a command from the watch names /// it. public static func homeKey(_ home: HomeModels) -> String { home.site.siteId ?? "demo" } @@ -116,15 +141,7 @@ public enum WatchShare { case .grid: way = !r.flowing ? .balanced : (r.towardHome ? .buying : .selling) case .car: way = r.flowing ? .charging : .resting } - let node: WatchNow.Node - switch r.node { - case .solar: node = .solar - case .battery: node = .battery - case .home: node = .home - case .grid: node = .grid - case .car: node = .car - } - return WatchNow.Reading(node: node, watts: r.watts, way: way, levelPct: r.levelPct, levelAssumed: r.levelAssumed, live: r.freshness.live) + return WatchNow.Reading(node: watchNode(r.node), watts: r.watts, way: way, levelPct: r.levelPct, levelAssumed: r.levelAssumed, live: r.freshness.live) } static func modes(_ home: HomeModels) -> WatchModes? { diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings index 42c850e..9d175fa 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings @@ -11,6 +11,9 @@ "SENT" = "SKICKAT"; "ACCEPTED" = "MOTTAGET"; "CONFIRMED" = "BEKRÄFTAT"; +"SENDING…" = "SKICKAR…"; +"▲ NOT CONFIRMED" = "▲ EJ BEKRÄFTAT"; +"▲ NOT SENT" = "▲ INTE SKICKAT"; "Sending to your iPhone…" = "Skickar till din iPhone …"; "Your iPhone is sending it to your box…" = "Din iPhone skickar det till boxen …"; "Your iPhone can't be reached. Nothing was sent." = "Din iPhone nås inte. Inget skickades."; diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift index 58cbd00..51978be 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift @@ -39,9 +39,18 @@ public enum WatchGlance { public let level: String? /// Lit: the frame is live and so is this reading. public let lit: Bool + /// Which way the battery or the grid runs, for the mark on its + /// glyph: up while it stores or the home buys, down while it gives + /// or sells, as the trace draws them. Nil for the rest, and at rest. + public let direction: Direction? public var id: WatchNow.Node { node } } + public enum Direction: Sendable, Equatable { case up, down } + + /// Below this a node rests: the threshold Now uses for a beam. + public static let restW: Double = 42 + public static func figures(_ now: WatchNow?, lit: Bool) -> [Figure] { guard let now else { return [] } return now.readings.map { r in @@ -58,11 +67,43 @@ public enum WatchGlance { unit: parts?.unit ?? "", word: word(r.way), level: level, - lit: lit && r.live + lit: lit && r.live, + direction: direction(r.node, r.way) ) } } + public static func direction(_ node: WatchNow.Node, _ way: WatchNow.Way) -> Direction? { + guard node == .battery || node == .grid else { return nil } + switch way { + case .charging, .buying: return .up + case .giving, .selling: return .down + default: return nil + } + } + + /// The reading under a finger on the trace, as the large figure says + /// it: its value and the word for its direction. Never lit: it is a + /// moment past, under the finger. + public static func figure(_ node: WatchNow.Node, at sample: LiveSample) -> Figure { + let watts = abs(sample.v) + let way = way(node, signedW: sample.v) + let parts = PowerFormat.parts(watts) + return Figure(node: node, title: title(node), value: Words.decimal(parts.text), unit: parts.unit, word: word(way), level: nil, lit: false, direction: direction(node, way)) + } + + /// The word for a signed reading of `node`, as the trace draws it. + public static func way(_ node: WatchNow.Node, signedW v: Double) -> WatchNow.Way { + let resting = abs(v) <= restW + switch node { + case .solar: return resting ? .resting : .making + case .battery: return resting ? .resting : (v > 0 ? .charging : .giving) + case .home: return .using + case .grid: return resting ? .balanced : (v > 0 ? .buying : .selling) + case .car: return resting ? .resting : .charging + } + } + public static func title(_ node: WatchNow.Node) -> String { switch node { case .solar: return WatchWord.solar.text @@ -87,33 +128,78 @@ public enum WatchGlance { } } - /// The mono line under the title, in the phone's words: `● LIVE · FTW - /// IN CONTROL`, or `○ AS OF 14:05 · FTW IN CONTROL`. It never counts - /// seconds; a cached reading names the minute it was read. + /// The mono line at the top of Now, in the phone's words and in the + /// watch's short form: `● LIVE · FTW IN CONTROL`, `○ 07:00 · FTW IN + /// CONTROL`. It never counts seconds and never says "as of": a hollow + /// ring and the minute the reading was read are the age. For its time + /// after a change of mode, the box's receipt takes the line: + /// `SENT ▸ ACCEPTED ▸`, then `SENT ▸ CONFIRMED`. public struct Status: Equatable, Sendable { + public enum Tone: Equatable, Sendable { case ink, live, alarm } + + public struct Part: Equatable, Sendable { + public let text: String + public let tone: Tone + } + public let lit: Bool - public let text: String - /// The second half is a warning. - public let alarm: Bool + public let parts: [Part] + + /// The line as one string, for VoiceOver and as its identity. + public var text: String { parts.map(\.text).joined(separator: " · ") } + /// Some part of it is a warning. + public var alarm: Bool { parts.contains { $0.tone == .alarm } } } - public static func status(_ record: CacheRecord?, lit: Bool, demo: Bool, zone: TimeZone) -> Status { - guard let record else { return Status(lit: false, text: WatchWord.noReadingYet.text, alarm: false) } - var parts = [String]() + /// How long a settled receipt keeps the line, as on the phone. + public static let receiptShownMs: Double = 4_000 + + public static func status(_ record: CacheRecord?, lit: Bool, demo: Bool, zone: TimeZone, receipt: WatchReceipt? = nil) -> Status { + typealias Part = Status.Part + if let receipt { return Status(lit: lit, parts: receiptParts(receipt)) } + guard let record else { return Status(lit: false, parts: [Part(text: WatchWord.noReadingYet.text, tone: .ink)]) } + var parts = [Part]() if lit { - parts.append(demo ? WatchWord.demo.text : WatchWord.live.text) + parts.append(Part(text: demo ? WatchWord.demo.text : WatchWord.live.text, tone: .ink)) } else { - parts.append(WatchWord.asOf.format(ZoneClock.of(zone).label(record.readAtMs))) + parts.append(Part(text: ZoneClock.of(zone).label(record.readAtMs), tone: .ink)) } - var alarm = false switch record.value.control { - case .inControl: parts.append(WatchWord.inControl.text) - case .notInControl: - parts.append(WatchWord.notInControl.text) - alarm = true + case .inControl: parts.append(Part(text: WatchWord.inControl.text, tone: .ink)) + case .notInControl: parts.append(Part(text: WatchWord.notInControl.text, tone: .alarm)) case .unknown: break } - return Status(lit: lit, text: parts.joined(separator: " · "), alarm: alarm) + return Status(lit: lit, parts: parts) + } + + /// A command's receipt in the status line's words: how far the box has + /// come, never a mark the box has not given. + static func receiptParts(_ r: WatchReceipt) -> [Status.Part] { + let sent = ReceiptLog.word(.sent) + " ▸" + switch r.tone { + case .done: + return [Status.Part(text: "\(sent) \(ReceiptLog.word(.confirmed))", tone: .live)] + case .failed: + let word = r.sent == .failed ? WatchWord.notSent.text : "▲ " + ReceiptLog.refusedWord + return [Status.Part(text: word, tone: .alarm)] + case .warning: + return [Status.Part(text: WatchWord.notConfirmed.text, tone: .alarm)] + case .working: + if r.accepted == .done { return [Status.Part(text: "\(sent) \(ReceiptLog.word(.accepted)) ▸", tone: .ink)] } + if r.sent == .done { return [Status.Part(text: sent, tone: .ink)] } + return [Status.Part(text: WatchWord.sending.text, tone: .ink)] + } + } + + /// The longest ordinary line in the phone's language, in characters, + /// so the watch can set one size that holds it on every screen: "07:00 + /// · FTW IN CONTROL" in English, "SKICKAT ▸ MOTTAGET ▸" in Swedish. A + /// warning may run longer and take a second line. + public static var statusChars: Int { + let readings = [WatchWord.live.text, WatchWord.demo.text, "00:00"].map { "\($0) · \(WatchWord.inControl.text)" } + let sent = ReceiptLog.word(.sent) + " ▸" + let receipts = [WatchWord.sending.text, sent, "\(sent) \(ReceiptLog.word(.accepted)) ▸", "\(sent) \(ReceiptLog.word(.confirmed))"] + return (readings + receipts + [WatchWord.noReadingYet.text]).map(\.count).max() ?? 22 } /// The household's zone, from whichever record names it. diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift index 9fd1e08..4eec2d2 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift @@ -20,13 +20,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. + /// The least a trace's scale reaches, so a quiet node draws flat: a + /// quarter of the phone's (`EnergyNode.scaleFloorW`), because the + /// wrist's trace is about a quarter as tall. Noise of 60 W then + /// spreads as few points as on the phone, and a home using 400 W + /// fills the trace rather than hugging its floor. public var floorW: Double { switch self { - case .home: return 2_000 - case .grid, .battery, .solar: return 3_000 - case .car: return 3_700 + case .home: return 500 + case .grid, .battery, .solar: return 750 + case .car: return 925 } } } @@ -84,6 +87,16 @@ public struct WatchNow: CacheValue { } public func reading(_ node: Node) -> Reading? { readings.first { $0.node == node } } + + /// Now's shape, drawn redacted while nothing is known. Never shown as + /// a reading. + public static let shape = WatchNow(readings: [ + Reading(node: .home, watts: 1_500, way: .using, live: false), + Reading(node: .solar, watts: 2_000, way: .making, live: false), + Reading(node: .battery, watts: 1_000, way: .charging, levelPct: 50, live: false), + Reading(node: .grid, watts: 500, way: .buying, live: false), + Reading(node: .car, watts: 0, way: .resting, live: false), + ], control: .unknown) } /// How the home is run, and what the watch may ask for: the box's modes in @@ -141,6 +154,14 @@ public struct WatchContext: Codable, Sendable, Equatable { /// The last command from the watch, as far as the box has answered it, /// so a watch that slept meanwhile still sees how it ended. public var receipt: WatchReceipt? + /// The last minutes of Now's trace, as this watch last drew them. The + /// watch keeps them here for its next open; the phone never fills it + /// and sends its own with the reply to `.look` (`WatchNews.recent`). + public var recent: WatchRecent? + /// The phone signed out, or was never paired, and says so. A context + /// with no home that does not say so comes from a phone still opening + /// its home, and changes nothing on the watch. + public var signedOut: Bool? /// Phone clock when the phone sent this. public var sentAtMs: Double @@ -157,13 +178,51 @@ public struct WatchContext: Codable, Sendable, Equatable { self.sentAtMs = sentAtMs } - /// The phone has signed out, or never paired. - public static func empty(sentAtMs: Double) -> WatchContext { + /// The phone has signed out, or never paired: the watch forgets the + /// home and everything it kept of it. + public static func signOut(sentAtMs: Double) -> WatchContext { + var ctx = WatchContext(home: "", isDemo: false, sentAtMs: sentAtMs) + ctx.signedOut = true + return ctx + } + + /// The phone has a home but has not opened it yet: nothing to show, + /// and nothing for the watch to forget. + public static func opening(sentAtMs: Double) -> WatchContext { WatchContext(home: "", isDemo: false, sentAtMs: sentAtMs) } public var hasHome: Bool { !home.isEmpty } + /// What the watch keeps once `next` arrives over `kept`. Only an + /// explicit sign-out forgets; a phone still opening its home changes + /// nothing; another home replaces the whole. For the same home each + /// record is the newer of the two, and one the phone did not send keeps + /// the old: a phone that has not read its prices yet since it opened + /// does not take them off the wrist. The dial keeps the last sun the + /// box forecast until a newer forecast comes. Nil: nothing to keep. + public static func merging(_ next: WatchContext, over kept: WatchContext?) -> WatchContext? { + if next.signedOut == true { return next } + guard next.hasHome else { return kept } + guard let kept, kept.home == next.home else { return next } + var out = next + out.prices = newer(kept.prices, next.prices) + out.cars = newer(kept.cars, next.cars) + out.plan = newer(kept.plan, next.plan) + out.now = newer(kept.now, next.now) + out.modes = next.modes ?? kept.modes + out.receipt = next.receipt ?? kept.receipt + if (kept.recent?.newestMs ?? 0) > (next.recent?.newestMs ?? 0) { out.recent = kept.recent } + if out.plan != nil, out.plan?.value.daylight == nil { out.plan?.value.daylight = kept.plan?.value.daylight } + return out + } + + private static func newer(_ kept: CacheRecord?, _ next: CacheRecord?) -> CacheRecord? { + guard let kept else { return next } + guard let next else { return kept } + return next.readAtMs >= kept.readAtMs ? next : kept + } + /// The same records, whenever the phone sent them. A resend of this /// tells the watch nothing new. public func sameAs(_ other: WatchContext) -> Bool { @@ -189,6 +248,109 @@ public struct WatchContext: Codable, Sendable, Equatable { } } +/// The last minutes of each node as the phone recorded them, sent with its +/// reply to `.look` so the watch's trace opens whole, and kept by the watch +/// for its next open. The watch saw none of it live, so it draws it in ink. +/// +/// On the wire as one packed blob, JSON then LZFSE as the context is: three +/// minutes of five nodes at 1 Hz come to about 3 KB. A window that would +/// not fit `maxBytes` keeps every other reading until it does. +public struct WatchRecent: Sendable, Equatable { + /// The width of the watch's trace. + public static let windowMs: Double = 3 * 60_000 + /// The most one reply to `.look` carries, as the phone sends it. + public static let maxBytes = 8_192 + + public struct Lane: Codable, Sendable, Equatable { + public var node: WatchNow.Node + /// The first reading's time by the phone clock, in whole ms. + public var t0: Double + /// Each reading's time after the one before, in ms; the first is 0. + public var dt: [Int] + /// Whole watts, in the direction the trace draws them + /// (`WatchNow.Reading.signedW`). + public var w: [Int] + + public init(node: WatchNow.Node, samples: [LiveSample]) { + self.node = node + t0 = samples.first?.t.rounded() ?? 0 + var last = t0 + dt = samples.map { s in + let t = s.t.rounded() + defer { last = t } + return Int(t - last) + } + w = samples.map { Int($0.v.rounded()) } + } + + /// Not seen live on this watch: drawn in ink. + public var samples: [LiveSample] { + var t = t0 + return zip(dt, w).map { step, watts in + t += Double(step) + return LiveSample(t: t, v: Double(watts), seen: false) + } + } + + /// Every other reading, the last one kept. + func thinned() -> Lane { + let all = samples + let keep = all.indices.filter { ($0 - all.count + 1) % 2 == 0 } + return Lane(node: node, samples: keep.map { all[$0] }) + } + } + + public var lanes: [Lane] + + public init(lanes: [Lane]) { + self.lanes = lanes + } + + /// Each node's readings in the window that ends at `nowMs`, thinned + /// until one reply carries them. + public static func make(_ samples: [WatchNow.Node: [LiveSample]], nowMs: Double) -> WatchRecent { + let from = nowMs - windowMs + var recent = WatchRecent(lanes: WatchNow.Node.allCases.compactMap { node in + let kept = (samples[node] ?? []).filter { $0.t >= from && $0.t <= nowMs } + return kept.isEmpty ? nil : Lane(node: node, samples: kept) + }) + while recent.wireBytes > maxBytes { + guard recent.lanes.contains(where: { $0.w.count > 2 }) else { return WatchRecent(lanes: []) } + recent.lanes = recent.lanes.map { $0.thinned() } + } + return recent + } + + public func samples(_ node: WatchNow.Node) -> [LiveSample] { + lanes.first { $0.node == node }?.samples ?? [] + } + + /// The newest reading in any lane. + public var newestMs: Double? { + lanes.compactMap { lane in lane.dt.isEmpty ? nil : lane.t0 + Double(lane.dt.reduce(0, +)) }.max() + } + + /// The bytes the phone sends: the whole message, as `WatchNews`. + public var wireBytes: Int { + (try? WatchNews.recent(self).data().count) ?? .max + } +} + +extension WatchRecent: Codable { + public init(from decoder: Decoder) throws { + let packed = try decoder.singleValueContainer().decode(Data.self) + guard let json = try? (packed as NSData).decompressed(using: .lzfse) as Data else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "not a packed trace")) + } + lanes = try JSONDecoder().decode([Lane].self, from: json) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(try (JSONEncoder().encode(lanes) as NSData).compressed(using: .lzfse) as Data) + } +} + /// A command from the watch: what to do, to which home, and until when it /// still means something. The phone builds the box's `cmd` and checks the /// home and the time; the watch never names an op the box knows. @@ -260,6 +422,9 @@ public enum WatchAsk: Codable, Sendable, Equatable { public enum WatchNews: Codable, Sendable, Equatable { case now(CacheRecord) case receipt(WatchReceipt) + /// The last three minutes of each node, with the reply to `.look`, so + /// the trace opens whole rather than growing from one dot. + case recent(WatchRecent) public func data() throws -> Data { try JSONEncoder().encode(self) } public static func from(_ data: Data) -> WatchNews? { try? JSONDecoder().decode(WatchNews.self, from: data) } diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift index 0c57dd8..caf3f1b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift @@ -20,6 +20,9 @@ public enum WatchWord: String, CaseIterable, Sendable { case sent = "SENT" case accepted = "ACCEPTED" case confirmed = "CONFIRMED" + case sending = "SENDING…" + case notConfirmed = "▲ NOT CONFIRMED" + case notSent = "▲ NOT SENT" case sendingToPhone = "Sending to your iPhone…" case phoneSending = "Your iPhone is sending it to your box…" case phoneUnreachableNothingSent = "Your iPhone can't be reached. Nothing was sent." diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift index 8bff7e8..d6df35d 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift @@ -105,4 +105,52 @@ import FTWShared #expect(try ctx.packed().count < 40_000) #expect(WatchShare.context(nil, isDemo: false, nowMs: 0).hasHome == false) } + + /// The reply to a look carries the last three minutes the phone + /// recorded of each node, in one message the size the watch allows. + @Test func theReplyToALookCarriesTheLastThreeMinutes() async throws { + let rig = Rig() + _ = try rig.start() + let home = try #require(rig.app.home) + await rig.run(200_000, step: 1_000) + let now = rig.scheduler.nowMs + let recent = WatchShare.recent(home, nowMs: now) + #expect(recent.wireBytes <= WatchRecent.maxBytes) + let nodes = Set(recent.lanes.map(\.node)) + #expect(nodes.isSuperset(of: [.home, .battery, .grid])) + let usage = recent.samples(.home) + // About one reading a second, across the whole window and no more. + #expect(usage.count >= 150 && usage.count <= 200) + #expect((usage.first?.t ?? 0) >= now - WatchRecent.windowMs) + #expect(now - (usage.last?.t ?? 0) < 3_000) + // Solar as what it makes, as the watch's figures say it. + #expect(recent.samples(.solar).allSatisfy { $0.v >= 0 }) + #expect(usage.allSatisfy { !$0.seen }) + } + + /// While the watch looks, the plan is read with its forecast, so the + /// dial has its sun without the phone's plan view ever opening. + @Test func aLookingWatchGetsTheSun() async throws { + let rig = Rig() + _ = try rig.start() + let home = try #require(rig.app.home) + let done = WatchShare.looking(home) + defer { done() } + await rig.run(10_000) + #expect(home.nodes.plan.forecastStamp != nil) + let ctx = WatchShare.context(home, isDemo: true, nowMs: rig.scheduler.nowMs) + #expect(ctx.plan?.value.daylight != nil) + } + + /// No home on the phone says why: a sign-out the watch forgets, or a + /// phone still opening the home the watch keeps. + @Test func noHomeSaysWhy() { + let out = WatchShare.context(nil, isDemo: false, signedOut: true, nowMs: 1) + let opening = WatchShare.context(nil, isDemo: false, nowMs: 1) + #expect(out.signedOut == true && !out.hasHome) + #expect(opening.signedOut == nil && !opening.hasHome) + let kept = WatchContext(home: "s1", isDemo: false, sentAtMs: 0) + #expect(WatchContext.merging(opening, over: kept) == kept) + #expect(WatchContext.merging(out, over: kept)?.hasHome == false) + } } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift index 5bb1a77..0532790 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift @@ -97,7 +97,8 @@ import Testing #expect(a.sameAs(b)) b.home = "s2" #expect(!a.sameAs(b)) - #expect(!WatchContext.empty(sentAtMs: 1).hasHome) + #expect(!WatchContext.signOut(sentAtMs: 1).hasHome) + #expect(!WatchContext.opening(sentAtMs: 1).hasHome) } @Test func asksAndNewsRoundTrip() throws { @@ -178,17 +179,90 @@ import Testing #expect(figures[2].value == "120" && figures[2].word == "buying") } + /// The watch's short form: live is a word, anything else the minute it + /// was read, never "as of" and never seconds. @Test func theStatusLineSaysLiveOrTheMinuteItWasRead() { let t = at(14, 10, 5) let zone = TimeZone(identifier: Self.zone)! #expect(WatchGlance.status(now(readAtMs: t), lit: true, demo: false, zone: zone).text == "LIVE · FTW IN CONTROL") - #expect(WatchGlance.status(now(readAtMs: t), lit: false, demo: false, zone: zone).text == "AS OF 10:05 · FTW IN CONTROL") + #expect(WatchGlance.status(now(readAtMs: t), lit: false, demo: false, zone: zone).text == "10:05 · FTW IN CONTROL") #expect(WatchGlance.status(now(readAtMs: t), lit: true, demo: true, zone: zone).text == "DEMO · FTW IN CONTROL") #expect(WatchGlance.status(nil, lit: false, demo: false, zone: zone).text == "NO READING YET") var out = now(readAtMs: t) out.value.control = .notInControl let warn = WatchGlance.status(out, lit: true, demo: false, zone: zone) #expect(warn.alarm && warn.text == "LIVE · ▲ FTW NOT IN CONTROL") + #expect(warn.parts.map(\.tone) == [.ink, .alarm]) + } + + /// One size holds every ordinary line, so the line never shrinks + /// between live and cached: the cached one is one character longer. + @Test func theStatusLineHasOneWidthInEveryState() { + let t = at(14, 23, 59) + let zone = TimeZone(identifier: Self.zone)! + #expect(WatchGlance.statusChars == 22) + for lit in [true, false] { + for demo in [true, false] { + #expect(WatchGlance.status(now(readAtMs: t), lit: lit, demo: demo, zone: zone).text.count <= WatchGlance.statusChars) + } + } + // So do the line before the first reading and a receipt on its way. + #expect(WatchGlance.status(nil, lit: false, demo: false, zone: zone).text.count <= WatchGlance.statusChars) + let c = WatchCommand(op: .mode("idle"), home: "s1", sentAtMs: t) + for accepted in [WatchReceipt.Mark.waiting, .done] { + let r = WatchReceipt(commandID: c.id, op: c.op, sent: .done, accepted: accepted, confirmed: .waiting, text: "", tone: .working) + #expect(WatchGlance.status(now(readAtMs: t), lit: true, demo: false, zone: zone, receipt: r).text.count <= WatchGlance.statusChars) + } + } + + /// After a change of mode the box's receipt takes the line for its + /// time, mark by mark, and never a mark the box has not given. + @Test func theReceiptTakesTheLineMarkByMark() { + let t = at(14, 10) + let zone = TimeZone(identifier: Self.zone)! + let c = WatchCommand(op: .mode("idle"), home: "s1", sentAtMs: t) + func line(_ r: WatchReceipt) -> WatchGlance.Status { + WatchGlance.status(now(readAtMs: t), lit: true, demo: false, zone: zone, receipt: r) + } + #expect(line(.tapped(c)).text == "SENDING…") + var r = WatchReceipt(commandID: c.id, op: c.op, sent: .done, accepted: .waiting, confirmed: .waiting, text: "", tone: .working) + #expect(line(r).text == "SENT ▸") + r.accepted = .done + #expect(line(r).text == "SENT ▸ ACCEPTED ▸") + r.confirmed = .done + r.tone = .done + #expect(line(r).text == "SENT ▸ CONFIRMED" && line(r).parts.first?.tone == .live) + #expect(line(.unreachable(c)).text == "▲ NOT SENT" && line(.unreachable(c)).alarm) + #expect(line(WatchReceipt.tapped(c).noAnswer()).text == "▲ NOT CONFIRMED") + r.sent = .done + r.confirmed = .failed + r.tone = .failed + #expect(line(r).text == "▲ REFUSED") + // The dot still says whether Now is live. + #expect(line(r).lit) + } + + // MARK: Now's figures + + @Test func theBatteryAndTheGridMarkTheirWay() { + let figures = WatchGlance.figures(now(readAtMs: 0).value, lit: false) + #expect(figures.map(\.direction) == [nil, .down, .up]) + let shape = WatchGlance.figures(WatchNow.shape, lit: true) + #expect(Set(shape.map(\.node)) == Set(WatchNow.Node.allCases)) + #expect(shape.allSatisfy { !$0.lit }) + } + + /// Under a finger the large figure gives the reading and its way, with + /// no minus sign. + @Test func aFingerOnTheTraceReadsTheWay() { + let selling = WatchGlance.figure(.grid, at: LiveSample(t: 0, v: -1_200)) + #expect(selling.value == "1.2" && selling.unit == "kW" && selling.word == "selling" && selling.direction == .down) + let storing = WatchGlance.figure(.battery, at: LiveSample(t: 0, v: 800)) + #expect(storing.value == "800" && storing.word == "charging" && storing.direction == .up) + #expect(WatchGlance.figure(.battery, at: LiveSample(t: 0, v: -30)).direction == nil) + #expect(WatchGlance.figure(.home, at: LiveSample(t: 0, v: 412)).word == "using") + #expect(WatchGlance.figure(.solar, at: LiveSample(t: 0, v: 2_000)).direction == nil) + #expect(!WatchGlance.figure(.home, at: LiveSample(t: 0, v: 412)).lit) } // MARK: The dial @@ -326,7 +400,7 @@ import Testing #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) + #expect(WatchNow.Node.home.floorW == 500 && WatchNow.Node.car.floorW == 925) } @Test func aCheapWindowHasOneFigureForASmallSlot() { @@ -334,6 +408,150 @@ import Testing #expect(w.map(\.short) == ["70", "30"]) } + // MARK: The last three minutes + + /// Readings at about 1 Hz with the jitter of a real stream, and values + /// that move every second. + func samples(from: Double, to: Double, everyMs: Double = 1_000, seed: UInt64 = 7) -> [LiveSample] { + var g = Seeded(seed) + var out = [LiveSample]() + var t = from + var v = 1_500.0 + while t <= to { + v = max(-9_000, min(9_000, v + Double(Int.random(in: -400...400, using: &g)))) + out.append(LiveSample(t: t, v: v + Double.random(in: -0.5..<0.5, using: &g))) + t += everyMs + Double(Int.random(in: -40...40, using: &g)) + } + return out + } + + /// Five nodes for three minutes at 1 Hz fit one reply with room to + /// spare, and come back to the millisecond and the watt. + @Test func theLastThreeMinutesFitOneReply() throws { + let t = at(14, 12) + var all = [WatchNow.Node: [LiveSample]]() + for (i, node) in WatchNow.Node.allCases.enumerated() { + all[node] = samples(from: t - 4 * 60_000, to: t, seed: UInt64(i + 1)) + } + let recent = WatchRecent.make(all, nowMs: t) + #expect(recent.lanes.count == 5) + #expect(recent.wireBytes <= WatchRecent.maxBytes) + // Nothing thinned: every reading in the window is there. + for node in WatchNow.Node.allCases { + let sent = try #require(all[node]).filter { $0.t >= t - WatchRecent.windowMs } + let back = recent.samples(node) + #expect(back.count == sent.count) + for (a, b) in zip(sent, back) { + #expect(abs(a.t - b.t) <= 1) + #expect(abs(a.v - b.v) <= 1) + #expect(!b.seen) + } + } + let news = try WatchNews.recent(recent).data() + #expect(news.count <= WatchRecent.maxBytes) + #expect(WatchNews.from(news) == .recent(recent)) + #expect(recent.newestMs == WatchNow.Node.allCases.compactMap { recent.samples($0).last?.t }.max()) + } + + /// A window too dense for one reply keeps every other reading until it + /// fits, and always the newest. + @Test func aWindowTooDenseIsThinnedToFit() { + let t = at(14, 12) + var all = [WatchNow.Node: [LiveSample]]() + for (i, node) in WatchNow.Node.allCases.enumerated() { + all[node] = samples(from: t - WatchRecent.windowMs, to: t, everyMs: 100, seed: UInt64(10 + i)) + } + let recent = WatchRecent.make(all, nowMs: t) + #expect(recent.wireBytes <= WatchRecent.maxBytes) + for node in WatchNow.Node.allCases { + let back = recent.samples(node) + #expect(back.count > 100) + #expect(abs((back.last?.t ?? 0) - (all[node]?.last?.t ?? 0)) <= 1) + #expect(zip(back, back.dropFirst()).allSatisfy { $0.t < $1.t }) + } + #expect(WatchRecent.make([:], nowMs: t).lanes.isEmpty) + } + + // MARK: What the watch keeps + + func context(home: String = "s1", readAtMs t: Double, prices: Bool = true, plan: Bool = true, now: Bool = true) -> WatchContext { + WatchContext( + home: home, isDemo: false, + prices: prices ? CacheRecord(siteId: home, carrier: .relay, srcState: .live, readAtMs: t, value: self.prices()) : nil, + plan: plan ? CacheRecord(siteId: home, carrier: .relay, srcState: .live, readAtMs: t, value: self.plan()) : nil, + now: now ? self.now(readAtMs: t) : nil, + modes: WatchModes(modes: [.init(key: "planner_self", name: "Plan", isPlan: true)], shown: "planner_self", label: "Plan · Balanced", mayControl: true), + sentAtMs: t + ) + } + + /// A phone that has not read its prices or its plan since it opened + /// sends a context without them: the watch keeps the ones it has. + @Test func aNilRecordKeepsTheOldOne() throws { + let t = at(14, 12) + let kept = context(readAtMs: t) + var next = context(readAtMs: t + 60_000, prices: false, plan: false) + next.modes = nil + let merged = try #require(WatchContext.merging(next, over: kept)) + #expect(merged.prices == kept.prices) + #expect(merged.plan == kept.plan) + #expect(merged.modes == kept.modes) + #expect(merged.now == next.now) + // An older record never takes the place of a newer one. + let late = context(readAtMs: t - 60_000) + #expect(WatchContext.merging(late, over: kept)?.now == kept.now) + #expect(WatchContext.merging(late, over: kept)?.prices == kept.prices) + } + + /// Only an explicit sign-out forgets; a phone still opening its home + /// changes nothing; another home replaces the whole. + @Test func onlyASignOutForgets() throws { + let t = at(14, 12) + let kept = context(readAtMs: t) + #expect(WatchContext.merging(.opening(sentAtMs: t + 1), over: kept) == kept) + #expect(WatchContext.merging(.opening(sentAtMs: t + 1), over: nil) == nil) + let out = try #require(WatchContext.merging(.signOut(sentAtMs: t + 1), over: kept)) + #expect(!out.hasHome && out.signedOut == true && out.prices == nil && out.now == nil) + let other = context(home: "s2", readAtMs: t - 1, prices: false) + #expect(WatchContext.merging(other, over: kept) == other) + // The two differ on the wire. + let opening = try #require(WatchContext.unpacked(try WatchContext.opening(sentAtMs: 1).packed())) + let signedOut = try #require(WatchContext.unpacked(try WatchContext.signOut(sentAtMs: 1).packed())) + #expect(opening.signedOut == nil && signedOut.signedOut == true) + } + + /// The dial keeps the last sun the box forecast until a newer forecast + /// comes; the trace the watch kept stays with the newer of the two. + @Test func theDialKeepsTheLastSunAndTheTraceItsNewest() throws { + let t = at(14, 12) + var kept = context(readAtMs: t) + let sun = PlanEntry.Daylight(covered: .init(fromMs: t, toMs: t + 86_400_000), sun: [.init(fromMs: at(15, 7), toMs: at(15, 17))]) + kept.plan?.value.daylight = sun + kept.recent = WatchRecent.make([.home: samples(from: t - 60_000, to: t)], nowMs: t) + var next = context(readAtMs: t + 900_000) + #expect(WatchContext.merging(next, over: kept)?.plan?.value.daylight == sun) + #expect(WatchContext.merging(next, over: kept)?.recent == kept.recent) + let later = PlanEntry.Daylight(covered: .init(fromMs: t + 1, toMs: t + 86_400_000), sun: []) + next.plan?.value.daylight = later + #expect(WatchContext.merging(next, over: kept)?.plan?.value.daylight == later) + } + + /// The watch's own cache from before this build, without the trace or + /// the sign-out mark, still opens. + @Test func aContextKeptByTheLastBuildStillOpens() throws { + let t = at(14, 12) + var ctx = context(readAtMs: t) + ctx.recent = WatchRecent.make([.home: samples(from: t - 60_000, to: t)], nowMs: t) + let packed = try ctx.packed() + #expect(WatchContext.unpacked(packed) == ctx) + var json = try #require(try JSONSerialization.jsonObject(with: try JSONEncoder().encode(ctx)) as? [String: Any]) + json["recent"] = nil + json["signedOut"] = nil + let old = try (JSONSerialization.data(withJSONObject: json) as NSData).compressed(using: .lzfse) as Data + let back = try #require(WatchContext.unpacked(old)) + #expect(back.recent == nil && back.prices == ctx.prices) + } + // MARK: Words @Test func everyWatchWordHasSwedish() throws { @@ -346,3 +564,15 @@ import Testing } } } + +/// A small fixed generator, so the noisy readings are the same each run. +struct Seeded: RandomNumberGenerator { + var state: UInt64 + init(_ seed: UInt64) { state = seed &* 0x9E37_79B9_7F4A_7C15 | 1 } + mutating func next() -> UInt64 { + state ^= state << 13 + state ^= state >> 7 + state ^= state << 17 + return state + } +} diff --git a/appleApp/FTWWatch/NowPage.swift b/appleApp/FTWWatch/NowPage.swift index 2a482b6..b4261c2 100644 --- a/appleApp/FTWWatch/NowPage.swift +++ b/appleApp/FTWWatch/NowPage.swift @@ -1,72 +1,90 @@ import FTWShared import SwiftUI +import WatchKit /// Now, alive, on one screen: the status line, one node's figure large, -/// its live trace, and the other nodes small. A tap on a small one makes -/// it the traced one. The trace and the figures glow only while the phone -/// passes on live frames; anything else is ink with the minute it was read. -/// How the home is run sits behind the toolbar's button. +/// its trace of the last three minutes, and the other nodes small. A tap +/// on a small one makes it the traced one; a drag across the trace reads +/// it. The trace opens whole, in ink, and glows only while the phone +/// passes on live frames; anything else is ink with the minute it was +/// read. How the home is run sits behind the toolbar's button, by name. struct NowPage: View { let model: WatchModel /// Always On redraws once a minute: a figure lit then would show an old /// reading as live, so nothing glows while the wrist is down. @Environment(\.isLuminanceReduced) private var dim + /// The reading under a finger on the trace. + @State private var held: LiveSample? var body: some View { let lit = model.lit && !dim - let figures = WatchGlance.figures(model.now?.value, lit: lit) - let hero = figures.first { $0.node == model.trace.node } ?? figures.first - let rest = figures.filter { $0.node != hero?.node } + let known = model.now != nil + let figures = WatchGlance.figures(model.now?.value ?? WatchNow.shape, lit: lit) + let traced = figures.first { $0.node == model.trace.node } ?? figures.first + let hero = held.map { WatchGlance.figure(model.trace.node, at: $0) } ?? traced + let rest = figures.filter { $0.node != traced?.node } + let status = WatchGlance.status(model.now, lit: lit, demo: model.context?.isDemo == true, zone: model.zone, receipt: model.lineReceipt) OnePage { - nowStack(lit: lit, figures: figures, hero: hero, rest: rest) + VStack(alignment: .leading, spacing: Self.compact ? 2 : 4) { + StatusLine(status: status) + // Clear of the page dots at the right edge. + .padding(.trailing, StatusLine.clearOfDots) + Group { + if let hero { + HeroFigure(figure: hero, way: held == nil ? model.now?.value.reading(hero.node)?.way ?? .resting : WatchGlance.way(hero.node, signedW: held?.v ?? 0), traceLit: lit) + } + // Whatever height is left, so Now fits 40 mm as well as 46. + TraceView(trace: model.trace, live: lit, zone: model.zone, held: $held) + .frame(minHeight: Self.compact ? 16 : 24, maxHeight: .infinity) + SmallFigures(figures: rest, now: model.now?.value) { model.follow($0) } + } + .redacted(reason: known ? [] : .placeholder) + .disabled(!known) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + .pageSpace() // A home without the traced node (no car, no sun) traces its first. .onChange(of: figures.map(\.node), initial: true) { _, nodes in - if let first = nodes.first, !nodes.contains(model.trace.node) { model.trace.follow(first) } + if known, let first = nodes.first, !nodes.contains(model.trace.node) { model.trace.follow(first) } } - .navigationTitle(WatchWord.nowTitle.text) .toolbar { modeButton } } - private func nowStack(lit: Bool, figures: [WatchGlance.Figure], hero: WatchGlance.Figure?, rest: [WatchGlance.Figure]) -> some View { - VStack(alignment: .leading, spacing: 3) { - StatusLine(status: WatchGlance.status(model.now, lit: lit, demo: model.context?.isDemo == true, zone: model.zone)) - // Clear of the page dots at the right edge. - .padding(.trailing, 10) - if let hero { - HeroFigure(figure: hero, way: model.now?.value.reading(hero.node)?.way ?? .resting) - } - // Whatever height is left, so Now fits 41 mm as well as 46. - TraceView(trace: model.trace, live: lit) - .frame(minHeight: 24, maxHeight: .infinity) - Grid(alignment: .leading, horizontalSpacing: 6, verticalSpacing: 3) { - ForEach(Array(stride(from: 0, to: rest.count, by: 2)), id: \.self) { i in - GridRow { - SmallFigure(figure: rest[i], way: model.now?.value.reading(rest[i].node)?.way ?? .resting) { model.follow(rest[i].node) } - if i + 1 < rest.count { - SmallFigure(figure: rest[i + 1], way: model.now?.value.reading(rest[i + 1].node)?.way ?? .resting) { model.follow(rest[i + 1].node) } - } - } - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } + /// The lowest screens (40 mm, 197 pt): a smaller large figure and + /// tighter gaps, so Now fits without scrolling and the rows keep their + /// 30 pt. + static let compact = WKInterfaceDevice.current().screenBounds.height < 205 @ToolbarContentBuilder private var modeButton: some ToolbarContent { - if let modes = model.context?.modes, modes.label != nil { + if let modes = model.context?.modes, let name = modeName(modes) { ToolbarItem(placement: .topBarLeading) { NavigationLink { ModeList(model: model, modes: modes) } label: { - Image(systemName: model.sending(WatchModel.modeTarget) ? "ellipsis" : "slider.horizontal.3") + Text(name) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink) + .lineLimit(1) + .contentTransition(.opacity) } + .animation(Theme.spring, value: name) .accessibilityLabel(WatchWord.mode.text) - .accessibilityValue(modes.label ?? "") + .accessibilityValue(name) .accessibilityIdentifier("watch.mode") } } } + + /// The mode by name, as the phone's button says it: "Plan" while the + /// planner runs the home, else the manual mode's name. A choice on its + /// way shows at once; the status line carries the box's receipt. + private func modeName(_ modes: WatchModes) -> String? { + var key = modes.shown + if let r = model.receipt(for: WatchModel.modeTarget), !r.settled, case .mode(let asked) = r.op { key = asked } + guard let key, let mode = modes.modes.first(where: { $0.key == key }) else { return modes.label } + return mode.isPlan ? WatchWord.planTitle.text : mode.name + } } /// Each node's glyph, so a small figure needs no word. @@ -82,19 +100,61 @@ enum NodeGlyph { } } -/// The traced node: its glyph, the figure large, and its word. +/// A node's glyph with the way it runs marked beside it in ink: ▲ while +/// the battery stores or the home buys, ▼ while it gives or sells, as the +/// trace draws them. It reads without colour, so a cached figure says it +/// too. The battery's and the grid's mark keeps its room at rest, so their +/// figures do not move when a direction comes or goes. +private struct NodeMark: View { + let node: WatchNow.Node + let direction: WatchGlance.Direction? + let size: CGFloat + let color: Color + /// The short line under the glyph that marks the traced node, in this + /// colour. + var traced: Color? + + var body: some View { + HStack(alignment: .center, spacing: 1) { + Image(systemName: NodeGlyph.name(node)) + .font(.system(size: size, weight: .semibold)) + .foregroundStyle(color) + .overlay(alignment: .bottom) { + if let traced { + Capsule() + .fill(traced) + .frame(width: size, height: 2.5) + .offset(y: size * 0.45) + } + } + if node == .battery || node == .grid { + Image(systemName: direction == .down ? "arrowtriangle.down.fill" : "arrowtriangle.up.fill") + .font(.system(size: size * 0.55, weight: .bold)) + .foregroundStyle(Theme.ink) + .opacity(direction == nil ? 0 : 1) + .contentTransition(.symbolEffect(.replace)) + } + } + .animation(Theme.spring, value: direction) + } +} + +/// The traced node: its glyph, the figure large, and its word. A short line +/// in its hue under the glyph marks it as the one the trace draws; it is +/// lit only while the trace is. private struct HeroFigure: View { let figure: WatchGlance.Figure let way: WatchNow.Way + /// The trace glows, so its mark under the glyph takes the node's hue. + let traceLit: Bool var body: some View { let hue = figure.lit ? Hue.of(figure.node, way: way) : Theme.ink HStack(alignment: .firstTextBaseline, spacing: 3) { - Image(systemName: NodeGlyph.name(figure.node)) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(figure.lit ? hue : Theme.ink2) + NodeMark(node: figure.node, direction: figure.direction, size: 14, color: figure.lit ? hue : Theme.ink2, traced: traceLit ? Hue.of(figure.node, way: way) : Theme.ink2) + .padding(.trailing, 2) Text(figure.value) - .font(Theme.figure(32)) + .font(Theme.figure(NowPage.compact ? 27 : 32)) .foregroundStyle(hue) .contentTransition(.numericText()) .lineLimit(1) @@ -116,22 +176,33 @@ private struct HeroFigure: View { } } -/// Another node, small: glyph and figure. A tap traces it. -private struct SmallFigure: View { - let figure: WatchGlance.Figure - let way: WatchNow.Way - let choose: () -> Void +/// The other nodes, small, two to a row: glyph with its direction, figure +/// and unit. Each row is at least 30 pt tall, a whole half of the width to +/// hit; a tap traces it. +private struct SmallFigures: View { + let figures: [WatchGlance.Figure] + let now: WatchNow? + let choose: (WatchNow.Node) -> Void var body: some View { - let hue = figure.lit ? Hue.of(figure.node, way: way) : Theme.ink - Button(action: choose) { - HStack(alignment: .firstTextBaseline, spacing: 3) { - Image(systemName: NodeGlyph.name(figure.node)) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(figure.lit ? hue : Theme.ink3) - .frame(width: 13) + Grid(alignment: .leading, horizontalSpacing: 6, verticalSpacing: 0) { + ForEach(Array(stride(from: 0, to: figures.count, by: 2)), id: \.self) { i in + GridRow { + small(figures[i]) + if i + 1 < figures.count { small(figures[i + 1]) } + } + } + } + } + + private func small(_ figure: WatchGlance.Figure) -> some View { + let hue = figure.lit ? Hue.of(figure.node, way: now?.reading(figure.node)?.way ?? .resting) : Theme.ink + return Button { choose(figure.node) } label: { + HStack(alignment: .firstTextBaseline, spacing: 4) { + NodeMark(node: figure.node, direction: figure.direction, size: 11, color: figure.lit ? hue : Theme.ink3) + .frame(width: 26, alignment: .leading) Text(figure.value) - .font(Theme.figure(15)) + .font(Theme.figure(16)) .foregroundStyle(hue) .lineLimit(1) .minimumScaleFactor(0.7) @@ -139,7 +210,7 @@ private struct SmallFigure: View { .font(.system(size: 10, weight: .medium)) .foregroundStyle(Theme.ink2) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: 30, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -151,23 +222,102 @@ private struct SmallFigure: View { /// The phone's live trace surface, small: three minutes, glowing only /// while live. No magnitudes in a gutter: the round edge of the screen cuts /// them, and the figure above says the value. +/// +/// A horizontal drag reads it: the window holds still, the large figure +/// gives the reading under the finger, the lock-on marks it with its time, +/// and each reading crossed ticks. A drag that starts up or down is left to +/// the pages. private struct TraceView: View { let trace: WatchTrace let live: Bool + let zone: TimeZone + @Binding var held: LiveSample? + @State private var step = ScrubStep() + @State private var frozenAtMs: Double? + @State private var catchUp: (fromMs: Double, at: Date)? + @State private var across: Bool? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + static let chart = ChartFrame(top: 4, bottom: 2, trailing: 0) + /// A drag this long before it counts as across or along the pages. + static let decideAfter: CGFloat = 6 var body: some View { GeometryReader { geo in - let chart = ChartFrame(top: 4, bottom: 2, trailing: 0) - let plot = chart.plot(in: geo.size) + let plot = Self.chart.plot(in: geo.size) ZStack { Color.clear if let frame = trace.frame { - LiveTraceSurface(frame: frame, look: WatchTrace.look(trace.node), live: live, chartFrame: chart, axisSize: 8, paused: !live) + LiveTraceSurface(frame: frame, look: WatchTrace.look(trace.node), live: live, chartFrame: Self.chart, frozenAtMs: frozenAtMs, catchUp: catchUp, axisSize: 8, paused: !live) + if let held, let i = frame.readings.firstIndex(of: held) { + LockOnMark(frame: frame, index: i, viewMs: viewMs(frame), look: WatchTrace.look(trace.node), zone: zone) + } } } + .contentShape(Rectangle()) + .simultaneousGesture( + DragGesture(minimumDistance: Self.decideAfter) + .onChanged { drag in + if across == nil { across = abs(drag.translation.width) > abs(drag.translation.height) } + guard across == true, let frame = trace.frame else { return } + if frozenAtMs == nil { frozenAtMs = viewMs(frame) } + move(to: drag.location.x, in: frame, size: geo.size) + } + .onEnded { _ in lift() } + ) .onAppear { trace.resize(plot.size) } .onChange(of: plot.size) { _, size in trace.resize(size) } } + .sensoryFeedback(.selection, trigger: step.ticks) + .sensoryFeedback(.impact(weight: .light), trigger: step.bumps) .accessibilityHidden(true) } + + /// The moment the window ends at: held under a finger, else now while + /// live, else the newest reading. + private func viewMs(_ frame: TraceFrame) -> Double { + frozenAtMs ?? (live ? Date.now.timeIntervalSince1970 * 1000 : frame.series.nowMs) + } + + private func move(to x: CGFloat, in frame: TraceFrame, size: CGSize) { + let s = frame.series, plot = Self.chart.plot(in: size) + guard s.ptPerMs > 0 else { return } + let t = viewMs(frame) - Double((plot.width - s.inset - (x - plot.minX)) / s.ptPerMs) + guard let i = SlotGrid.nearest(frame.times, to: t) else { return } + step.move(to: i, landmark: frame.landmarks.contains(i)) + if held != frame.readings[i] { held = frame.readings[i] } + } + + private func lift() { + across = nil + guard held != nil || frozenAtMs != nil else { return } + if let frozenAtMs, live, !reduceMotion { catchUp = (frozenAtMs, .now) } + frozenAtMs = nil + held = nil + step.lift() + } +} + +/// The lock-on over the reading under the finger: the rule, the dot with +/// its brackets, and its time in the inverse-video tag. +private struct LockOnMark: View { + let frame: TraceFrame + let index: Int + let viewMs: Double + let look: TraceLook + let zone: TimeZone + + var body: some View { + Canvas { g, size in + let s = frame.series, r = frame.readings[index] + let plot = TraceView.chart.plot(in: size) + let px = 1 / max(g.environment.displayScale, 1) + let x = ((plot.minX + s.x(r.t) - s.shift(at: viewMs)) / px).rounded() * px + px / 2 + let y = plot.minY + s.map.y(r.v) + LockOn.rule(at: x, plot: plot, px: px, in: &g) + LockOn.dot(at: CGPoint(x: x, y: y), color: r.v >= 0 ? look.up : look.down, in: &g) + LockOn.tag(ZoneClock.of(zone).seconds(r.t), over: x, plot: plot, size: 10, in: &g, canvas: size) + } + .allowsHitTesting(false) + } } diff --git a/appleApp/FTWWatch/Phosphor.swift b/appleApp/FTWWatch/Phosphor.swift index 6427303..dabf697 100644 --- a/appleApp/FTWWatch/Phosphor.swift +++ b/appleApp/FTWWatch/Phosphor.swift @@ -1,5 +1,6 @@ import FTWShared import SwiftUI +import WatchKit // Phosphor on the wrist: light means live. Only a reading the phone just // passed on live may light up; everything else is ink and holds still. @@ -7,21 +8,61 @@ import SwiftUI // 10-cell meter, the receipt log and the lit dot. The block price bars // live on the dial and the price page. -/// The mono status line: `● LIVE · FTW IN CONTROL`, `○ AS OF 14:05`. +/// The mono status line in the watch's short form: `● LIVE · FTW IN +/// CONTROL`, `○ 07:00 · FTW IN CONTROL`, or a command's receipt for its +/// time. One size in every state, set by the screen's width so that the +/// longest ordinary line in the phone's language fits: it never shrinks +/// and never cuts. A warning that runs longer takes a second line rather +/// than lose its words. struct StatusLine: View { let status: WatchGlance.Status var body: some View { - HStack(spacing: 4) { + HStack(alignment: .top, spacing: 3) { LitDot(isLive: status.lit) - Text(status.text) - .font(Theme.mono(.caption2)) - .foregroundStyle(status.alarm ? Theme.alarm : Theme.ink2) - .lineLimit(1) - .minimumScaleFactor(0.7) + .frame(height: Self.size * 1.2) + status.parts.enumerated().reduce(Text(verbatim: "")) { line, item in + let (i, part) = item + return Text("\(line)\(i == 0 ? "" : "\u{00A0}· ")\(Text(verbatim: part.text).foregroundStyle(Self.color(part.tone)))") + } + .font(.system(size: Self.size, weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.ink3) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .contentTransition(.opacity) + .frame(maxWidth: .infinity, alignment: .leading) } - .accessibilityElement(children: .combine) + .animation(Theme.spring, value: status) + .accessibilityElement(children: .ignore) + .accessibilityLabel(status.text) + .accessibilityIdentifier("watch.status") } + + private static func color(_ tone: WatchGlance.Status.Tone) -> Color { + switch tone { + case .ink: return Theme.ink2 + case .live: return Theme.live + case .alarm: return Theme.alarm + } + } + + /// SF Mono's advance, in em, with a hair to spare. + static let advance: CGFloat = 0.605 + /// Room kept clear of the page dots at the right edge: 4–5 pt of gap + /// on 40 mm. + static let clearOfDots: CGFloat = 8 + /// What the line does not get of the screen's width: the page's edges + /// (about 4.5 and 3 pt), the dot and its gap, and `clearOfDots`. + static let inset: CGFloat = 28 + + /// The size that holds `WatchGlance.statusChars` across this screen: + /// 10 pt on 40 mm and 13 pt on 46 mm in English, larger in Swedish, + /// whose words are shorter. + static let size: CGFloat = { + let width = WKInterfaceDevice.current().screenBounds.width + let fit = (width - inset) / (CGFloat(WatchGlance.statusChars) * advance) + return min(13, max(10, (fit * 2).rounded(.down) / 2)) + }() } /// The phosphor dot: lit lime while live, a hollow ink ring otherwise. It @@ -34,12 +75,12 @@ struct LitDot: View { var body: some View { ZStack { - Circle().fill(Theme.live.opacity(isLive ? 0.18 : 0)).frame(width: 11, height: 11) - Circle().fill(Theme.live.opacity(isLive ? 0.35 : 0)).frame(width: 8, height: 8) + Circle().fill(Theme.live.opacity(isLive ? 0.18 : 0)).frame(width: 9.5, height: 9.5) + Circle().fill(Theme.live.opacity(isLive ? 0.35 : 0)).frame(width: 7.5, height: 7.5) Circle().fill(isLive ? Theme.live : .clear).frame(width: 5.5, height: 5.5) Circle().stroke(Theme.ink3, lineWidth: 1).frame(width: 5.5, height: 5.5).opacity(isLive ? 0 : 1) } - .frame(width: 11, height: 11) + .frame(width: 9.5, height: 9.5) .animation(reduceMotion ? nil : (isLive ? Theme.spring : .easeOut(duration: 0.4)), value: isLive) } } diff --git a/appleApp/FTWWatch/WatchCache.swift b/appleApp/FTWWatch/WatchCache.swift index 43d6f85..43eccc5 100644 --- a/appleApp/FTWWatch/WatchCache.swift +++ b/appleApp/FTWWatch/WatchCache.swift @@ -3,8 +3,8 @@ import FTWShared import WidgetKit /// The watch's own copy of the last truth: the whole context it last had, -/// so it opens on it with no phone near, and the records its -/// complications read. Kept in this watch's App Group, which stays on the +/// with the last minutes of Now's trace, so it opens on it with no phone +/// near, and the records its complications read. Kept in this watch's App Group, which stays on the /// watch. Each record keeps the time it was read, so what opens from here /// is dated and never glows. struct WatchCache: Sendable { @@ -19,13 +19,15 @@ struct WatchCache: Sendable { return WatchContext.unpacked(data) } - /// Keep `ctx`. With no home, forget everything: the phone signed out. + /// Keep `ctx`. Only a sign-out forgets everything; a context with no + /// home that does not say so never reaches here (`WatchContext.merging`). func save(_ ctx: WatchContext) async { - guard ctx.hasHome else { + if ctx.signedOut == true { files.removeAll() reload() return } + guard ctx.hasHome else { return } let asked = files.clearCount do { if let r = ctx.prices { _ = try await files.write(r, asked: asked) } diff --git a/appleApp/FTWWatch/WatchModel.swift b/appleApp/FTWWatch/WatchModel.swift index 3f77115..faa1f16 100644 --- a/appleApp/FTWWatch/WatchModel.swift +++ b/appleApp/FTWWatch/WatchModel.swift @@ -26,7 +26,12 @@ final class WatchModel { private(set) var canSend = false /// The receipt of the last command for each target: a charger's id, or /// `modeTarget`. - private(set) var receipts: [String: WatchReceipt] = [:] + private(set) var receipts: [String: WatchReceipt] = [:] { + didSet { if receipts[Self.modeTarget] != oldValue[Self.modeTarget] { showModeReceipt() } } + } + /// The mode's receipt while it takes Now's status line: from the tap + /// until `WatchGlance.receiptShownMs` after the box answered. + private(set) var lineReceipt: WatchReceipt? static let modeTarget = "mode" @@ -51,6 +56,7 @@ final class WatchModel { /// a context or the cache, which never glows. @ObservationIgnored private var nowArrivedAtMs: Double? @ObservationIgnored private var cooling: Task? + @ObservationIgnored private var lineClearing: Task? private var nowMs: Double { Date.now.timeIntervalSince1970 * 1000 } @@ -71,7 +77,10 @@ final class WatchModel { /// Open on the last truth, then listen. func start() { - if let kept = cache?.load() { take(kept, keep: false) } + if let kept = cache?.load().map(Self.forPictures) { + if let recent = kept.recent { trace.backfill(recent) } + take(kept, keep: false) + } read.start { [weak self] in self?.handle($0) } // The watch's own key, made once, for the day it reaches the box // without the phone. @@ -82,21 +91,36 @@ final class WatchModel { func look() { read.look() } - /// Off screen: keep the newest frame as the last truth, dated. + /// Off screen: keep the newest frame and the trace's last minutes as + /// the last truth, dated, for the next open. func away() { read.away() - if var ctx = context, let now, now.readAtMs > (ctx.now?.readAtMs ?? 0) { - ctx.now = now - context = ctx - keep(ctx) - } + guard var ctx = context, ctx.hasHome else { return } + if let now, now.readAtMs > (ctx.now?.readAtMs ?? 0) { ctx.now = now } + ctx.recent = trace.recent() ?? ctx.recent + context = ctx + keep(ctx) } private func handle(_ event: PathEvent) { + #if DEBUG + if Self.noReading { + switch event { + case .news(.now), .news(.recent): return + case .context(let ctx): return take(Self.forPictures(ctx), keep: false) + default: break + } + } + #endif switch event { case .context(let ctx): take(ctx, keep: true) case .news(.now(let record)): frame(record) case .news(.receipt(let r)): receipt(r) + case .news(.recent(let r)): + guard let ctx = context, ctx.hasHome else { return } + trace.backfill(r) + // Kept at once, so an open after a kill has these minutes too. + keep(ctx) case .canSend(let ok): canSend = ok case .notSent(let id): guard let hit = receipts.first(where: { $0.value.commandID == id }), hit.value.sent == .active else { return } @@ -106,8 +130,34 @@ final class WatchModel { // MARK: The context - private func take(_ ctx: WatchContext, keep: Bool) { + /// `-FTWWatchNoReading YES` (debug builds) keeps Now's readings away, + /// for pictures of the shape Now shows before its first one. + private static var noReading: Bool { + #if DEBUG + UserDefaults.standard.bool(forKey: "FTWWatchNoReading") + #else + false + #endif + } + + private static func forPictures(_ ctx: WatchContext) -> WatchContext { + guard noReading else { return ctx } + var out = ctx + out.now = nil + out.recent = nil + return out + } + + /// A context from a path or the cache. Only a sign-out forgets; a + /// record the phone did not send keeps the one the watch has. + private func take(_ next: WatchContext, keep: Bool) { + guard let ctx = WatchContext.merging(next, over: context) else { return } if let old = context, old.sameAs(ctx) { return } + if !ctx.hasHome { + trace.clear() + now = nil + nowArrivedAtMs = nil + } context = ctx // A live frame newer than the context's copy keeps its place. if nowArrivedAtMs == nil || (ctx.now?.readAtMs ?? 0) > (now?.readAtMs ?? 0) { @@ -121,9 +171,13 @@ final class WatchModel { if keep { self.keep(ctx) } } + /// Keep `ctx` with the trace as it stands, so an open after a kill + /// still has its last minutes. private func keep(_ ctx: WatchContext) { guard let cache else { return } - Task { await cache.save(ctx) } + var ctx = ctx + if ctx.hasHome { ctx.recent = trace.recent() ?? ctx.recent } + Task { [ctx] in await cache.save(ctx) } } private func frame(_ record: CacheRecord) { @@ -139,7 +193,10 @@ final class WatchModel { private func relight() { lit = WatchGlance.lit(now, arrivedAtMs: nowArrivedAtMs, nowMs: nowMs) cooling?.cancel() - guard lit else { return } + guard lit else { + trace.settle() + return + } cooling = Task { [weak self] in try? await Task.sleep(for: .milliseconds(Int(WatchGlance.liveForMs) + 50)) guard !Task.isCancelled else { return } @@ -188,4 +245,17 @@ final class WatchModel { guard let mine = receipts[target], let next = mine.taking(r) else { return } receipts[target] = next } + + /// The mode's receipt on Now's line while the box answers, and for a + /// moment after: one wait, no ticking timer. + private func showModeReceipt() { + lineClearing?.cancel() + lineReceipt = receipts[Self.modeTarget] + guard let r = lineReceipt, r.settled else { return } + lineClearing = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(Int(WatchGlance.receiptShownMs))) + guard !Task.isCancelled else { return } + self?.lineReceipt = nil + } + } } diff --git a/appleApp/FTWWatch/WatchRoot.swift b/appleApp/FTWWatch/WatchRoot.swift index aaef3f0..954c741 100644 --- a/appleApp/FTWWatch/WatchRoot.swift +++ b/appleApp/FTWWatch/WatchRoot.swift @@ -4,6 +4,8 @@ import SwiftUI /// Four pages, one idea each, each on one screen with nothing to scroll: /// Now alive, the plan, the car and the price. The Crown turns the pages, /// except on the plan, where it steps the hand; a swipe goes on from there. +/// Each page takes the space rule (`pageSpace`): no title, and the screen +/// down to its corner. struct WatchRoot: View { let model: WatchModel @State private var page: Page = WatchRoot.firstPage @@ -31,6 +33,20 @@ struct WatchRoot: View { static var firstPage: Page { UserDefaults.standard.string(forKey: "FTWWatchPage").flatMap(Page.init(rawValue:)) ?? .now } + + /// What a page leaves free at the screen's foot for its rounded corner. + static let cornerRoom: CGFloat = 8 +} + +extension View { + /// The space rule for every page. No navigation title: the clock stays + /// and the content starts under it. The content runs into the bottom + /// safe area and stops `WatchRoot.cornerRoom` short of the edge, as the + /// system's own apps do. Now has it; Plan, Car and Price take it next. + func pageSpace() -> some View { + padding(.bottom, WatchRoot.cornerRoom) + .ignoresSafeArea(.container, edges: .bottom) + } } /// Before the phone has handed over a home, or after it signed out. @@ -54,12 +70,15 @@ private struct NoHome: View { } } -/// The box's modes, as the phone last read them. A choice asks once more -/// and then goes through the phone; the receipt stays under the list. +/// The box's modes, as the phone last read them. A choice asks once more, +/// goes through the phone and returns to Now, whose status line carries +/// the box's receipt for its time; the receipt also stays under the list. struct ModeList: View { let model: WatchModel let modes: WatchModes @State private var asking: WatchModes.Mode? + @State private var sent = false + @Environment(\.dismiss) private var dismiss var body: some View { List { @@ -94,9 +113,12 @@ struct ModeList: View { } } .navigationTitle(WatchWord.mode.text) - .sheet(item: $asking) { m in + .sheet(item: $asking, onDismiss: { + if sent { dismiss() } + }) { m in Confirm(question: WatchWord.modeAsk.format(m.name), action: m.name) { model.send(.mode(m.key)) + sent = true } } } diff --git a/appleApp/FTWWatch/WatchTrace.swift b/appleApp/FTWWatch/WatchTrace.swift index 7757070..8146ae1 100644 --- a/appleApp/FTWWatch/WatchTrace.swift +++ b/appleApp/FTWWatch/WatchTrace.swift @@ -3,21 +3,32 @@ import Observation import SwiftUI /// Now's live trace on the wrist: the phone's node view trace (`LiveSeries`, -/// drawn in `TraceInk`), for one node at a time, from the frames the phone -/// passes on while the watch looks. A reading counts as seen only when its +/// drawn in `TraceInk`), for one node at a time. It opens whole: the phone +/// sends its last three minutes with the reply to a look, and the watch +/// keeps them for its next open. A reading counts as seen only when its /// frame came live, so the line glows only then and is ink otherwise. /// +/// While live the window ends now and scrolls. Otherwise it ends at the +/// newest reading and holds still, so a cached open shows the three +/// minutes it last knew, dated by the status line. +/// /// Built once per frame or change of node or size; the view only scrolls /// and strokes it. @Observable @MainActor final class WatchTrace { /// Three minutes: about one point of width per second on a watch. - static let windowMs: Double = 3 * 60_000 + static let windowMs: Double = WatchRecent.windowMs + /// A reading from the phone this close to one the watch already has is + /// the same reading, stamped on its way. + static let sameMs: Double = 900 private(set) var frame: TraceFrame? /// The node the trace follows. private(set) var node: WatchNow.Node + /// Whether the newest frame came live: the window then follows the + /// clock. + private(set) var live = false @ObservationIgnored private var samples: [WatchNow.Node: [LiveSample]] = [:] @ObservationIgnored private var size: CGSize = .zero @@ -28,19 +39,56 @@ final class WatchTrace { /// One frame from the phone. `lit` says whether it came live just now. func add(_ record: CacheRecord, lit: Bool) { - let from = record.readAtMs - Self.windowMs for r in record.value.readings { guard let v = r.signedW else { continue } var run = samples[r.node, default: []] // A frame the trace already has, or an older one, adds nothing. if let last = run.last, last.t >= record.readAtMs { continue } run.append(LiveSample(t: record.readAtMs, v: v, seen: lit && r.live)) - if let cut = run.firstIndex(where: { $0.t >= from }), cut > 1 { run.removeFirst(cut - 1) } - samples[r.node] = run + samples[r.node] = Self.cut(run, to: record.readAtMs) + } + live = lit + rebuild(keepScale: true) + } + + /// The last minutes the phone recorded, or this watch kept, in ink. A + /// reading the trace already has keeps its place, so what this watch saw + /// live stays seen. + func backfill(_ recent: WatchRecent) { + for lane in recent.lanes { + let own = samples[lane.node] ?? [] + let times = own.map(\.t) + let fill = lane.samples.filter { s in + guard let i = SlotGrid.nearest(times, to: s.t) else { return true } + return abs(times[i] - s.t) > Self.sameMs + } + guard !fill.isEmpty else { continue } + let run = (own + fill).sorted { $0.t < $1.t } + samples[lane.node] = Self.cut(run, to: run.last?.t ?? 0) } + rebuild(keepScale: false) + } + + /// The live frames have stopped: the window holds at the newest reading. + func settle() { + guard live else { return } + live = false rebuild(keepScale: true) } + /// The phone signed out: nothing of that home stays. + func clear() { + samples = [:] + live = false + frame = nil + } + + /// The last minutes of every node, for the watch's own cache. + func recent() -> WatchRecent? { + guard let newest = newestMs else { return nil } + return WatchRecent.make(samples, nowMs: newest) + } + func follow(_ node: WatchNow.Node) { guard node != self.node else { return } self.node = node @@ -53,14 +101,24 @@ final class WatchTrace { rebuild(keepScale: false) } + private var newestMs: Double? { samples.values.compactMap { $0.last?.t }.max() } + + /// Keep the window and the reading before it, so the line runs off the + /// left edge. + private static func cut(_ run: [LiveSample], to endMs: Double) -> [LiveSample] { + guard let first = run.firstIndex(where: { $0.t >= endMs - windowMs }), first > 1 else { return run } + return Array(run[(first - 1)...]) + } + private func rebuild(keepScale: Bool) { guard size.width > 0 else { return } let now = Date() + let end = live ? now.timeIntervalSince1970 * 1000 : (newestMs ?? now.timeIntervalSince1970 * 1000) let series = LiveSeries.build( samples[node] ?? [], sourceGapMs: WatchGlance.liveForMs, windowMs: Self.windowMs, - nowMs: now.timeIntervalSince1970 * 1000, + nowMs: end, size: size, previous: keepScale ? frame?.series.domain : nil, floor: node.floorW diff --git a/appleApp/FTWWatchUITests/WatchTour.swift b/appleApp/FTWWatchUITests/WatchTour.swift index 4a30a35..b1f8618 100644 --- a/appleApp/FTWWatchUITests/WatchTour.swift +++ b/appleApp/FTWWatchUITests/WatchTour.swift @@ -114,4 +114,62 @@ final class WatchTour: XCTestCase { sleep(5) shoot("plan") } + + /// A change of mode from the toolbar's button returns to Now, whose + /// status line carries the box's receipt for its time. + func testModeReceiptOnNow() throws { + let app = launch("now") + let mode = app.buttons["watch.mode"].firstMatch + XCTAssertTrue(mode.waitForExistence(timeout: 30)) + sleep(4) + shoot("mode-before") + mode.tap() + let idle = app.buttons["watch.mode.idle"] + XCTAssertTrue(idle.waitForExistence(timeout: 5)) + if !idle.isHittable { app.swipeUp() } + idle.tap() + let yes = app.buttons["watch.confirm"] + XCTAssertTrue(yes.waitForExistence(timeout: 5)) + yes.tap() + // Back on Now, with no tap of the person's own. + let line = app.descendants(matching: .any)["watch.status"] + XCTAssertTrue(line.waitForExistence(timeout: 5)) + XCTAssertTrue(mode.waitForExistence(timeout: 5)) + shoot("mode-receipt-now") + sleep(2) + shoot("mode-receipt-later") + // Put the home back on the plan for the next test. + mode.tap() + let plan = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'watch.mode.planner_'")).firstMatch + if plan.waitForExistence(timeout: 5) { + if !plan.isHittable { app.swipeDown() } + plan.tap() + if yes.waitForExistence(timeout: 5) { yes.tap() } + } + sleep(6) + } + + /// A drag across the trace reads it and leaves the page where it is; a + /// drag up the screen still turns the page. On the simulator, with + /// XCUITest's synthesised touches; a real wrist is another check. + func testDragAcrossTheTraceKeepsThePage() throws { + let app = launch("now") + let line = app.descendants(matching: .any)["watch.status"] + XCTAssertTrue(line.waitForExistence(timeout: 30)) + sleep(6) + let window = app.windows.firstMatch + let from = window.coordinate(withNormalizedOffset: CGVector(dx: 0.85, dy: 0.55)) + let to = window.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: 0.56)) + from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 2.5) + sleep(1) + // Still on Now: the status line and the hero are on screen. + XCTAssertTrue(line.isHittable, "a drag across the trace turned the page") + XCTAssertTrue(app.descendants(matching: .any)["watch.now.hero"].exists) + shoot("drag-across-after") + // A swipe up from the middle of the screen, which is the trace on + // 40 and 46 mm, still turns the page after a read. + app.swipeUp() + XCTAssertTrue(app.otherElements["watch.plan.dial"].waitForExistence(timeout: 5), "a swipe up from the trace did not turn the page") + shoot("drag-up-pages") + } } From 22d89ca142e397c793b35311284ab7a8d675d594 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 18:49:31 +0200 Subject: [PATCH 2/2] Apple Watch: Now stays lit with the phone in a pocket (U3) While a watch looks, the phone's stream runs as for a screen: a watchers count in SiteModel holds 1 Hz and FTW Cloud's looking, and the phone going to the background closes nothing until the look ends. The watch's glow lasts as long as the phone counts a reading live (WatchNow.standsMs) and 2.5 of the stream's own intervals plus the hop, at least 4 s, so a frame at 1 Hz, 5 s or one late never makes Now blink, and a real silence still goes dark. The watch renews its look with the frames every 20 s and once when it cools, and the phone takes its time in the background anew with each renewal. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Watch/WatchBridge.swift | 38 ++++++++--- .../Sources/FTWKit/State/AppModel.swift | 7 ++- .../Sources/FTWKit/State/SiteModel.swift | 34 +++++++++- .../Sources/FTWKit/State/WatchShare.swift | 12 ++-- .../Sources/FTWShared/Watch/WatchGlance.swift | 53 ++++++++++++++-- .../Sources/FTWShared/Watch/WatchWire.swift | 8 ++- .../Tests/FTWKitTests/WatchShareTests.swift | 40 ++++++++++++ .../Tests/FTWSharedTests/WatchTests.swift | 63 +++++++++++++++++++ appleApp/FTWWatch/WatchModel.swift | 46 +++++++++++--- 9 files changed, 271 insertions(+), 30 deletions(-) diff --git a/appleApp/FTW/Watch/WatchBridge.swift b/appleApp/FTW/Watch/WatchBridge.swift index 25b37f4..83cd3ce 100644 --- a/appleApp/FTW/Watch/WatchBridge.swift +++ b/appleApp/FTW/Watch/WatchBridge.swift @@ -46,9 +46,18 @@ final class WatchBridge: NSObject { /// readings followed. private var look: Task? private var lookTimer: Task? - /// The watch says it still looks each time it comes to the front. Past - /// this without word, the session is let go. + /// The time iOS gives this app in the background for a look, taken anew + /// with each word from the watch. + private var lookTime: BackgroundTime? + /// The watch says it still looks each time it comes to the front, and + /// every 20 s while it stays there. Past this without word, the session + /// is let go. private static let lookMs: Double = 120_000 + /// When Now last went to the watch. + private var forwardedAtMs: Double = 0 + /// A look asked again after this long without Now gets the last three + /// minutes again: the watch missed them. + private static let missedMs: Double = 10_000 private var nowMs: Double { Date.now.timeIntervalSince1970 * 1000 } @@ -131,19 +140,24 @@ final class WatchBridge: NSObject { private func startLooking() { send() + // Each word from the watch takes the time in the background anew + // before the last runs out, so a steady look with the phone locked + // never gaps. + let time = BackgroundTime("FTW watch looks") { [weak self] in self?.stopLooking() } + lookTime?.end() + lookTime = time // Asked again while it looks: keep the session, and only the clock - // that lets it go starts over. The watch was away meanwhile, so it - // gets the last three minutes again. - if look != nil, let home = app?.home { + // that lets it go starts over. A watch that went without Now + // meanwhile gets the last three minutes again. + if look != nil, nowMs - forwardedAtMs > Self.missedMs, let home = app?.home { send(news: .recent(WatchShare.recent(home, nowMs: nowMs))) } if look == nil, let runner { look = Task { [weak self] in - let time = BackgroundTime("FTW watch looks") { self?.stopLooking() } - defer { time.end() } _ = await runner.hold { home in await self?.forwardNow(home) } + self?.lookEnded() } } lookTimer?.cancel() @@ -161,6 +175,14 @@ final class WatchBridge: NSObject { lookTimer = nil } + /// The look's session is let go and suspended: the time in the + /// background goes too, unless a new look has begun. + private func lookEnded() { + guard look == nil else { return } + lookTime?.end() + lookTime = nil + } + /// Now's readings to the watch as each frame lands, until the watch /// looks away, stops being reachable, or says nothing for `lookMs`. private func forwardNow(_ home: HomeModels) async { @@ -177,6 +199,7 @@ final class WatchBridge: NSObject { // opens whole, then Now as it stands. send(news: .recent(WatchShare.recent(home, nowMs: nowMs))) send(news: WatchShare.now(home).map(WatchNews.now)) + forwardedAtMs = nowMs // Ends when the look is let go; a watch out of reach meanwhile gets // nothing, and its reachability ends the look. for await _ in frames.stream { @@ -184,6 +207,7 @@ final class WatchBridge: NSObject { guard let record = WatchShare.now(home), record.readAtMs != lastAt else { continue } lastAt = record.readAtMs send(news: .now(record)) + forwardedAtMs = nowMs } Self.log.info("watch looked away: Now no longer sent") } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index ff26698..f51c80b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -266,9 +266,12 @@ public final class AppModel { } /// The app went to the background: close the session and keep what is - /// on screen. The demo has no socket to free and keeps running. + /// on screen. The demo has no socket to free and keeps running. While an + /// Apple Watch looks through this phone nothing closes; the look's end + /// calls this again. public func suspend() { - guard let site else { return } + // A watch looking keeps the session; the end of its look suspends. + guard let site, !site.watched else { return } if isDemo { site.setVisible(false) } else { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index a491f29..52c8624 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -56,6 +56,10 @@ public final class SiteModel { /// every H. @ObservationIgnored public private(set) var readingGapMs: Double = LiveTrace.gapMs public private(set) var documentVisible = true + /// The app itself is on screen; `documentVisible` also counts a watch. + @ObservationIgnored private var appVisible = true + /// Apple Watches looking at Now through this phone. + @ObservationIgnored private var watchers = 0 /// The clock screens read for ages. It moves with every reading, and /// between readings only when something on screen would change: the /// minute, a stream going quiet, a grade lapsing, or a counter the band @@ -285,11 +289,35 @@ public final class SiteModel { persistNow(wait: true) } - /// Match the stream's cadence to whether anyone can see it. + /// Match the stream's cadence to whether anyone can see it: this app on + /// screen, or an Apple Watch looking through it. public func setVisible(_ visible: Bool) { - guard visible else { + appVisible = visible + if !visible { persistNow() } + follow() + } + + /// An Apple Watch looks at Now through this phone. While one does, the + /// stream runs as it does with the app on screen, whatever the phone's + /// own state: 1 Hz from the box, looking on FTW Cloud, and reads as for + /// a screen. Pair each call with `endWatching`. + public func beginWatching() { + watchers += 1 + follow() + } + + public func endWatching() { + watchers = max(0, watchers - 1) + follow() + } + + /// A watch looks: the session stays open while the app is in the + /// background, until the look ends. + public var watched: Bool { watchers > 0 } + + private func follow() { + guard appVisible || watchers > 0 else { hide() - persistNow() return } let was = documentVisible diff --git a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift index 9412768..cc857ad 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift @@ -35,10 +35,13 @@ public enum WatchShare { /// The models the watch's pages show, read as if a screen showing them /// had appeared: the prices, the chargers, the plan with the forecast - /// that gives the dial its sun, and the mode. Call the returned closure - /// when the watch looks away. + /// that gives the dial its sun, and the mode. The stream runs as for a + /// screen meanwhile, at 1 Hz, even with the phone locked in a pocket, so + /// the wrist sees what the app would. Call the returned closure when the + /// watch looks away. public static func looking(_ home: HomeModels) -> @MainActor () -> Void { - let (prices, chargers, plan, mode) = (home.prices, home.loadpoints, home.nodes.plan, home.nodes.mode) + let (site, prices, chargers, plan, mode) = (home.site, home.prices, home.loadpoints, home.nodes.plan, home.nodes.mode) + site.beginWatching() prices.activate() chargers.activate() plan.activate(forecast: true) @@ -48,6 +51,7 @@ public enum WatchShare { chargers.deactivate() plan.deactivate(forecast: true) mode.deactivate() + site.endWatching() } } @@ -128,7 +132,7 @@ public enum WatchShare { case .warning: control = .notInControl case .neutral, .stale: control = .unknown } - return CacheRecord(siteId: key ?? homeKey(home), carrier: site.carrier, srcState: site.isLive ? .live : .stale, readAtMs: at, value: WatchNow(readings: readings, control: control)) + return CacheRecord(siteId: key ?? homeKey(home), carrier: site.carrier, srcState: site.isLive ? .live : .stale, readAtMs: at, value: WatchNow(readings: readings, control: control, standsMs: site.readingGapMs)) } static func reading(_ r: NodeReading) -> WatchNow.Reading { diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift index 51978be..b65a3c8 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift @@ -8,8 +8,9 @@ import Foundation /// live carrier with its own source live. Everything read from the /// context or the watch's cache is ink, with the time it was read. public enum WatchGlance { - /// How long a forwarded frame stays lit after it arrived: the relay's - /// gap rule (3.5 s), with room for the hop from the phone. + /// The least time a forwarded frame stays lit after it arrived: the + /// relay's gap rule (3.5 s), with room for the hop from the phone. + /// `Glow` lengthens it to the stream's own pace. public static let liveForMs: Double = 4_000 /// How long a complication shows the battery's level after the phone /// read it. Past this the level may have moved, and the centre is @@ -20,11 +21,53 @@ public enum WatchGlance { /// Whether Now is live at `nowMs`. `arrivedAtMs` is when the frame /// reached the watch, by the watch's clock; nil for a record from the - /// context or the cache, which never glows. - public static func lit(_ record: CacheRecord?, arrivedAtMs: Double?, nowMs: Double) -> Bool { + /// context or the cache, which never glows. `forMs` is how long a frame + /// stays lit, from `Glow`. + public static func lit(_ record: CacheRecord?, arrivedAtMs: Double?, forMs: Double = liveForMs, nowMs: Double) -> Bool { guard let record, let arrivedAtMs else { return false } guard record.carrier.connected, record.srcState == .live else { return false } - return nowMs - arrivedAtMs >= -1_000 && nowMs - arrivedAtMs <= liveForMs + return nowMs - arrivedAtMs >= -1_000 && nowMs - arrivedAtMs <= forMs + } + + /// How long a live frame stays lit on the wrist: as long as the phone + /// itself counts it live (`WatchNow.standsMs`), and at least about 2.5 + /// of the stream's last intervals, each with the hop from the phone, + /// never less than `liveForMs`. A stream at 1 Hz stays lit through a + /// late frame; one at 0.2 Hz from a phone in a pocket does not blink + /// between frames. A real silence still goes dark: the interval counted + /// is at most `longestMs`, so without the phone's word no frame stays + /// lit longer than `longestForMs`. + public struct Glow: Equatable, Sendable { + /// The phone's own pace, from the times it read the last two frames. + public private(set) var intervalMs: Double? + /// The phone's own word for how long the last frame stands. + public private(set) var standsMs: Double? + private var lastReadAtMs: Double? + + public static let hopMs: Double = 1_000 + public static let longestMs: Double = 6_000 + public static var longestForMs: Double { 2.5 * longestMs + hopMs } + + public init() {} + + /// A live frame, read by the phone at `readAtMs`. + public mutating func frame(readAtMs: Double, standsMs: Double? = nil) { + if let last = lastReadAtMs, readAtMs > last { intervalMs = min(readAtMs - last, Self.longestMs) } + lastReadAtMs = max(readAtMs, lastReadAtMs ?? readAtMs) + self.standsMs = standsMs + } + + /// The stream stopped, or the watch looked away: the next frame + /// starts a new pace. + public mutating func reset() { + self = Glow() + } + + public var forMs: Double { + let pace = intervalMs.map { 2.5 * $0 + Self.hopMs } ?? 0 + let phone = standsMs.map { $0 + Self.hopMs } ?? 0 + return max(WatchGlance.liveForMs, pace, phone) + } } /// One figure on Now. diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift index 4eec2d2..f4a6375 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift @@ -80,10 +80,16 @@ public struct WatchNow: CacheValue { public var readings: [Reading] public var control: Control + /// How long the phone itself counts this reading live with no newer + /// one: its stream's gap rule (3.5 s at 1 Hz, longer for a slower + /// stream or FTW Cloud). The watch glows no longer than this and the + /// hop. Nil from a phone before 0.4.4. + public var standsMs: Double? - public init(readings: [Reading], control: Control) { + public init(readings: [Reading], control: Control, standsMs: Double? = nil) { self.readings = readings self.control = control + self.standsMs = standsMs } public func reading(_ node: Node) -> Reading? { readings.first { $0.node == node } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift index d6df35d..d883506 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/WatchShareTests.swift @@ -153,4 +153,44 @@ import FTWShared #expect(WatchContext.merging(opening, over: kept) == kept) #expect(WatchContext.merging(out, over: kept)?.hasHome == false) } + + /// A watch looking through a phone locked in a pocket gets Now at 1 Hz, + /// not the 0.2 Hz a hidden app asks for; the phone going to the + /// background meanwhile closes nothing; the end of the look lets the + /// phone rest again. + @Test func aLookingWatchKeepsTheStreamAtOneHertz() async throws { + let rig = Rig() + _ = try rig.start() + let home = try #require(rig.app.home) + let site = home.site + final class Frames { var at = Set() } + func frames(over ms: Double) async -> Int { + let seen = Frames() + let token = site.observe { if let t = site.lastFrameAtMs { seen.at.insert(t) } } + await rig.run(ms) + site.unobserve(token) + return seen.at.count + } + await rig.run(3_000) + rig.app.setVisible(false) + rig.app.suspend() + await rig.run(6_000) + #expect(await frames(over: 20_000) <= 6) + + let done = WatchShare.looking(home) + #expect(site.documentVisible && site.watched) + await rig.run(3_000) + #expect(await frames(over: 20_000) >= 17) + rig.app.setVisible(false) + rig.app.suspend() + #expect(site.documentVisible) + #expect(await frames(over: 10_000) >= 8) + // The phone's word for how long a reading stands goes with it. + #expect(WatchShare.now(home)?.value.standsMs == LiveTrace.gapMs) + + done() + #expect(!site.documentVisible && !site.watched) + await rig.run(6_000) + #expect(await frames(over: 20_000) <= 6) + } } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift index 0532790..c482953 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift @@ -170,6 +170,69 @@ import Testing #expect(WatchGlance.lit(now(carrier: .cloud, readAtMs: t), arrivedAtMs: t, nowMs: t)) } + /// The watch's rule, step by step: each frame lights Now from its + /// arrival for the glow's time. Counts the 50 ms moments that were dark + /// between the first frame and the last. + func darkMoments(reads: [Double], hop: (Int) -> Double, standsMs: Double? = nil) -> Int { + var glow = WatchGlance.Glow() + var lit: [(at: Double, forMs: Double)] = [] + for (i, read) in reads.enumerated() { + glow.frame(readAtMs: read, standsMs: standsMs) + lit.append((read + hop(i), glow.forMs)) + } + var dark = 0, k = 0 + var t = lit[0].at + while t <= lit[lit.count - 1].at { + while k + 1 < lit.count, lit[k + 1].at <= t { k += 1 } + if t - lit[k].at > lit[k].forMs { dark += 1 } + t += 50 + } + return dark + } + + /// A phone at 1 Hz, at 0.2 Hz from a pocket, or with one frame late, + /// never makes Now blink: frames that keep their pace keep the light. + @Test func aSteadyStreamNeverBlinks() { + var g = Seeded(3) + let jitter = (0..<200).map { _ in Double(Int.random(in: 50...700, using: &g)) } + let everySecond = (0..<120).map { Double($0) * 1_000 } + #expect(darkMoments(reads: everySecond, hop: { jitter[$0] }, standsMs: 3_500) == 0) + #expect(darkMoments(reads: everySecond, hop: { jitter[$0] }) == 0) + // A phone in a pocket streams every 5 s, and says so. + let everyFive = (0..<30).map { Double($0) * 5_000 } + #expect(darkMoments(reads: everyFive, hop: { jitter[$0] }, standsMs: 17_500) == 0) + // One frame 2.5 s late at 1 Hz, and one 6 s late at 0.2 Hz. + let late = everySecond.enumerated().map { $0.offset == 40 ? $0.element + 2_500 : $0.element } + #expect(darkMoments(reads: late, hop: { jitter[$0] }, standsMs: 3_500) == 0) + // From a phone that does not say, the pace alone holds 2 s late. + let lateBare = everySecond.enumerated().map { $0.offset == 40 ? $0.element + 2_000 : $0.element } + #expect(darkMoments(reads: lateBare, hop: { jitter[$0] }) == 0) + let lateFive = everyFive.enumerated().map { $0.offset == 12 ? $0.element + 6_000 : $0.element } + #expect(darkMoments(reads: lateFive, hop: { jitter[$0] }, standsMs: 17_500) == 0) + } + + /// Never fake live: a real silence goes dark, after the phone's own + /// time for a reading and the hop, and never later than that. + @Test func aRealSilenceGoesDark() { + var glow = WatchGlance.Glow() + for i in 0..<10 { glow.frame(readAtMs: Double(i) * 1_000, standsMs: 3_500) } + #expect(glow.forMs == 4_500) + // Ten seconds of nothing is dark. + #expect(darkMoments(reads: [0, 1_000, 2_000, 12_000], hop: { _ in 300 }, standsMs: 3_500) > 100) + // Without the phone's word the pace alone never holds a frame past + // the longest a hidden stream waits. + var bare = WatchGlance.Glow() + bare.frame(readAtMs: 0) + #expect(bare.forMs == WatchGlance.liveForMs) + bare.frame(readAtMs: 60_000) + #expect(bare.forMs == WatchGlance.Glow.longestForMs) + bare.reset() + #expect(bare.forMs == WatchGlance.liveForMs) + let frame = now(readAtMs: 0) + #expect(WatchGlance.lit(frame, arrivedAtMs: 0, forMs: 4_500, nowMs: 4_400)) + #expect(!WatchGlance.lit(frame, arrivedAtMs: 0, forMs: 4_500, nowMs: 4_600)) + } + @Test func aFigureGlowsOnlyWhenItsOwnReadingIsLive() { let figures = WatchGlance.figures(now(readAtMs: 0).value, lit: true) #expect(figures.map(\.lit) == [true, true, false]) diff --git a/appleApp/FTWWatch/WatchModel.swift b/appleApp/FTWWatch/WatchModel.swift index faa1f16..70b5f42 100644 --- a/appleApp/FTWWatch/WatchModel.swift +++ b/appleApp/FTWWatch/WatchModel.swift @@ -20,7 +20,8 @@ final class WatchModel { private(set) var context: WatchContext? /// Now: the latest live frame, or the context's copy. private(set) var now: CacheRecord? - /// Whether Now glows. Off `WatchGlance.liveForMs` after the last frame. + /// Whether Now glows: on with each live frame, off once the stream's + /// own pace has passed with none (`WatchGlance.Glow`). private(set) var lit = false /// Commands can leave now. private(set) var canSend = false @@ -55,7 +56,11 @@ final class WatchModel { /// When the latest live frame reached this watch. Nil for a copy from /// a context or the cache, which never glows. @ObservationIgnored private var nowArrivedAtMs: Double? + @ObservationIgnored private var glow = WatchGlance.Glow() @ObservationIgnored private var cooling: Task? + /// On screen, so the phone should be sending Now. + @ObservationIgnored private var looking = false + @ObservationIgnored private var askedAtMs: Double = 0 @ObservationIgnored private var lineClearing: Task? private var nowMs: Double { Date.now.timeIntervalSince1970 * 1000 } @@ -89,11 +94,21 @@ final class WatchModel { } } - func look() { read.look() } + /// How often a steady look tells the phone it still looks, well inside + /// the phone's own time in the background and its 2 minutes. + static let renewMs: Double = 20_000 + + func look() { + looking = true + askedAtMs = nowMs + read.look() + } /// Off screen: keep the newest frame and the trace's last minutes as /// the last truth, dated, for the next open. func away() { + looking = false + glow.reset() read.away() guard var ctx = context, ctx.hasHome else { return } if let now, now.readAtMs > (ctx.now?.readAtMs ?? 0) { ctx.now = now } @@ -182,28 +197,43 @@ final class WatchModel { private func frame(_ record: CacheRecord) { guard context == nil || record.siteId == context?.home else { return } + glow.frame(readAtMs: record.readAtMs, standsMs: record.value.standsMs) now = record nowArrivedAtMs = nowMs relight() trace.add(record, lit: lit) + // A steady look renews itself with the frames, so the phone never + // lets it go while the wrist is up. + if looking, nowMs - askedAtMs > Self.renewMs { look() } } - /// Glow while the last frame is fresh, then cool once: one wake per - /// frame, no ticking timer. + /// Glow while the last frame is fresh for the stream's pace, then cool + /// once: one wake per frame, no ticking timer. private func relight() { - lit = WatchGlance.lit(now, arrivedAtMs: nowArrivedAtMs, nowMs: nowMs) + let forMs = glow.forMs + lit = WatchGlance.lit(now, arrivedAtMs: nowArrivedAtMs, forMs: forMs, nowMs: nowMs) cooling?.cancel() - guard lit else { + guard lit, let arrived = nowArrivedAtMs else { trace.settle() return } + let left = max(0, arrived + forMs - nowMs) cooling = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(Int(WatchGlance.liveForMs) + 50)) + try? await Task.sleep(for: .milliseconds(Int(left) + 50)) guard !Task.isCancelled else { return } - self?.relight() + self?.cooled() } } + /// No frame came in the stream's own time: dark, and one more ask in + /// case the phone let the look go. + private func cooled() { + relight() + guard !lit else { return } + glow.reset() + if looking { look() } + } + // MARK: Commands static func target(_ op: WatchCommand.Op) -> String {