From f2eb4bcdba0f6b531e669dee1e7abf13cf811bb6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 17:56:36 +0200 Subject: [PATCH 1/4] Price widgets: the price now, the cheapest two hours next, and a grey end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four families read the App Group cache and nothing else: small (also the StandBy widget), medium with a twelve-hour curve, and the lock screen's rectangle and inline line. Prices show on the lock screen. - PriceGlance (FTWShared) builds the timeline: one entry now and one at every price boundary the box sent, at the box's resolution. Where the prices stop, the widget turns grey and says "No prices after Tue 24:00 · open FTW". It never stretches or repeats old prices. - The total says what it holds. The wire carries spot and total but not the settings between them, so a line through the two tells a grid fee from VAT. - Freshness stays two fields: every entry is carrier .cache with the srcState the app saw when it fetched. - SiteModel logs each write with os.Logger and gains refreshSharedPrices. The app asks for a BGAppRefreshTask just after 13:05, then every half hour until the box has tomorrow. No silent push, no server. - Swedish and English live in FTWShared; a test checks that every widget sentence has Swedish. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW-Info.plist | 8 + appleApp/FTW/FTWApp.swift | 50 ++- .../Sources/FTWKit/State/SiteModel.swift | 70 +++++ appleApp/FTWKit/Sources/FTWShared/Cache.swift | 5 + .../Sources/FTWShared/PriceGlance.swift | 296 ++++++++++++++++++ .../Sources/FTWShared/PriceRefresh.swift | 35 +++ .../Resources/sv.lproj/Localizable.strings | 17 + appleApp/FTWKit/Sources/FTWShared/Words.swift | 10 +- .../Tests/FTWKitTests/WidgetPricesTests.swift | 60 ++++ .../FTWSharedTests/PriceGlanceTests.swift | 266 ++++++++++++++++ .../FTWSharedTests/PriceRefreshTests.swift | 40 +++ appleApp/FTWWidgets/PriceViews.swift | 227 ++++++++++++++ appleApp/FTWWidgets/PriceWidget.swift | 120 +++---- 13 files changed, 1123 insertions(+), 81 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift create mode 100644 appleApp/FTWWidgets/PriceViews.swift diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index 03bab63..0c887b4 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -4,6 +4,14 @@ FTWAppGroup $(FTW_APP_GROUP) + BGTaskSchedulerPermittedIdentifiers + + energy.ftw.app.prices + + UIBackgroundModes + + fetch + CFBundleLocalizations en diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index b7fc0de..f35afc0 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -1,5 +1,7 @@ +import BackgroundTasks import FTWDemo import FTWKit +import os import SwiftUI import WidgetKit @@ -50,9 +52,55 @@ struct FTWApp: App { // switcher: slow the stream, keep the session. Only the // background closes it. app.setVisible(phase == .active) - if phase == .background { app.suspend() } + if phase == .background { + app.suspend() + PricesInBackground.schedule(app) + } } } + .backgroundTask(.appRefresh(PricesInBackground.id)) { [app] in + await PricesInBackground.run(app) + } + } +} + +/// Tomorrow's prices for the widgets without opening the app. iOS wakes the +/// app shortly after 13:05 if it allows it at all; the app opens a session, +/// asks for prices, leaves them in the App Group and closes again. No silent +/// push and no server: when iOS does not wake the app, the widgets wait for +/// the next time it is opened. +@MainActor +enum PricesInBackground { + /// Listed under BGTaskSchedulerPermittedIdentifiers in FTW-Info.plist. + static let id = "energy.ftw.app.prices" + static let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + + static func schedule(_ app: AppModel) { + guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + let nowMs = Date.now.timeIntervalSince1970 * 1000 + let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: site.sharedPricesEndMs) / 1000) + let request = BGAppRefreshTaskRequest(identifier: id) + request.earliestBeginDate = at + do { + try BGTaskScheduler.shared.submit(request) + log.info("background prices asked for after \(at.formatted(date: .abbreviated, time: .shortened), privacy: .public)") + } catch { + // The simulator, Background App Refresh turned off, or Low Power + // Mode. The widgets then wait for the app to be opened. + log.notice("background prices not scheduled: \(error.localizedDescription, privacy: .public)") + } + } + + static func run(_ app: AppModel) async { + defer { schedule(app) } + guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + log.info("background prices: woken") + // A new carrier if the last one was closed, or a fresh stream. + await app.refresh() + let prices = await site.refreshSharedPrices() + log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)") + // Someone may have opened the app meanwhile; their session stays. + if UIApplication.shared.applicationState == .background { app.suspend() } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 62eab3e..78489ba 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1,6 +1,9 @@ import Foundation import FTWShared import Observation +import os + +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") /// The bridge between the session and the screens. /// @@ -406,10 +409,77 @@ public final class SiteModel { guard !destroyed else { return } do { try shared.write(record) + log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)") sharedWritten?() } catch { // A widget showing yesterday's record with its date is better // than no app; the next read tries again. + log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)") + } + } + + /// Where the prices the widgets hold run out. Nil when they hold none. + public var sharedPricesEndMs: Double? { + guard let siteId, let record = shared?.read(PriceEntry.self, siteId: siteId) else { return nil } + return record.value.prices.slots.map { $0.startMs + $0.durationMs }.max() + } + + /// The background refresh: wait for the stream, ask for today's and + /// tomorrow's prices as the price card does, and leave them for the + /// widgets. Returns what was written, or nil when nothing was: no + /// stream within `waitMs`, a box without prices, or a failed ask. + /// + /// The caller builds the carrier and closes it after. + @discardableResult + public func refreshSharedPrices(calendar: Calendar = .current, waitMs: Double = 20_000) async -> Prices? { + guard siteId != nil, shared != nil, !destroyed else { return nil } + guard await streaming(within: waitMs) else { + log.notice("background prices: no stream within \(Int(waitMs / 1000), privacy: .public) s") + return nil + } + guard session.caps.contains(Contract.capPriceSpot) else { + log.notice("background prices: this box offers no prices") + return nil + } + let from = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)).timeIntervalSince1970 * 1000 + do { + let wire = try await prices(fromMs: from, toMs: from + PriceModel.horizonMs) + sharePrices(wire, fromMs: from) + return wire + } catch { + log.error("background prices: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + /// True once the session streams, false if it has not within `ms`. + private func streaming(within ms: Double) async -> Bool { + if session.phase == .streaming { return true } + let wait = StreamWait() + let ok = await withCheckedContinuation { cont in + wait.continuation = cont + wait.token = observe { [weak self, wait] in + if self?.session.phase == .streaming { wait.settle(true) } + } + wait.timer = scheduler.after(ms) { [wait] in wait.settle(false) } } + if let token = wait.token { unobserve(token) } + return ok + } +} + +/// One wait for the stream, settled once. The timer settles it even if the +/// model is gone, so the background task always ends. +@MainActor +private final class StreamWait { + var continuation: CheckedContinuation? + var token: Int? + var timer: Cancellable? + + func settle(_ ok: Bool) { + guard let cont = continuation else { return } + continuation = nil + timer?.cancel() + cont.resume(returning: ok) } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift index 4966a37..61221b1 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Cache.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -64,6 +64,11 @@ public struct CacheRead: Sendable { public let record: CacheRecord public let origin: CacheOrigin + public init(record: CacheRecord, origin: CacheOrigin) { + self.record = record + self.origin = origin + } + public var value: Value { record.value } /// Always `.cache`. A file on this phone is not a connection to the box. public var carrier: CarrierKind { .cache } diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift new file mode 100644 index 0000000..d5e6c15 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift @@ -0,0 +1,296 @@ +import Foundation + +/// What a price widget shows at one moment, read from the cached prices and +/// nothing else. +/// +/// A widget cannot ask the box anything, so its timeline is worked out in +/// advance: one moment now and one at every price boundary the box sent, +/// at whatever resolution the box sent it. Where the prices stop, the last +/// moment says so. It never stretches the last price over hours the market +/// has not priced, and never starts the day over with yesterday's. +public enum PriceGlance { + /// How far ahead the medium widget's curve reaches. + public static let curveMs: Double = 12 * 3_600_000 + /// The stretch "cheapest" looks for, as on the app's price card. + public static let blockHours: Double = 2 + + /// What the box's total holds besides the market price. The wire has + /// spot and total but not the settings between them, so this is read off + /// the two figures: Core charges (spot + grid fee) × (1 + VAT). + public enum Included: Equatable, Sendable { + case gridFeeAndVAT, vat, gridFee + /// The total is the market price and nothing else. + case spotOnly + /// Something is added, but the market barely moved, so a fee cannot + /// be told from a percentage. + case surcharges + } + + public enum State: Equatable, Sendable { + /// The app has left nothing: not paired, or signed out. + case nothing + /// A price covers this moment. + case price + /// No price covers this moment, and the next one starts at `nextMs`. + case gap(nextMs: Double) + /// No price covers this moment or anything after it. + case ended(lastMs: Double) + } + + public struct Moment: Equatable, Sendable { + public let atMs: Double + public let state: State + /// `.cache` whenever there is a record: a file on this phone is not a + /// connection to the box. `.none` when there is nothing. + public let carrier: CarrierKind + /// As it was when the app fetched the prices. Never better. + public let srcState: SourceState + /// Wall clock on the phone when the app fetched the prices. + public let readAtMs: Double? + public let currency: String + public let current: PriceSlot? + public let cheapest: PriceStrip.Block? + /// Slots from the current one to `curveMs` ahead. + public let curve: [PriceStrip.Bar] + public let meanMinor: Double? + public let included: Included + + /// "42.3", the price now. Nil when no price covers this moment. + public let priceText: String? + /// "öre/kWh" + public let unitText: String + /// "öre", for one short line. + public let unitLabel: String + /// "incl. grid fee and VAT" + public let includedText: String + /// "Cheapest 02–04" + public let cheapestText: String? + /// Why there is no price: "No prices after 24:00 · open FTW". + public let emptyText: String? + /// "Fetched 13:05" + public let fetchedText: String? + + /// Whether the widget should go grey: no price for this moment. + public var isEmpty: Bool { state != .price } + + /// One line for the lock screen's inline slot. + public var inlineText: String { + guard let priceText else { return emptyText ?? "" } + let price = "\(priceText) \(unitLabel)" + return cheapestText.map { "\(price) · \($0)" } ?? price + } + } + + /// The timeline: one moment now and one at every boundary ahead, the + /// last where the prices end. + public static func moments(_ read: CacheRead?, nowMs: Double, calendar: Calendar = .current) -> [Moment] { + guard let read else { return [moment(nil, atMs: nowMs, calendar: calendar)] } + let slots = usable(read.value.prices) + var edges = Set() + for s in slots { + edges.insert(s.startMs) + edges.insert(s.startMs + s.durationMs) + } + let ahead = edges.filter { $0 > nowMs }.sorted() + return ([nowMs] + ahead).map { moment(read, atMs: $0, calendar: calendar) } + } + + /// The widget at one moment. + public static func moment(_ read: CacheRead?, atMs t: Double, calendar: Calendar = .current) -> Moment { + guard let read else { + return Moment( + atMs: t, state: .nothing, carrier: .none, srcState: .never, readAtMs: nil, + currency: "", current: nil, cheapest: nil, curve: [], meanMinor: nil, included: .spotOnly, + priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, + emptyText: GlanceWord.openToSee.text, fetchedText: nil + ) + } + let prices = read.value.prices + let slots = usable(prices) + let unit = PriceUnits.unit(prices.currency) + let current = slots.first { t >= $0.startMs && t < $0.startMs + $0.durationMs } + let included = included(slots) + + let state: State + if current != nil { + state = .price + } else if let next = slots.first(where: { $0.startMs > t }) { + state = .gap(nextMs: next.startMs) + } else { + state = .ended(lastMs: slots.last.map { $0.startMs + $0.durationMs } ?? read.value.fromMs) + } + + var cheapest: PriceStrip.Block? + var curve: [PriceStrip.Bar] = [] + var mean: Double? + if current != nil { + let summary = PriceStrip.summary(prices, nowMs: t) + cheapest = PriceStrip.bestBlock(slots.filter { $0.startMs + $0.durationMs > t }, hours: blockHours, cheapest: true) + curve = summary.bars.filter { $0.startMs < t + curveMs } + mean = summary.meanMinor + } + + let clock = Clock(calendar: calendar) + var emptyText: String? + switch state { + case .nothing, .price: emptyText = nil + case .gap(let next): emptyText = GlanceWord.noPriceUntil.format(clock.at(next, asEnd: false, from: t)) + case .ended(let last): + // A box that sent no slots at all has no last hour to name. + emptyText = slots.isEmpty ? GlanceWord.openToSee.text : GlanceWord.noPricesAfter.format(clock.at(last, asEnd: true, from: t)) + } + + return Moment( + atMs: t, + state: state, + carrier: read.carrier, + srcState: read.srcState, + readAtMs: read.record.readAtMs, + currency: prices.currency, + current: current, + cheapest: cheapest, + curve: curve, + meanMinor: mean, + included: included, + priceText: current.map { PriceStrip.text($0.totalMinor, prices.currency) }, + unitText: unit.perKwh, + unitLabel: unit.label, + includedText: text(included), + cheapestText: cheapest.map { clock.cheapest($0, from: t) }, + emptyText: emptyText, + fetchedText: GlanceWord.fetched.format(clock.at(read.record.readAtMs, asEnd: false, from: t)) + ) + } + + /// What the total holds, from a straight line through (spot, total). + /// Rounding to whole minor units blurs each point by half a unit, so the + /// line needs some spread in spot before its slope means anything. + public static func included(_ slots: [PriceSlot]) -> Included { + guard !slots.isEmpty, slots.contains(where: { abs($0.totalMinor - $0.spotMinor) >= 1 }) else { return .spotOnly } + let spots = slots.map(\.spotMinor) + guard let lo = spots.min(), let hi = spots.max(), hi - lo >= 4 else { return .surcharges } + let n = Double(slots.count) + let mx = spots.reduce(0, +) / n + let my = slots.map(\.totalMinor).reduce(0, +) / n + var sxx = 0.0, sxy = 0.0 + for s in slots { + sxx += (s.spotMinor - mx) * (s.spotMinor - mx) + sxy += (s.spotMinor - mx) * (s.totalMinor - my) + } + let slope = sxy / sxx + guard slope.isFinite, slope > 0 else { return .surcharges } + let fee = (my - slope * mx) / slope + switch (fee >= 1, slope > 1.03) { + case (true, true): return .gridFeeAndVAT + case (false, true): return .vat + case (true, false): return .gridFee + case (false, false): return .surcharges + } + } + + public static func text(_ included: Included) -> String { + switch included { + case .gridFeeAndVAT: return GlanceWord.inclGridFeeAndVAT.text + case .vat: return GlanceWord.inclVAT.text + case .gridFee: return GlanceWord.inclGridFee.text + case .spotOnly: return GlanceWord.spotOnly.text + case .surcharges: return GlanceWord.inclSurcharges.text + } + } + + /// The widget's name and line in the gallery. + public static var title: String { GlanceWord.title.text } + public static var summary: String { GlanceWord.summary.text } + public static var priceNow: String { GlanceWord.priceNow.text } + + static func usable(_ prices: Prices) -> [PriceSlot] { + prices.slots.filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } + } +} + +/// Every sentence a price widget shows. The English is the key, and the +/// Swedish lives in `Resources/sv.lproj`; a test checks each one has it. +enum GlanceWord: String, CaseIterable { + case title = "Electricity price" + case summary = "The price now and when it is cheapest next, from what FTW last fetched." + case priceNow = "Price now" + case cheapest = "Cheapest %@" + case cheapestNow = "Cheapest now until %@" + case tomorrow = "tomorrow %@" + case noPricesAfter = "No prices after %@ · open FTW" + case noPriceUntil = "No price until %@ · open FTW" + case openToSee = "Open FTW to see prices" + case fetched = "Fetched %@" + case inclGridFeeAndVAT = "incl. grid fee and VAT" + case inclVAT = "incl. VAT" + case inclGridFee = "incl. grid fee" + case spotOnly = "spot price only" + case inclSurcharges = "incl. surcharges" + + var text: String { Words.text(rawValue) } + + func format(_ arg: String) -> String { + String(format: text, arg) + } +} + +/// Times on the household's clock, 24-hour, as the box's own page writes +/// them. An end at midnight reads 24:00 of the day it ends, not 00:00 of the +/// next. A time on another day than the moment names that day, because a +/// widget can sit unread for days. +struct Clock { + let calendar: Calendar + + struct Wall { + let day: Date + let hour: Int + let minute: Int + + var hm: String { String(format: "%02d:%02d", hour, minute) } + } + + func wall(_ ms: Double, asEnd: Bool) -> Wall { + let date = Date(timeIntervalSince1970: ms / 1000) + let c = calendar.dateComponents([.hour, .minute], from: date) + let day = calendar.startOfDay(for: date) + if asEnd, c.hour == 0, c.minute == 0, let before = calendar.date(byAdding: .day, value: -1, to: day) { + return Wall(day: before, hour: 24, minute: 0) + } + return Wall(day: day, hour: c.hour ?? 0, minute: c.minute ?? 0) + } + + func day(_ ms: Double) -> Date { + calendar.startOfDay(for: Date(timeIntervalSince1970: ms / 1000)) + } + + /// "13:05", or "Tue 24:00" when that is not the moment's day. + func at(_ ms: Double, asEnd: Bool, from t: Double) -> String { + let w = wall(ms, asEnd: asEnd) + guard w.day != day(t) else { return w.hm } + var named = calendar + named.locale = Words.locale + let weekday = named.component(.weekday, from: w.day) + return "\(named.shortWeekdaySymbols[weekday - 1]) \(w.hm)" + } + + /// "Cheapest 02–04", "Cheapest now until 04", "Cheapest tomorrow 15–17". + /// + /// Tomorrow is named only when the clock alone would read as today: at + /// 20:00, "02–04" can only be tonight, but at 14:00 "15–17" looks like + /// this afternoon. + func cheapest(_ block: PriceStrip.Block, from t: Double) -> String { + let a = wall(block.startMs, asEnd: false) + let b = wall(block.endMs, asEnd: true) + let whole = a.minute == 0 && b.minute == 0 + func short(_ w: Wall) -> String { whole ? String(format: "%02d", w.hour) : w.hm } + if block.startMs <= t { + return GlanceWord.cheapestNow.format(short(b)) + } + var range = "\(short(a))–\(short(b))" + let now = wall(t, asEnd: false) + if a.day > now.day, (a.hour, a.minute) > (now.hour, now.minute) { + range = GlanceWord.tomorrow.format(range) + } + return GlanceWord.cheapest.format(range) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift b/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift new file mode 100644 index 0000000..12dd383 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift @@ -0,0 +1,35 @@ +import Foundation + +/// When the app asks iOS to wake it for tomorrow's prices. +/// +/// The day-ahead market publishes tomorrow around 12:45, and the box asks +/// it once an hour, so the box holds tomorrow some time between then and +/// about 13:45. The app asks to run just after 13:05, and again every half +/// hour until the box has tomorrow. iOS decides when, or whether, it runs: +/// this is the earliest, never a promise. +public enum PriceRefresh { + public static let hour = 13 + public static let minute = 5 + /// How soon to ask again when the box did not have tomorrow yet. + public static let retryMs: Double = 30 * 60_000 + /// No retries after this hour; the next try is tomorrow's. + public static let lastRetryHour = 20 + + /// The earliest the next refresh should run, given when the prices the + /// widgets hold run out. + public static func next(afterMs nowMs: Double, pricesEndMs: Double?, calendar: Calendar = .current) -> Double { + let now = Date(timeIntervalSince1970: nowMs / 1000) + let today = calendar.startOfDay(for: now) + func at(_ day: Int, _ h: Int, _ m: Int) -> Double { + let d = calendar.date(byAdding: .day, value: day, to: today) ?? today + let t = calendar.date(bySettingHour: h, minute: m, second: 0, of: d) ?? d + return t.timeIntervalSince1970 * 1000 + } + let publish = at(0, hour, minute) + if nowMs < publish { return publish } + let endOfTomorrow = at(2, 0, 0) + if let end = pricesEndMs, end >= endOfTomorrow { return at(1, hour, minute) } + let retry = nowMs + retryMs + return retry <= at(0, lastRetryHour, 0) ? retry : at(1, hour, minute) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 8d2de29..3f30126 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -26,3 +26,20 @@ "%ld min ago" = "för %ld min sedan"; "%ld h ago" = "för %ld h sedan"; "%ld d ago" = "för %ld d sedan"; + +/* Price widgets */ +"Electricity price" = "Elpris"; +"The price now and when it is cheapest next, from what FTW last fetched." = "Priset nu och när det blir billigast härnäst, från det FTW senast hämtade."; +"Price now" = "Pris nu"; +"Cheapest %@" = "Billigast %@"; +"Cheapest now until %@" = "Billigast nu till %@"; +"tomorrow %@" = "i morgon %@"; +"No prices after %@ · open FTW" = "Inga priser efter %@ · öppna FTW"; +"No price until %@ · open FTW" = "Inget pris förrän %@ · öppna FTW"; +"Open FTW to see prices" = "Öppna FTW för att se priser"; +"Fetched %@" = "Hämtat %@"; +"incl. grid fee and VAT" = "inkl. nätavgift och moms"; +"incl. VAT" = "inkl. moms"; +"incl. grid fee" = "inkl. nätavgift"; +"spot price only" = "endast spotpris"; +"incl. surcharges" = "inkl. påslag"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Words.swift b/appleApp/FTWKit/Sources/FTWShared/Words.swift index 4e011c6..2cb7302 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Words.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Words.swift @@ -7,11 +7,19 @@ import Foundation /// the widget list both languages in `CFBundleLocalizations`; without that, /// iOS shows a package in the app's development language only. enum Words { + static var bundle: Bundle { .module } + static func text(_ key: String) -> String { - Bundle.module.localizedString(forKey: key, value: key, table: nil) + bundle.localizedString(forKey: key, value: key, table: nil) } static func format(_ key: String, _ args: CVarArg...) -> String { String(format: text(key), arguments: args) } + + /// The language the words resolve to, for the names the system spells, + /// such as weekdays, so a Swedish sentence never names an English day. + static var locale: Locale { + Locale(identifier: Bundle.module.preferredLocalizations.first ?? "en") + } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift new file mode 100644 index 0000000..4466e09 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift @@ -0,0 +1,60 @@ +import Foundation +import FTWDemo +import FTWShared +import Testing +@testable import FTWKit + +/// The background refresh that leaves tomorrow's prices for the widgets. +@MainActor +@Suite struct WidgetPricesTests { + let scheduler = ManualScheduler() + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + + func site(wakes: Counter) -> SiteModel { + let site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: nil, shared: shared) + site.sharedWritten = { wakes.count += 1 } + site.start() + return site + } + + func run(_ ms: Double, step: Double = 20) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + @Test func aBackgroundRefreshLeavesPricesAndWakesTheWidgets() async throws { + let wakes = Counter() + let site = site(wakes: wakes) + site.connect(SimulatedBox.loopback(scheduler)) + let asked = scheduler.nowMs + let task = Task { await site.refreshSharedPrices() } + await run(2_000) + let written = try #require(await task.value) + #expect(!written.slots.isEmpty) + + let read = try #require(CacheReader(sources: [shared]).read(PriceEntry.self, siteId: "home")) + #expect(read.value.prices == written) + #expect(read.carrier == .cache) + #expect(read.record.readAtMs >= asked && read.record.readAtMs <= scheduler.nowMs) + #expect(wakes.count == 1) + #expect(site.sharedPricesEndMs == written.slots.map { $0.startMs + $0.durationMs }.max()) + } + + /// No stream, no write: the widgets keep what they had, and the task + /// ends on time rather than holding the wake. + @Test func withoutAStreamItGivesUpAndWritesNothing() async { + let wakes = Counter() + let site = site(wakes: wakes) + let task = Task { await site.refreshSharedPrices(waitMs: 5_000) } + await run(5_500, step: 250) + #expect(await task.value == nil) + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(wakes.count == 0) + #expect(site.sharedPricesEndMs == nil) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift new file mode 100644 index 0000000..0e03bf1 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift @@ -0,0 +1,266 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The price widgets' timeline, worked out from the cache alone. Times are +/// Stockholm's; 12 October 2026 is a Monday. These read English: the tests +/// run in the development language. +@Suite struct PriceGlanceTests { + static let hourMs: Double = 3_600_000 + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + /// Wall clock in Stockholm, as ms. + func at(_ day: Int, _ hour: Int, _ minute: Int = 0, month: Int = 10) -> Double { + calendar.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + /// Slots back to back from `fromMs`, each `dtMs` long, priced by index. + func slots(from fromMs: Double, count: Int, dtMs: Double = hourMs, total: (Int) -> Double = { _ in 50 }) -> [PriceSlot] { + (0.. CacheRead { + let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) + let record = CacheRecord(siteId: "home", carrier: carrier, srcState: srcState, readAtMs: readAtMs, value: PriceEntry(fromMs: slots.first?.startMs ?? 0, prices: prices)) + return CacheRead(record: record, origin: .app) + } + + // MARK: The timeline + + @Test func oneMomentNowAndOneAtEveryHourTheBoxPriced() { + let fetched = at(12, 13, 5) + let r = read(slots(from: at(12, 0), count: 48), readAtMs: fetched) + let moments = PriceGlance.moments(r, nowMs: at(12, 13, 30), calendar: calendar) + #expect(moments.first?.atMs == at(12, 13, 30)) + #expect(moments.count == 1 + 35) + let steps = zip(moments.dropFirst(), moments.dropFirst(2)).map { $1.atMs - $0.atMs } + #expect(steps.allSatisfy { $0 == Self.hourMs }) + #expect(moments.last?.atMs == at(14, 0)) + } + + /// Quarter-hour prices give a moment every quarter hour, and a box that + /// mixes resolutions is followed slot by slot. + @Test func theTimelineFollowsTheResolutionTheBoxSent() { + let quarter = Self.hourMs / 4 + let today = slots(from: at(12, 0), count: 24) + let tomorrow = slots(from: at(13, 0), count: 96, dtMs: quarter) + let moments = PriceGlance.moments(read(today + tomorrow, readAtMs: at(12, 13, 5)), nowMs: at(12, 22, 30), calendar: calendar) + let times = moments.map(\.atMs) + #expect(times.prefix(3) == [at(12, 22, 30), at(12, 23), at(13, 0)]) + #expect(times.contains(at(13, 0, 15))) + let tail = zip(times.dropFirst(2), times.dropFirst(3)).map { $1 - $0 } + #expect(tail.allSatisfy { $0 == quarter }) + #expect(moments.last?.state == .ended(lastMs: at(14, 0))) + } + + @Test func eachMomentShowsTheSlotItFallsIn() { + let r = read(slots(from: at(12, 0), count: 24) { Double(10 + $0) }, readAtMs: at(12, 6)) + for m in PriceGlance.moments(r, nowMs: at(12, 9, 40), calendar: calendar) where m.state == .price { + let hour = calendar.component(.hour, from: Date(timeIntervalSince1970: m.atMs / 1000)) + #expect(m.current?.totalMinor == Double(10 + hour)) + #expect(m.priceText == PriceStrip.text(Double(10 + hour), "SEK")) + } + } + + // MARK: When the prices run out + + @Test func theLastMomentSaysWhenThePricesRanOut() throws { + let r = read(slots(from: at(12, 0), count: 18), readAtMs: at(12, 8)) + let last = try #require(PriceGlance.moments(r, nowMs: at(12, 9), calendar: calendar).last) + #expect(last.atMs == at(12, 18)) + #expect(last.state == .ended(lastMs: at(12, 18))) + #expect(last.isEmpty) + #expect(last.priceText == nil) + #expect(last.cheapestText == nil) + #expect(last.curve.isEmpty) + #expect(last.emptyText == "No prices after 18:00 · open FTW") + #expect(last.inlineText == "No prices after 18:00 · open FTW") + } + + /// Nothing after the end is priced: not the last hour stretched, not + /// yesterday's day started over. + @Test func noMomentAfterTheEndShowsAPrice() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + for h in 0..<72 { + let m = PriceGlance.moment(r, atMs: at(14, 0) + Double(h) * Self.hourMs + 1, calendar: calendar) + #expect(m.state == .ended(lastMs: at(14, 0))) + #expect(m.current == nil && m.priceText == nil && m.curve.isEmpty) + } + } + + /// The proof case: fetched Monday just after the market published, and + /// not opened since. Thirty-six hours on, the widget is grey and says + /// which midnight the prices ended at. + @Test func thirtySixHoursLaterTheWidgetIsGrey() throws { + let fetched = at(12, 13, 5) + let r = read(slots(from: at(12, 0), count: 48), readAtMs: fetched) + let later = fetched + 36 * Self.hourMs + let moments = PriceGlance.moments(r, nowMs: later, calendar: calendar) + #expect(moments.count == 1) + let m = try #require(moments.first) + #expect(m.state == .ended(lastMs: at(14, 0))) + #expect(m.emptyText == "No prices after Tue 24:00 · open FTW") + #expect(m.fetchedText == "Fetched Mon 13:05") + #expect(m.carrier == .cache) + } + + /// An end at midnight is 24:00 of the day it closes, also on the day the + /// clocks go back and the day has 25 hours. + @Test func midnightReadsAsTheEndOfTheDayAcrossTheClockChange() throws { + let day = at(25, 0) + let next = at(26, 0) + #expect(next - day == 25 * Self.hourMs) + let r = read(slots(from: day, count: 25), readAtMs: day) + let last = try #require(PriceGlance.moments(r, nowMs: at(25, 20), calendar: calendar).last) + #expect(last.atMs == next) + #expect(last.emptyText == "No prices after Sun 24:00 · open FTW") + #expect(PriceGlance.moment(r, atMs: at(25, 22, 30), calendar: calendar).cheapestText == "Cheapest now until 24") + } + + /// One failed fetch at midday leaves a hole. The widget says so for as + /// long as it lasts and picks the prices up again after it. + @Test func aHoleIsSaidNotFilled() { + let morning = slots(from: at(12, 0), count: 6) + let afternoon = slots(from: at(12, 12), count: 12) + let moments = PriceGlance.moments(read(morning + afternoon, readAtMs: at(12, 5)), nowMs: at(12, 5, 30), calendar: calendar) + let gap = moments.first { $0.atMs == at(12, 6) } + #expect(gap?.state == .gap(nextMs: at(12, 12))) + #expect(gap?.emptyText == "No price until 12:00 · open FTW") + #expect(gap?.priceText == nil) + #expect(!moments.contains { $0.atMs > at(12, 6) && $0.atMs < at(12, 12) }) + #expect(moments.first { $0.atMs == at(12, 12) }?.state == .price) + } + + @Test func beforeTheFirstPriceTheWidgetWaitsForIt() { + let r = read(slots(from: at(12, 6), count: 18), readAtMs: at(12, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 5), calendar: calendar) + #expect(m.state == .gap(nextMs: at(12, 6))) + #expect(m.emptyText == "No price until 06:00 · open FTW") + } + + // MARK: Cheapest next + + /// A day that is dear until midnight and cheapest from 02 to 04. + func night(dtMs: Double = hourMs, cheapFrom: Double, cheapTo: Double) -> [PriceSlot] { + let start = at(12, 0) + let count = Int((at(14, 0) - start) / dtMs) + return slots(from: start, count: count, dtMs: dtMs) { i in + let t = start + Double(i) * dtMs + return t >= cheapFrom && t < cheapTo ? 5 : 80 + Double(i % 7) + } + } + + @Test func cheapestNextNamesTheTwoHoursAhead() { + let r = read(night(cheapFrom: at(13, 2), cheapTo: at(13, 4)), readAtMs: at(12, 13, 5)) + let evening = PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar) + #expect(evening.cheapest?.startMs == at(13, 2)) + #expect(evening.cheapestText == "Cheapest 02–04") + #expect(evening.inlineText == "\(evening.priceText!) öre · Cheapest 02–04") + // Inside the block it is now, and says until when. + #expect(PriceGlance.moment(r, atMs: at(13, 2, 30), calendar: calendar).cheapestText == "Cheapest now until 04") + } + + @Test func cheapestFollowsQuarterHours() { + let quarter = Self.hourMs / 4 + let r = read(night(dtMs: quarter, cheapFrom: at(13, 2, 15), cheapTo: at(13, 4, 15)), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar).cheapestText == "Cheapest 02:15–04:15") + } + + /// At 14:00, "15–17" would read as this afternoon. Tomorrow's is named. + @Test func tomorrowIsNamedOnlyWhenTheClockWouldMislead() { + let r = read(night(cheapFrom: at(13, 15), cheapTo: at(13, 17)), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestText == "Cheapest tomorrow 15–17") + // The same block seen the next morning is today's. + #expect(PriceGlance.moment(r, atMs: at(13, 9), calendar: calendar).cheapestText == "Cheapest 15–17") + } + + @Test func noCheapestWhenLessThanTwoHoursArePriced() { + let r = read(slots(from: at(12, 0), count: 24), readAtMs: at(12, 8)) + #expect(PriceGlance.moment(r, atMs: at(12, 23, 30), calendar: calendar).cheapestText == nil) + #expect(PriceGlance.moment(r, atMs: at(12, 22, 30), calendar: calendar).cheapestText == "Cheapest now until 24") + } + + @Test func theCurveReachesTwelveHoursAhead() throws { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 13, 30), calendar: calendar) + #expect(m.curve.first?.startMs == at(12, 13)) + #expect(m.curve.first?.current == true) + #expect(m.curve.count == 13) + #expect(try #require(m.curve.last).startMs < at(12, 13, 30) + PriceGlance.curveMs) + } + + // MARK: What the total includes + + /// Core's formula, rounded once on the wire as Core rounds it. + func core(spots: [Double], fee: Double, vat: Double) -> [PriceSlot] { + spots.enumerated().map { i, spot in + PriceSlot(startMs: Double(i) * Self.hourMs, durationMs: Self.hourMs, spotMinor: spot.rounded(), totalMinor: ((spot + fee) * (1 + vat / 100)).rounded()) + } + } + + @Test func theTotalSaysWhatItIncludes() { + let day = (0..<24).map { 30 + 70 * sin(Double($0) / 4) + Double($0) * 0.37 } + #expect(PriceGlance.included(core(spots: day, fee: 45, vat: 25)) == .gridFeeAndVAT) + #expect(PriceGlance.included(core(spots: day, fee: 0, vat: 25)) == .vat) + #expect(PriceGlance.included(core(spots: day, fee: 45, vat: 0)) == .gridFee) + #expect(PriceGlance.included(core(spots: day, fee: 0, vat: 0)) == .spotOnly) + // Cents move little over a day; the line still holds. + let cents = (0..<96).map { 6 + 4 * sin(Double($0) / 12) } + #expect(PriceGlance.included(core(spots: cents, fee: 3, vat: 24)) == .gridFeeAndVAT) + #expect(PriceGlance.included(core(spots: cents, fee: 0, vat: 24)) == .vat) + // A flat market cannot tell a fee from a percentage. + #expect(PriceGlance.included(core(spots: Array(repeating: 50, count: 24), fee: 45, vat: 25)) == .surcharges) + #expect(PriceGlance.text(.gridFeeAndVAT) == "incl. grid fee and VAT") + #expect(PriceGlance.text(.spotOnly) == "spot price only") + } + + // MARK: Freshness + + /// A record read back is the cache, whatever carried it and however + /// healthy the box was then. No moment claims live. + @Test func aCachedPriceNeverLooksLive() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5), carrier: .relay, srcState: .live) + let moments = PriceGlance.moments(r, nowMs: at(12, 13, 30), calendar: calendar) + for m in moments { + #expect(m.carrier == .cache) + #expect(m.srcState == .live) + #expect(m.readAtMs == at(12, 13, 5)) + let words = [m.priceText, m.cheapestText, m.emptyText, m.fetchedText, m.includedText, m.inlineText].compactMap { $0 } + #expect(!words.contains { $0.lowercased().contains("live") }) + } + #expect(moments.first?.fetchedText == "Fetched 13:05") + // An answer the box itself called short is kept as such. + let short = read(slots(from: at(12, 0), count: 24), readAtMs: at(12, 8), srcState: .stale) + #expect(PriceGlance.moment(short, atMs: at(12, 9), calendar: calendar).srcState == .stale) + } + + @Test func withNothingCachedTheWidgetAsksForTheApp() { + let moments = PriceGlance.moments(nil, nowMs: at(12, 9), calendar: calendar) + #expect(moments.count == 1) + #expect(moments[0].state == .nothing) + #expect(moments[0].carrier == .none) + #expect(moments[0].srcState == .never) + #expect(moments[0].emptyText == "Open FTW to see prices") + } + + // MARK: Words + + /// Every sentence a widget shows has a Swedish line with the same + /// placeholders. + @Test func everyWidgetWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Localizable", withExtension: "strings", subdirectory: nil, localization: "sv")) + let table = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in GlanceWord.allCases { + let sv = try #require(table[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(sv.components(separatedBy: "%@").count == word.rawValue.components(separatedBy: "%@").count) + #expect(sv != word.rawValue) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift new file mode 100644 index 0000000..f4d3f59 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import FTWShared + +/// When the app asks iOS to wake it for tomorrow's prices. +@Suite struct PriceRefreshTests { + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + func at(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Double { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + @Test func beforeTheMarketPublishesItWaitsForFivePastOne() { + #expect(PriceRefresh.next(afterMs: at(12, 8), pricesEndMs: at(13, 0), calendar: calendar) == at(12, 13, 5)) + } + + @Test func withTomorrowInHandTheNextIsTomorrows() { + #expect(PriceRefresh.next(afterMs: at(12, 13, 20), pricesEndMs: at(14, 0), calendar: calendar) == at(13, 13, 5)) + } + + /// The box asks the market once an hour, so at 13:05 it may not have + /// tomorrow yet. Ask again in half an hour, until the evening. + @Test func withoutTomorrowItAsksAgainSoon() { + #expect(PriceRefresh.next(afterMs: at(12, 13, 10), pricesEndMs: at(13, 0), calendar: calendar) == at(12, 13, 40)) + #expect(PriceRefresh.next(afterMs: at(12, 13, 10), pricesEndMs: nil, calendar: calendar) == at(12, 13, 40)) + #expect(PriceRefresh.next(afterMs: at(12, 19, 50), pricesEndMs: at(13, 0), calendar: calendar) == at(13, 13, 5)) + } + + /// 13:05 on the household's clock, also across the night the clocks go + /// back. + @Test func fivePastOneIsWallClockAcrossTheClockChange() { + let next = PriceRefresh.next(afterMs: at(24, 14), pricesEndMs: at(26, 0), calendar: calendar) + #expect(next == at(25, 13, 5)) + #expect(next - at(24, 13, 5) == 25 * 3_600_000) + } +} diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift new file mode 100644 index 0000000..89f0e49 --- /dev/null +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -0,0 +1,227 @@ +import Charts +import FTWShared +import SwiftUI +import WidgetKit + +/// One moment of the price widget, in whichever family it sits. Every +/// sentence comes from `PriceGlance`; this file only lays them out. +struct PriceWidgetView: View { + let moment: PriceGlance.Moment + @Environment(\.widgetFamily) private var family + + var body: some View { + content + .containerBackground(for: .widget) { background } + } + + /// Grey when there is no price for this moment. The lock screen draws + /// its own, so the accessory families bring none. + private var background: Color { + switch family { + case .accessoryRectangular, .accessoryInline, .accessoryCircular: return .clear + default: return moment.isEmpty ? Tone.empty : Tone.surface + } + } + + @ViewBuilder private var content: some View { + switch family { + case .systemMedium: MediumPrice(moment: moment) + case .accessoryRectangular: RectangularPrice(moment: moment) + case .accessoryInline: Text(moment.inlineText) + default: SmallPrice(moment: moment) + } + } +} + +/// The home screen's small widget, and StandBy's. +struct SmallPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let price = moment.priceText { + Text(PriceGlance.priceNow).font(.caption).foregroundStyle(.secondary) + PriceFigure(price: price, size: 38) + Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.8) + Spacer(minLength: 4) + Cheapest(moment: moment) + Fetched(moment: moment) + } else { + Empty(moment: moment) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .combine) + } +} + +/// The figures on the left and the next twelve hours on the right. +struct MediumPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + if let price = moment.priceText { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(PriceGlance.priceNow).font(.caption).foregroundStyle(.secondary) + PriceFigure(price: price, size: 34) + Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(2).minimumScaleFactor(0.8) + Spacer(minLength: 4) + Cheapest(moment: moment) + Fetched(moment: moment) + } + .frame(width: 128, alignment: .leading) + .accessibilityElement(children: .combine) + Curve(moment: moment) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(alignment: .leading, spacing: 2) { Empty(moment: moment) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +/// The lock screen's rectangle: price, cheapest, what the price holds. +struct RectangularPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let price = moment.priceText { + Text("\(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + .widgetAccentable() + if let cheapest = moment.cheapestText { + Text(cheapest).font(.caption) + } + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary) + } else { + Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +// MARK: Parts + +struct PriceFigure: View { + let price: String + let size: CGFloat + + var body: some View { + Text(price) + .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .minimumScaleFactor(0.5) + .lineLimit(1) + .widgetAccentable() + } +} + +struct Cheapest: View { + let moment: PriceGlance.Moment + + var body: some View { + if let text = moment.cheapestText { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(Tone.cheap) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } +} + +/// When the app fetched these prices. A widget only ever shows the cache. +struct Fetched: View { + let moment: PriceGlance.Moment + + var body: some View { + if let text = moment.fetchedText { + Text(text).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + } + } +} + +/// No price for this moment: say why and send the reader to the app. +struct Empty: View { + let moment: PriceGlance.Moment + + var body: some View { + Text(PriceGlance.title).font(.caption).foregroundStyle(.secondary) + Spacer(minLength: 4) + Text(moment.emptyText ?? "") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + Fetched(moment: moment) + } +} + +/// The next twelve hours as bars from zero, at the resolution the box sent: +/// green below the average ahead, red above, the current slot solid. +struct Curve: View { + let moment: PriceGlance.Moment + + var body: some View { + Chart { + ForEach(moment.curve) { bar in + BarMark( + xStart: .value("From", date(bar.startMs)), + xEnd: .value("To", date(bar.endMs)), + y: .value("Price", PriceUnits.display(bar.minor, moment.currency)) + ) + .foregroundStyle(Tone.of(bar.tone)) + .opacity(bar.current ? 1 : 0.55) + } + if let mean = moment.meanMinor { + RuleMark(y: .value("Average", PriceUnits.display(mean, moment.currency))) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) + .foregroundStyle(.secondary) + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .hour, count: 3)) { _ in + AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .omitted))) + } + } + .chartYAxis(.hidden) + .accessibilityHidden(true) + } + + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } +} + +/// The app's colour roles (FTW/UI/Theme.swift, from the web app's tokens), +/// the few a price widget needs. +enum Tone { + static let surface = pair(0xFAFAF8, 0x161616) + static let empty = pair(0xDCDCD6, 0x2A2A2A) + static let cheap = pair(0x009639, 0x5FD37F) + static let dear = pair(0xCC243D, 0xFF6E74) + static let negative = pair(0xD48500, 0xFFB000) + static let flat = pair(0x686862, 0x858585) + + static func of(_ tone: PriceStrip.Tone) -> Color { + switch tone { + case .cheap: return cheap + case .dear: return dear + case .flat: return flat + case .negative: return negative + } + } + + private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { + Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) + } +} + +private extension UIColor { + convenience init(rgb: UInt32) { + self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + } +} diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index 586246d..2e42dc2 100644 --- a/appleApp/FTWWidgets/PriceWidget.swift +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -1,7 +1,10 @@ import FTWShared +import os import SwiftUI import WidgetKit +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + @main struct FTWWidgets: WidgetBundle { var body: some Widget { @@ -9,105 +12,64 @@ struct FTWWidgets: WidgetBundle { } } -/// The price now and the cheapest two hours ahead, from what the app left in -/// the App Group. A widget opens no connection and holds no key. +/// The price now, when it is cheapest next, and a short curve, from what the +/// app left in the App Group. A widget opens no connection and holds no key. +/// Prices are not secret, so the lock screen shows them as they are. struct PriceWidget: Widget { var body: some WidgetConfiguration { - StaticConfiguration(kind: "energy.ftw.prices", provider: PriceTimeline()) { moment in - PriceWidgetView(moment: moment) - .containerBackground(.fill.tertiary, for: .widget) + StaticConfiguration(kind: "energy.ftw.prices", provider: PriceTimeline()) { entry in + PriceWidgetView(moment: entry.moment) } - .configurationDisplayName("Electricity price") - .description("The price now and the cheapest two hours ahead.") - .supportedFamilies([.systemSmall]) + .configurationDisplayName(PriceGlance.title) + .description(PriceGlance.summary) + // systemSmall is also the StandBy widget. + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) } } -struct PriceMoment: TimelineEntry { +struct PriceWidgetEntry: TimelineEntry { let date: Date - /// Nil when the app has left nothing: not paired, or signed out. - let read: CacheRead? + let moment: PriceGlance.Moment } struct PriceTimeline: TimelineProvider { - func placeholder(in context: Context) -> PriceMoment { - PriceMoment(date: .now, read: nil) + func placeholder(in context: Context) -> PriceWidgetEntry { + // Drawn redacted while the real entry loads, so only the shape shows. + let now = Date.now.timeIntervalSince1970 * 1000 + return PriceWidgetEntry(date: .now, moment: PriceGlance.moment(Self.sample(nowMs: now), atMs: now)) } - func getSnapshot(in context: Context, completion: @escaping (PriceMoment) -> Void) { - completion(moments(now: .now)[0]) + func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) { + completion(entries(now: .now)[0]) } /// The app reloads every timeline after it writes, so nothing here asks - /// to be woken. - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - completion(Timeline(entries: moments(now: .now), policy: .never)) - } - - /// One moment now and one at every price boundary ahead. The last falls - /// where the prices end, so the widget says they ran out rather than - /// showing the last hour again. - func moments(now: Date) -> [PriceMoment] { - guard let reader = CacheReader.shared(), - let site = reader.latestSiteID(PriceEntry.self), - let read = reader.read(PriceEntry.self, siteId: site) else { - return [PriceMoment(date: now, read: nil)] + /// to be woken. The last entry falls where the prices end and stays. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entries = entries(now: .now) + if let last = entries.last?.moment { + log.info("price timeline: \(entries.count, privacy: .public) entries, last \(String(describing: last.state), privacy: .public)") } - let nowMs = now.timeIntervalSince1970 * 1000 - let ahead = Set(read.value.prices.slots.map { $0.startMs + $0.durationMs }.filter { $0 > nowMs }).sorted() - return [PriceMoment(date: now, read: read)] + ahead.map { PriceMoment(date: Date(timeIntervalSince1970: $0 / 1000), read: read) } + completion(Timeline(entries: entries, policy: .never)) } -} - -struct PriceWidgetView: View { - let moment: PriceMoment - var body: some View { - if let read = moment.read { - prices(read) - } else { - VStack(alignment: .leading, spacing: 4) { - Text("FTW").font(.headline) - Text("Open FTW to see prices.").font(.caption).foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } + private func entries(now: Date) -> [PriceWidgetEntry] { + let reader = CacheReader.shared() + let read = reader.flatMap { r in r.latestSiteID(PriceEntry.self).flatMap { r.read(PriceEntry.self, siteId: $0) } } + if reader == nil { log.error("price widget: no App Group, nothing to read") } + return PriceGlance.moments(read, nowMs: now.timeIntervalSince1970 * 1000) + .map { PriceWidgetEntry(date: Date(timeIntervalSince1970: $0.atMs / 1000), moment: $0) } } - private func prices(_ read: CacheRead) -> some View { - let prices = read.value.prices - let summary = PriceStrip.summary(prices, nowMs: moment.date.timeIntervalSince1970 * 1000) - return VStack(alignment: .leading, spacing: 2) { - Text("Price now").font(.caption).foregroundStyle(.secondary) - if let current = summary.current { - Text(PriceStrip.text(current.totalMinor, prices.currency)) - .font(.system(.largeTitle, design: .rounded).weight(.semibold).monospacedDigit()) - .minimumScaleFactor(0.6) - .lineLimit(1) - Text(PriceUnits.unit(prices.currency).perKwh).font(.caption2).foregroundStyle(.secondary) - Spacer(minLength: 2) - if let cheapest = summary.cheapest { - Text("Cheapest \(time(cheapest.startMs))–\(time(cheapest.endMs))") - .font(.caption2.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - } else { - Spacer(minLength: 2) - Text(endText(prices)).font(.caption).foregroundStyle(.secondary) - } - // Read back from the phone, never live: say when it was fetched. - Text("Updated \(time(read.record.readAtMs))").font(.caption2).foregroundStyle(.secondary) + /// A day's shape for the redacted placeholder. Never shown as a price. + static func sample(nowMs: Double) -> CacheRead { + let hour: Double = 3_600_000 + let start = (nowMs / hour).rounded(.down) * hour + let slots = (0..<12).map { i in + PriceSlot(startMs: start + Double(i) * hour, durationMs: hour, spotMinor: 40, totalMinor: 60 + Double((i * 37) % 50)) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private func endText(_ prices: Prices) -> String { - guard let last = prices.slots.map({ $0.startMs + $0.durationMs }).max() else { return "No prices · open FTW" } - return "No prices after \(time(last)) · open FTW" - } - - private func time(_ ms: Double) -> String { - Date(timeIntervalSince1970: ms / 1000).formatted(date: .omitted, time: .shortened) + let prices = Prices(zone: "", currency: "SEK", slots: slots, stale: false) + let record = CacheRecord(siteId: "", carrier: .none, srcState: .never, readAtMs: nowMs, value: PriceEntry(fromMs: start, prices: prices)) + return CacheRead(record: record, origin: .app) } } From 91ebff4989a2a193df96de19601884cb55da98da Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:02:11 +0200 Subject: [PATCH 2/4] Draw the curve's bars from zero and keep the last hour label whole A bar mark with an x interval and a single y lies on its side, so the curve showed short dashes at each price. Rectangles from zero to the price fix that. Hour labels fall every third hour, clear of the right edge, on the 24-hour clock the rest of the widget uses. The average line takes the muted tone instead of the system tint. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWWidgets/PriceViews.swift | 52 ++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift index 89f0e49..1cf5432 100644 --- a/appleApp/FTWWidgets/PriceViews.swift +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -170,29 +170,61 @@ struct Curve: View { var body: some View { Chart { ForEach(moment.curve) { bar in - BarMark( - xStart: .value("From", date(bar.startMs)), - xEnd: .value("To", date(bar.endMs)), - y: .value("Price", PriceUnits.display(bar.minor, moment.currency)) - ) - .foregroundStyle(Tone.of(bar.tone)) - .opacity(bar.current ? 1 : 0.55) + mark(bar) } if let mean = moment.meanMinor { RuleMark(y: .value("Average", PriceUnits.display(mean, moment.currency))) .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) - .foregroundStyle(.secondary) + .foregroundStyle(Tone.flat) } } + .chartXScale(domain: date(start)...date(end)) .chartXAxis { - AxisMarks(values: .stride(by: .hour, count: 3)) { _ in - AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .omitted))) + AxisMarks(values: hours) { value in + AxisValueLabel { + if let d = value.as(Date.self) { Text(Self.hour(d)) } + } } } .chartYAxis(.hidden) .accessibilityHidden(true) } + /// A bar from zero, as wide as the slot. A rectangle, because a bar + /// mark with an x interval and one y lies on its side. + private func mark(_ bar: PriceStrip.Bar) -> some ChartContent { + let price: Double = PriceUnits.display(bar.minor, moment.currency) + return RectangleMark( + xStart: .value("From", date(bar.startMs)), + xEnd: .value("To", date(bar.endMs)), + yStart: .value("Zero", 0.0), + yEnd: .value("Price", price) + ) + .foregroundStyle(Tone.of(bar.tone)) + .opacity(bar.current ? 1 : 0.55) + } + + private var start: Double { moment.curve.first?.startMs ?? moment.atMs } + private var end: Double { moment.curve.last?.endMs ?? moment.atMs } + + /// Every third whole hour, kept clear of the right edge so no label is + /// cut short. + private var hours: [Date] { + let hour: Double = 3_600_000 + var t = (start / hour).rounded(.up) * hour + var out: [Date] = [] + while t < end - hour { + if Calendar.current.component(.hour, from: date(t)) % 3 == 0 { out.append(date(t)) } + t += hour + } + return out + } + + /// "02", on the 24-hour clock the rest of the widget uses. + private static func hour(_ d: Date) -> String { + String(format: "%02d", Calendar.current.component(.hour, from: d)) + } + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } } From 669b830c2a49611cc3f2d24b52aa16bac37cddab Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:14:14 +0200 Subject: [PATCH 3/4] Fit the lock screen, drop UIKit, and time the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the lock screen the inline line shares its width with the date, so it now shows the price and the start of the cheapest stretch ("273 öre · billigast 02:45"), and "Inga priser · öppna FTW" when there is none. The rectangle's lower lines shrink a little before they would be cut. Colours resolve through a SwiftUI ShapeStyle instead of UIColor, and the background task reads the scene phase instead of UIApplication. The timeline sorts the slots and reads what the total holds once, not once per entry: 121 entries took 37-40 ms in a Debug build in the simulator, now 14-17 ms, file read included. An OSSignposter interval wraps it for Instruments, and the log line carries the time. The price figure changes with a numeric content transition. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/FTWApp.swift | 6 ++- .../Sources/FTWShared/PriceGlance.swift | 52 +++++++++++++------ .../Resources/sv.lproj/Localizable.strings | 3 ++ .../FTWSharedTests/PriceGlanceTests.swift | 10 ++-- appleApp/FTWWidgets/PriceViews.swift | 50 +++++++++++------- appleApp/FTWWidgets/PriceWidget.swift | 7 ++- 6 files changed, 87 insertions(+), 41 deletions(-) diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index f35afc0..cb35e34 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -52,6 +52,7 @@ struct FTWApp: App { // switcher: slow the stream, keep the session. Only the // background closes it. app.setVisible(phase == .active) + PricesInBackground.inBackground = phase == .background if phase == .background { app.suspend() PricesInBackground.schedule(app) @@ -74,6 +75,9 @@ enum PricesInBackground { /// Listed under BGTaskSchedulerPermittedIdentifiers in FTW-Info.plist. static let id = "energy.ftw.app.prices" static let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + /// Set from the scene phase. A cold launch for this task has no scene, + /// so it starts in the background. + static var inBackground = true static func schedule(_ app: AppModel) { guard let site = app.site, site.siteId != nil, !app.isDemo else { return } @@ -100,7 +104,7 @@ enum PricesInBackground { let prices = await site.refreshSharedPrices() log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)") // Someone may have opened the app meanwhile; their session stays. - if UIApplication.shared.applicationState == .background { app.suspend() } + if inBackground { app.suspend() } } } diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift index d5e6c15..950ac0c 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift @@ -11,8 +11,6 @@ import Foundation public enum PriceGlance { /// How far ahead the medium widget's curve reaches. public static let curveMs: Double = 12 * 3_600_000 - /// The stretch "cheapest" looks for, as on the app's price card. - public static let blockHours: Double = 2 /// What the box's total holds besides the market price. The wire has /// spot and total but not the settings between them, so this is read off @@ -65,6 +63,8 @@ public enum PriceGlance { public let includedText: String /// "Cheapest 02–04" public let cheapestText: String? + /// "cheapest 02:45": the start alone, for one short line. + public let cheapestStartText: String? /// Why there is no price: "No prices after 24:00 · open FTW". public let emptyText: String? /// "Fetched 13:05" @@ -73,11 +73,13 @@ public enum PriceGlance { /// Whether the widget should go grey: no price for this moment. public var isEmpty: Bool { state != .price } - /// One line for the lock screen's inline slot. + /// One line for the lock screen's inline slot, which shares its + /// width with the date. Without a price it only sends the reader to + /// the app; the other families say when the prices ended. public var inlineText: String { - guard let priceText else { return emptyText ?? "" } + guard let priceText else { return GlanceWord.noPricesShort.text } let price = "\(priceText) \(unitLabel)" - return cheapestText.map { "\(price) · \($0)" } ?? price + return cheapestStartText.map { "\(price) · \($0)" } ?? price } } @@ -85,14 +87,16 @@ public enum PriceGlance { /// last where the prices end. public static func moments(_ read: CacheRead?, nowMs: Double, calendar: Calendar = .current) -> [Moment] { guard let read else { return [moment(nil, atMs: nowMs, calendar: calendar)] } + // Sorted and read once, not once per moment. let slots = usable(read.value.prices) + let included = included(slots) var edges = Set() for s in slots { edges.insert(s.startMs) edges.insert(s.startMs + s.durationMs) } let ahead = edges.filter { $0 > nowMs }.sorted() - return ([nowMs] + ahead).map { moment(read, atMs: $0, calendar: calendar) } + return ([nowMs] + ahead).map { moment(read, slots: slots, included: included, atMs: $0, calendar: calendar) } } /// The widget at one moment. @@ -101,15 +105,18 @@ public enum PriceGlance { return Moment( atMs: t, state: .nothing, carrier: .none, srcState: .never, readAtMs: nil, currency: "", current: nil, cheapest: nil, curve: [], meanMinor: nil, included: .spotOnly, - priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, + priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, cheapestStartText: nil, emptyText: GlanceWord.openToSee.text, fetchedText: nil ) } + let slots = usable(read.value.prices) + return moment(read, slots: slots, included: included(slots), atMs: t, calendar: calendar) + } + + private static func moment(_ read: CacheRead, slots: [PriceSlot], included: Included, atMs t: Double, calendar: Calendar) -> Moment { let prices = read.value.prices - let slots = usable(prices) let unit = PriceUnits.unit(prices.currency) let current = slots.first { t >= $0.startMs && t < $0.startMs + $0.durationMs } - let included = included(slots) let state: State if current != nil { @@ -124,8 +131,8 @@ public enum PriceGlance { var curve: [PriceStrip.Bar] = [] var mean: Double? if current != nil { - let summary = PriceStrip.summary(prices, nowMs: t) - cheapest = PriceStrip.bestBlock(slots.filter { $0.startMs + $0.durationMs > t }, hours: blockHours, cheapest: true) + let summary = PriceStrip.summary(Prices(zone: prices.zone, currency: prices.currency, slots: slots, stale: prices.stale), nowMs: t) + cheapest = summary.cheapest curve = summary.bars.filter { $0.startMs < t + curveMs } mean = summary.meanMinor } @@ -157,6 +164,7 @@ public enum PriceGlance { unitLabel: unit.label, includedText: text(included), cheapestText: cheapest.map { clock.cheapest($0, from: t) }, + cheapestStartText: cheapest.map { clock.cheapestStart($0, from: t) }, emptyText: emptyText, fetchedText: GlanceWord.fetched.format(clock.at(read.record.readAtMs, asEnd: false, from: t)) ) @@ -217,9 +225,12 @@ enum GlanceWord: String, CaseIterable { case cheapest = "Cheapest %@" case cheapestNow = "Cheapest now until %@" case tomorrow = "tomorrow %@" + case cheapestStart = "cheapest %@" + case cheapestStartNow = "cheapest now" case noPricesAfter = "No prices after %@ · open FTW" case noPriceUntil = "No price until %@ · open FTW" case openToSee = "Open FTW to see prices" + case noPricesShort = "No prices · open FTW" case fetched = "Fetched %@" case inclGridFeeAndVAT = "incl. grid fee and VAT" case inclVAT = "incl. VAT" @@ -286,11 +297,20 @@ struct Clock { if block.startMs <= t { return GlanceWord.cheapestNow.format(short(b)) } - var range = "\(short(a))–\(short(b))" + let range = "\(short(a))–\(short(b))" + return GlanceWord.cheapest.format(misleads(a, from: t) ? GlanceWord.tomorrow.format(range) : range) + } + + /// "cheapest 02:45", "cheapest now", "cheapest tomorrow 15:00". + func cheapestStart(_ block: PriceStrip.Block, from t: Double) -> String { + if block.startMs <= t { return GlanceWord.cheapestStartNow.text } + let a = wall(block.startMs, asEnd: false) + return GlanceWord.cheapestStart.format(misleads(a, from: t) ? GlanceWord.tomorrow.format(a.hm) : a.hm) + } + + /// A time tomorrow that the clock alone would read as later today. + private func misleads(_ a: Wall, from t: Double) -> Bool { let now = wall(t, asEnd: false) - if a.day > now.day, (a.hour, a.minute) > (now.hour, now.minute) { - range = GlanceWord.tomorrow.format(range) - } - return GlanceWord.cheapest.format(range) + return a.day > now.day && (a.hour, a.minute) > (now.hour, now.minute) } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 3f30126..2fe7e81 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -34,9 +34,12 @@ "Cheapest %@" = "Billigast %@"; "Cheapest now until %@" = "Billigast nu till %@"; "tomorrow %@" = "i morgon %@"; +"cheapest %@" = "billigast %@"; +"cheapest now" = "billigast nu"; "No prices after %@ · open FTW" = "Inga priser efter %@ · öppna FTW"; "No price until %@ · open FTW" = "Inget pris förrän %@ · öppna FTW"; "Open FTW to see prices" = "Öppna FTW för att se priser"; +"No prices · open FTW" = "Inga priser · öppna FTW"; "Fetched %@" = "Hämtat %@"; "incl. grid fee and VAT" = "inkl. nätavgift och moms"; "incl. VAT" = "inkl. moms"; diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift index 0e03bf1..df2cb40 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift @@ -81,7 +81,7 @@ import Testing #expect(last.cheapestText == nil) #expect(last.curve.isEmpty) #expect(last.emptyText == "No prices after 18:00 · open FTW") - #expect(last.inlineText == "No prices after 18:00 · open FTW") + #expect(last.inlineText == "No prices · open FTW") } /// Nothing after the end is priced: not the last hour stretched, not @@ -162,9 +162,12 @@ import Testing let evening = PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar) #expect(evening.cheapest?.startMs == at(13, 2)) #expect(evening.cheapestText == "Cheapest 02–04") - #expect(evening.inlineText == "\(evening.priceText!) öre · Cheapest 02–04") + // The inline line shares its width with the date: the start alone. + #expect(evening.inlineText == "\(evening.priceText!) öre · cheapest 02:00") // Inside the block it is now, and says until when. - #expect(PriceGlance.moment(r, atMs: at(13, 2, 30), calendar: calendar).cheapestText == "Cheapest now until 04") + let inside = PriceGlance.moment(r, atMs: at(13, 2, 30), calendar: calendar) + #expect(inside.cheapestText == "Cheapest now until 04") + #expect(inside.cheapestStartText == "cheapest now") } @Test func cheapestFollowsQuarterHours() { @@ -177,6 +180,7 @@ import Testing @Test func tomorrowIsNamedOnlyWhenTheClockWouldMislead() { let r = read(night(cheapFrom: at(13, 15), cheapTo: at(13, 17)), readAtMs: at(12, 13, 5)) #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestText == "Cheapest tomorrow 15–17") + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestStartText == "cheapest tomorrow 15:00") // The same block seen the next morning is today's. #expect(PriceGlance.moment(r, atMs: at(13, 9), calendar: calendar).cheapestText == "Cheapest 15–17") } diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift index 1cf5432..f97cad9 100644 --- a/appleApp/FTWWidgets/PriceViews.swift +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -11,15 +11,15 @@ struct PriceWidgetView: View { var body: some View { content - .containerBackground(for: .widget) { background } + .containerBackground(for: .widget) { Rectangle().fill(background) } } /// Grey when there is no price for this moment. The lock screen draws /// its own, so the accessory families bring none. - private var background: Color { + private var background: AnyShapeStyle { switch family { - case .accessoryRectangular, .accessoryInline, .accessoryCircular: return .clear - default: return moment.isEmpty ? Tone.empty : Tone.surface + case .accessoryRectangular, .accessoryInline, .accessoryCircular: return AnyShapeStyle(.clear) + default: return AnyShapeStyle(moment.isEmpty ? Tone.empty : Tone.surface) } } @@ -94,9 +94,9 @@ struct RectangularPrice: View { Text("\(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") .widgetAccentable() if let cheapest = moment.cheapestText { - Text(cheapest).font(.caption) + Text(cheapest).font(.caption).lineLimit(1).minimumScaleFactor(0.75) } - Text(moment.includedText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.7) } else { Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(3) } @@ -115,6 +115,7 @@ struct PriceFigure: View { var body: some View { Text(price) .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .contentTransition(.numericText()) .minimumScaleFactor(0.5) .lineLimit(1) .widgetAccentable() @@ -231,14 +232,14 @@ struct Curve: View { /// The app's colour roles (FTW/UI/Theme.swift, from the web app's tokens), /// the few a price widget needs. enum Tone { - static let surface = pair(0xFAFAF8, 0x161616) - static let empty = pair(0xDCDCD6, 0x2A2A2A) - static let cheap = pair(0x009639, 0x5FD37F) - static let dear = pair(0xCC243D, 0xFF6E74) - static let negative = pair(0xD48500, 0xFFB000) - static let flat = pair(0x686862, 0x858585) - - static func of(_ tone: PriceStrip.Tone) -> Color { + static let surface = Pair(0xFAFAF8, 0x161616) + static let empty = Pair(0xDCDCD6, 0x2A2A2A) + static let cheap = Pair(0x009639, 0x5FD37F) + static let dear = Pair(0xCC243D, 0xFF6E74) + static let negative = Pair(0xD48500, 0xFFB000) + static let flat = Pair(0x686862, 0x858585) + + static func of(_ tone: PriceStrip.Tone) -> Pair { switch tone { case .cheap: return cheap case .dear: return dear @@ -246,14 +247,23 @@ enum Tone { case .negative: return negative } } +} + +/// One colour for light and one for dark, picked by SwiftUI where it draws. +struct Pair: ShapeStyle { + let light: Color + let dark: Color + + init(_ light: UInt32, _ dark: UInt32) { + self.light = Self.color(light) + self.dark = Self.color(dark) + } - private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { - Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) + func resolve(in environment: EnvironmentValues) -> Color { + environment.colorScheme == .dark ? dark : light } -} -private extension UIColor { - convenience init(rgb: UInt32) { - self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) + private static func color(_ rgb: UInt32) -> Color { + Color(red: Double((rgb >> 16) & 0xFF) / 255, green: Double((rgb >> 8) & 0xFF) / 255, blue: Double(rgb & 0xFF) / 255) } } diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index 2e42dc2..bb50c0f 100644 --- a/appleApp/FTWWidgets/PriceWidget.swift +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -4,6 +4,7 @@ import SwiftUI import WidgetKit private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") +private let signposter = OSSignposter(subsystem: "energy.ftw.app", category: "widgets") @main struct FTWWidgets: WidgetBundle { @@ -46,9 +47,13 @@ struct PriceTimeline: TimelineProvider { /// The app reloads every timeline after it writes, so nothing here asks /// to be woken. The last entry falls where the prices end and stays. func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let interval = signposter.beginInterval("price timeline") + let started = ContinuousClock.now let entries = entries(now: .now) + let ms = (ContinuousClock.now - started) / .milliseconds(1) + signposter.endInterval("price timeline", interval) if let last = entries.last?.moment { - log.info("price timeline: \(entries.count, privacy: .public) entries, last \(String(describing: last.state), privacy: .public)") + log.info("price timeline: \(entries.count, privacy: .public) entries in \(ms, format: .fixed(precision: 1), privacy: .public) ms, last \(String(describing: last.state), privacy: .public)") } completion(Timeline(entries: entries, policy: .never)) } From 815215cb1dbc3e217924e3d18ec7cd8def46b0aa Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:25:10 +0200 Subject: [PATCH 4/4] Write the cache off the main thread, skip unchanged prices, follow the rendering mode - The App Group write runs off the main actor (a @concurrent function), in the order it was asked for. A sign-out asked for meanwhile wins: writes and clears share a lock and a per-directory clear count, so a late write never puts the home back. - The same prices again are neither written nor reloaded, so the widget timeline is rebuilt only for new prices. The background task waits for the write to land before iOS can suspend it. - The curve follows widgetRenderingMode: tinted, clear and StandBy's one-colour mode keep the current and cheap slots strong and fade the rest. The cheapest line and the symbol take the accent. - A bolt symbol heads every family, as in Apple's own widgets. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/FTWApp.swift | 9 ++- .../Sources/FTWKit/State/SiteModel.swift | 77 ++++++++++++++----- appleApp/FTWKit/Sources/FTWShared/Cache.swift | 35 ++++++++- .../Tests/FTWKitTests/WidgetPricesTests.swift | 24 +++++- appleApp/FTWWidgets/PriceViews.swift | 44 +++++++++-- 5 files changed, 154 insertions(+), 35 deletions(-) diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index cb35e34..e0591e1 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -55,7 +55,7 @@ struct FTWApp: App { PricesInBackground.inBackground = phase == .background if phase == .background { app.suspend() - PricesInBackground.schedule(app) + Task { await PricesInBackground.schedule(app) } } } } @@ -79,10 +79,11 @@ enum PricesInBackground { /// so it starts in the background. static var inBackground = true - static func schedule(_ app: AppModel) { + static func schedule(_ app: AppModel) async { guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + let pricesEndMs = await site.sharedPricesEndMs() let nowMs = Date.now.timeIntervalSince1970 * 1000 - let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: site.sharedPricesEndMs) / 1000) + let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: pricesEndMs) / 1000) let request = BGAppRefreshTaskRequest(identifier: id) request.earliestBeginDate = at do { @@ -96,7 +97,6 @@ enum PricesInBackground { } static func run(_ app: AppModel) async { - defer { schedule(app) } guard let site = app.site, site.siteId != nil, !app.isDemo else { return } log.info("background prices: woken") // A new carrier if the last one was closed, or a fresh stream. @@ -105,6 +105,7 @@ enum PricesInBackground { log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)") // Someone may have opened the app meanwhile; their session stays. if inBackground { app.suspend() } + await schedule(app) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 78489ba..12a6db6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -43,6 +43,12 @@ public final class SiteModel { /// What widgets read. Nil for the demo and in tests. @ObservationIgnored private let shared: AppGroupFiles? @ObservationIgnored private var lastCars: (entry: CarEntry, atMs: Double)? + /// The prices last left for the widgets, so the same prices again are + /// neither written nor reloaded. + @ObservationIgnored private var lastPrices: PriceEntry? + /// The latest write to `shared`. Each waits for the one before, so they + /// land in the order they were asked for. + @ObservationIgnored private var writing: Task? /// Called after every write to `shared`, so the app can wake its widgets. @ObservationIgnored public var sharedWritten: (@MainActor () -> Void)? @ObservationIgnored private var attemptStartedAtMs: Double @@ -370,17 +376,24 @@ public final class SiteModel { /// each of those. static let carRewriteMs: Double = 5 * 60_000 - /// Leave today's and tomorrow's prices for the widgets. - func sharePrices(_ prices: Prices, fromMs: Double) { - guard let siteId, let shared else { return } + /// Leave today's and tomorrow's prices for the widgets. The same prices + /// again are skipped: the widgets' timeline already holds them, and a + /// reload would only redraw it. Returns the write, for a caller that + /// must not end before it lands. + @discardableResult + func sharePrices(_ prices: Prices, fromMs: Double) -> Task? { + guard let siteId, let shared else { return nil } + let entry = PriceEntry(fromMs: fromMs, prices: prices) + guard entry != lastPrices else { return nil } + lastPrices = entry let record = CacheRecord( siteId: siteId, carrier: session.carrier, srcState: prices.stale ? .stale : .live, readAtMs: scheduler.nowMs, - value: PriceEntry(fromMs: fromMs, prices: prices) + value: entry ) - write(record, to: shared) + return write(record, to: shared) } /// Leave the chargers for the widgets, as the box just described them. @@ -405,23 +418,48 @@ public final class SiteModel { write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared) } - private func write(_ record: CacheRecord, to shared: AppGroupFiles) { - guard !destroyed else { return } - do { - try shared.write(record) - log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)") - sharedWritten?() - } catch { - // A widget showing yesterday's record with its date is better - // than no app; the next read tries again. - log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)") + /// Encoding and the file write run off the main actor. A sign-out + /// asked for meanwhile wins: the write is dropped rather than putting + /// the home back (`AppGroupFiles.clearCount`). + @discardableResult + private func write(_ record: CacheRecord, to shared: AppGroupFiles) -> Task? { + guard !destroyed else { return nil } + let asked = shared.clearCount + let previous = writing + let task = Task { [weak self] in + await previous?.value + do { + guard try await shared.write(record, asked: asked) else { return } + log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)") + guard let self, !self.destroyed else { return } + self.sharedWritten?() + } catch { + // A widget showing yesterday's record with its date is + // better than no app; the next read tries again. + log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)") + if Value.self == PriceEntry.self { self?.lastPrices = nil } + } } + writing = task + return task } /// Where the prices the widgets hold run out. Nil when they hold none. - public var sharedPricesEndMs: Double? { - guard let siteId, let record = shared?.read(PriceEntry.self, siteId: siteId) else { return nil } - return record.value.prices.slots.map { $0.startMs + $0.durationMs }.max() + /// Read from what this process wrote, or off the main actor from disk + /// after a cold launch. + public func sharedPricesEndMs() async -> Double? { + if let lastPrices { return Self.end(lastPrices) } + guard let siteId, let shared else { return nil } + return await Self.readEnd(shared, siteId: siteId) + } + + @concurrent + private nonisolated static func readEnd(_ shared: AppGroupFiles, siteId: String) async -> Double? { + shared.read(PriceEntry.self, siteId: siteId).flatMap { end($0.value) } + } + + private nonisolated static func end(_ entry: PriceEntry) -> Double? { + entry.prices.slots.map { $0.startMs + $0.durationMs }.max() } /// The background refresh: wait for the stream, ask for today's and @@ -445,6 +483,9 @@ public final class SiteModel { do { let wire = try await prices(fromMs: from, toMs: from + PriceModel.horizonMs) sharePrices(wire, fromMs: from) + // iOS may suspend the app once the task returns: land the write + // first, or the one already on its way with these prices. + await writing?.value return wire } catch { log.error("background prices: \(error.localizedDescription, privacy: .public)") diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift index 61221b1..fb6bf43 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Cache.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization // What the app leaves on disk for its widgets, and later for a notification // extension: one home's prices and its car, each with where it came from @@ -158,14 +159,40 @@ public struct AppGroupFiles: CacheSource { /// Only the app writes. Atomic, so a widget never reads half a file. public func write(_ record: CacheRecord) throws { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let data = try JSONEncoder().encode(record) - try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + try Self.clears.withLock { _ in try store(record) } + } + + /// Writes off the caller's thread. `asked` is `clearCount` when the write + /// was asked for: a clear since then means the home was signed out, so + /// the write is dropped rather than putting the home back. Returns + /// whether it wrote. + @concurrent + public nonisolated func write(_ record: CacheRecord, asked: Int) async throws -> Bool { + try Self.clears.withLock { counts in + guard counts[directory, default: 0] == asked else { return false } + try store(record) + return true + } } /// Everything, for signing out. A widget then has nothing to draw. public func removeAll() { - try? FileManager.default.removeItem(at: directory) + Self.clears.withLock { counts in + counts[directory, default: 0] += 1 + try? FileManager.default.removeItem(at: directory) + } + } + + /// How many times this process has cleared this cache. Writes and + /// clears take the same lock, so they land in the order they were asked + /// for. + public var clearCount: Int { Self.clears.withLock { $0[directory, default: 0] } } + private static let clears = Mutex<[URL: Int]>([:]) + + private func store(_ record: CacheRecord) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift index 4466e09..b0c4667 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift @@ -42,7 +42,27 @@ import Testing #expect(read.carrier == .cache) #expect(read.record.readAtMs >= asked && read.record.readAtMs <= scheduler.nowMs) #expect(wakes.count == 1) - #expect(site.sharedPricesEndMs == written.slots.map { $0.startMs + $0.durationMs }.max()) + #expect(await site.sharedPricesEndMs() == written.slots.map { $0.startMs + $0.durationMs }.max()) + + // The same prices again are neither written nor reloaded. + let again = Task { await site.refreshSharedPrices() } + await run(2_000) + #expect(await again.value == written) + #expect(wakes.count == 1) + } + + /// A sign-out that lands while a write is on its way wins: the home is + /// not put back for the widgets. + @Test func aSignOutBeatsAWriteOnItsWay() async throws { + let wakes = Counter() + let site = site(wakes: wakes) + let prices = Prices(zone: "SE3", currency: "SEK", slots: [PriceSlot(startMs: 0, durationMs: 3_600_000, spotMinor: 40, totalMinor: 60)], stale: false) + let write = try #require(site.sharePrices(prices, fromMs: 0)) + site.destroy() + shared.removeAll() + await write.value + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(wakes.count == 0) } /// No stream, no write: the widgets keep what they had, and the task @@ -55,6 +75,6 @@ import Testing #expect(await task.value == nil) #expect(shared.siteIDs(PriceEntry.self).isEmpty) #expect(wakes.count == 0) - #expect(site.sharedPricesEndMs == nil) + #expect(await site.sharedPricesEndMs() == nil) } } diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift index f97cad9..f2c8d91 100644 --- a/appleApp/FTWWidgets/PriceViews.swift +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -27,7 +27,7 @@ struct PriceWidgetView: View { switch family { case .systemMedium: MediumPrice(moment: moment) case .accessoryRectangular: RectangularPrice(moment: moment) - case .accessoryInline: Text(moment.inlineText) + case .accessoryInline: Label(moment.inlineText, systemImage: Header.symbol) default: SmallPrice(moment: moment) } } @@ -40,7 +40,7 @@ struct SmallPrice: View { var body: some View { VStack(alignment: .leading, spacing: 2) { if let price = moment.priceText { - Text(PriceGlance.priceNow).font(.caption).foregroundStyle(.secondary) + Header(text: PriceGlance.priceNow) PriceFigure(price: price, size: 38) Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.8) @@ -64,7 +64,7 @@ struct MediumPrice: View { if let price = moment.priceText { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 2) { - Text(PriceGlance.priceNow).font(.caption).foregroundStyle(.secondary) + Header(text: PriceGlance.priceNow) PriceFigure(price: price, size: 34) Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(2).minimumScaleFactor(0.8) @@ -91,7 +91,8 @@ struct RectangularPrice: View { var body: some View { VStack(alignment: .leading, spacing: 0) { if let price = moment.priceText { - Text("\(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + Text("\(Image(systemName: Header.symbol)) \(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + .contentTransition(.numericText()) .widgetAccentable() if let cheapest = moment.cheapestText { Text(cheapest).font(.caption).lineLimit(1).minimumScaleFactor(0.75) @@ -108,6 +109,23 @@ struct RectangularPrice: View { // MARK: Parts +/// "⚡ Pris nu": the symbol takes the accent colour, the words stay quiet. +struct Header: View { + static let symbol = "bolt.fill" + let text: String + + var body: some View { + HStack(spacing: 3) { + Image(systemName: Self.symbol) + .imageScale(.small) + .foregroundStyle(Tone.accent) + .widgetAccentable() + Text(text).foregroundStyle(.secondary) + } + .font(.caption) + } +} + struct PriceFigure: View { let price: String let size: CGFloat @@ -132,6 +150,7 @@ struct Cheapest: View { .foregroundStyle(Tone.cheap) .lineLimit(1) .minimumScaleFactor(0.7) + .widgetAccentable() } } } @@ -152,7 +171,7 @@ struct Empty: View { let moment: PriceGlance.Moment var body: some View { - Text(PriceGlance.title).font(.caption).foregroundStyle(.secondary) + Header(text: PriceGlance.title) Spacer(minLength: 4) Text(moment.emptyText ?? "") .font(.footnote.weight(.medium)) @@ -165,8 +184,12 @@ struct Empty: View { /// The next twelve hours as bars from zero, at the resolution the box sent: /// green below the average ahead, red above, the current slot solid. +/// +/// Tinted and clear home screens and StandBy at night draw in one colour, so +/// there the current slot and the cheap ones stand out and the rest are faint. struct Curve: View { let moment: PriceGlance.Moment + @Environment(\.widgetRenderingMode) private var renderingMode var body: some View { Chart { @@ -201,8 +224,14 @@ struct Curve: View { yStart: .value("Zero", 0.0), yEnd: .value("Price", price) ) - .foregroundStyle(Tone.of(bar.tone)) - .opacity(bar.current ? 1 : 0.55) + .foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(Tone.of(bar.tone)) : AnyShapeStyle(.primary)) + .opacity(opacity(bar)) + } + + private func opacity(_ bar: PriceStrip.Bar) -> Double { + if bar.current { return 1 } + guard renderingMode != .fullColor else { return 0.55 } + return bar.tone == .cheap || bar.tone == .negative ? 0.8 : 0.3 } private var start: Double { moment.curve.first?.startMs ?? moment.atMs } @@ -238,6 +267,7 @@ enum Tone { static let dear = Pair(0xCC243D, 0xFF6E74) static let negative = Pair(0xD48500, 0xFFB000) static let flat = Pair(0x686862, 0x858585) + static let accent = Pair(0xDA7F00, 0xFFAC41) static func of(_ tone: PriceStrip.Tone) -> Pair { switch tone {