Skip to content

iPhone M1 price widgets: four families from the App Group cache - #9

Closed
frahlg wants to merge 4 commits into
claude/ios-m1-foundationfrom
ios-m1-widgets
Closed

frahlg wants to merge 4 commits into
claude/ios-m1-foundationfrom
ios-m1-widgets

Conversation

@frahlg

@frahlg frahlg commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Stacked on #8 (claude/ios-m1-foundation). This is step 2 of the iPhone M1: the price widgets. The car view and Now run in a parallel branch with separate files.

What changed

Four families, all from one PriceWidget (energy.ftw.prices):

  • systemSmall: price now, what it includes, cheapest next, when it was fetched. This is also the StandBy widget.
  • systemMedium: the same, plus the next twelve hours as bars at the box's resolution, green below the average ahead and red above, the current slot solid.
  • accessoryRectangular: price, cheapest next, what it includes.
  • accessoryInline: "273 öre · billigast 02:45". It shares its width with the date, so it carries the start of the cheapest stretch only, and "Inga priser · öppna FTW" when there is no price.

Prices show on the lock screen as they are; they are not secret.

Timeline (FTWShared/PriceGlance.swift). One entry now and one at every price boundary the box sent. Quarter-hour prices give an entry every quarter hour, and a box that mixes resolutions is followed slot by slot. Where the prices stop, the last entry turns the widget grey with "Inga priser efter sön 24:00 · öppna FTW". It never stretches the last price or starts a day over. A hole in the middle says "Inget pris förrän 12:00 · öppna FTW" and picks the prices up after it. An end at midnight reads 24:00 of the day it ends, and a time on another day names that day, since a widget can sit unread for days. The policy is .never: the app reloads every timeline after it writes.

Cheapest next. The cheapest two hours in a row ahead, as on the app's price card: "Billigast 02–04", "Billigast 02:15–04:15" at quarter hours, "Billigast nu till 04" inside the block. "i morgon" is added only when the clock alone would read as today (at 14:00, "15–17" looks like this afternoon; at 20:00, "02–04" can only be tonight).

What the price includes. The wire carries spotMinor and totalMinor but not the settings between them. Core charges (spot + grid fee) × (1 + VAT) (prices.go Applier), so a straight line through the slots' two figures tells a grid fee from VAT: "inkl. nätavgift och moms", "inkl. moms", "inkl. nätavgift", "endast spotpris", or "inkl. påslag" when the market barely moved and the two cannot be told apart. Tests run Core's formula with öre and with cents.

Freshness. Every entry carries carrier (always .cache with a record, .none without) and the srcState the app saw when it fetched. Nothing in a widget says live; each shows "Hämtat 17:44".

Keeping the cache fresh.

  • SiteModel writes after every price.get and calls WidgetCenter.reloadAllTimelines() (from iPhone M1 foundation: Swift app cut to Pair, Now and widgets #8). It now logs each write with os.Logger (energy.ftw.app, category widgets).
  • SiteModel.refreshSharedPrices() waits up to 20 s for the stream, asks for today and tomorrow as the price card does, and writes them.
  • The app asks for a BGAppRefreshTask (energy.ftw.app.prices) when it goes to the background: just after 13:05, then every half hour until 20:00 while the box lacks tomorrow, since Core asks the market once an hour. iOS decides when or whether it runs. No silent push, no server.
  • FTW-Info.plist gains BGTaskSchedulerPermittedIdentifiers and UIBackgroundModes: fetch.

Words. All widget text comes from FTWShared, in English and Swedish. A test checks that every widget sentence has Swedish with the same placeholders. Weekday names follow the language the words resolve to.

Native and fast.

  • The widget makes no network call and runs no loop or timer. Its timeline is built once, ahead, with policy .never.
  • It reloads only after a write with new prices: the same prices again are neither written nor reloaded. The writes come from price.get in the app and the BGAppRefreshTask after 13:05.
  • The App Group write runs off the main actor (a @concurrent function), in the order it was asked for. Writes and sign-out share a lock and a per-directory clear count, so a write still on its way after a sign-out is dropped instead of putting the home back. A test covers that race.
  • widgetRenderingMode: in full colour the curve is green and red. Tinted, clear (Liquid Glass) and StandBy at night draw in one colour; there the current and cheap slots stay strong and the rest fade. The price, the cheapest line and the symbol are .widgetAccentable().
  • A bolt.fill symbol heads every family, including the lock screen's lines, as in Apple's own widgets. System fonts throughout.
  • The price changes with .contentTransition(.numericText()) between entries.
  • No UIKit: colours resolve through a SwiftUI ShapeStyle, and the background task reads the scene phase.
  • invalidatableContent() is not used: it marks content an interaction will change, and these widgets have no buttons or toggles.
  • An OSSignposter interval wraps the timeline build, and its log line carries the time.

Files

Only the files this branch owns: appleApp/FTWWidgets/**, FTWShared/**, Tests/FTWSharedTests/**, State/SiteModel.swift, FTW/FTWApp.swift (the background task), FTW-Info.plist (background keys). One new test file, Tests/FTWKitTests/WidgetPricesTests.swift, covers the background refresh against the simulated box; it touches no existing file. CacheRead gains a public init.

Checks

Check Result
swift test in appleApp/FTWKit (Xcode 27) 120 passed, 1 live test skipped. New: 16 timeline and wording tests, 4 schedule tests, 3 background write tests
xcodebuild FTW scheme, generic/platform=iOS Simulator Builds with 0 warnings
Small and medium on a new iPhone 17 simulator (iOS 26.5, Swedish), light and dark Renders from the App Group cache, quarter-hour prices
Lock screen rectangular and inline, same simulator, light and dark Renders; both lines fit beside the date
Home screen tinted and clear (Liquid Glass) Renders in one colour; current and cheap slots stand out
Same simulator, prices fetched 36 h earlier, home and lock screen Grey: "Inga priser efter sön 24:00 · öppna FTW", "Hämtat sön 06:01"; inline "Inga priser · öppna FTW"
Timeline build in the widget, about 120 entries from 192 quarter-hour slots, Debug build in the simulator, file read included 14–20 ms (37–40 ms before reading the slots once)

The simulator got its prices from a fixture written to the App Group container in the shape sharePrices writes, since the demo writes nothing and there is no box in the simulator. The home-screen widgets were placed through SpringBoard's layout file, the lock-screen ones by hand in the simulator.

Open

  • DCO: the commits carry no Signed-off-by. CONTRIBUTING asks for it, and an agent should not sign in Fredrik's name.
  • No StandBy picture. The simulator cannot enter StandBy (it needs a charging, locked phone on its side). systemSmall is the StandBy family, and it uses no colour that depends on the background it loses there. Check on a device.
  • The background task is not proven end to end. BGTaskScheduler.submit fails in the simulator, and the refresh needs a paired box. The fetch and write are tested against the simulated box. On a device: pair, background the app after 13:05, and check log stream --predicate 'subsystem == "energy.ftw.app"'.
  • If the device key needs a passkey prompt (a device enrolled before the local copy), a background run cannot show one. The connect fails and connectHelp is set until the next foreground connect clears it.
  • For the car view branch: the app's PriceChart uses the same BarMark(xStart:xEnd:y:), which draws bars on their side, and it says "incl. fees and VAT" whatever the box includes. PriceGlance.included and PriceGlance.text can replace that line.

🤖 Generated with Claude Code

frahlg and others added 4 commits October 12, 2026 17:56
… end

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
@frahlg

frahlg commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Included in #14, which lands the whole iPhone M1 app as one PR. The branch stays.

@frahlg frahlg closed this Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant