Author SHA1 Message Date
FuelBoard Contributor 10e3b54d0c onboarding: persist completed flag keychain-first so it doesn't re-show
The completed flag lived only in app-group UserDefaults
(UserDefaults(suiteName: appGroupSuite)). On free SideStore accounts the
app-group container isn't provisioned, so the suite read as nil: save was a
silent no-op and load always returned false -> onboarding re-appeared on
every launch even after finishing and granting all permissions.

Switch to saveString/loadString (keychain-first, app-group mirror) under
onboardingCompletedKey, matching favourites/distance-unit persistence which
survives reinstall and works without a provisioning group. loadString falls
back to the app-group mirror, so anyone who previously completed on a
provisioned group keeps their flag; users on unprovisioned groups complete
once more and then it sticks.
2026-08-20 12:27:09 +01:00
FuelBoard Contributor 28bb245c93 live activity: fix vertical centering on lock screen
GeometryReader as the layout root pinned content top-left, and forcing
maxHeight:.infinity over-claimed the whole proposed height, centring the
content below true vertical centre (bigger gap above) on the Lock Screen.

Demote GeometryReader to a background side-channel that only measures the
slot width for the rich-vs-compact branch; the content stays intrinsic-sized
so the system vertically centres it itself.
2026-08-20 10:16:21 +01:00
FuelBoard Contributor d8412c132a live activity: remove the white/grey background circle behind the pump
User: 'Remove the white background circle from the live activity'. Drop
the explicit Circle badge and render the fuel-tinted fuelpump.fill on its
own (sized up 19 -> 28 since it no longer sits inside a disc).
2026-08-19 22:52:50 +01:00
FuelBoard Contributor 1d62204a7a live activity: explicit badge (dark circle + bright pump); fix alignment look
fuelpump.circle.fill's first hierarchical style hits the PUMP, not the
circle, so the earlier darkened colour landed on the pump and the circle
stayed white (user: 'I didn't want the fuel pump darker I wanted the
white circle darker'). Rebuild the brand glyph from two explicit shapes:
a light-grey (.86) Circle badge + a bright fuel-tinted fuelpump.fill, so
circle vs pump colours are unambiguous and the badge centres cleanly with
the two-line text block.

Add a DEBUG-only -qaLiveActivity <fuel> launch hook that starts a Live
Activity with a long station name so the Lock Screen can be rendered in
the Simulator and verified by pixels (pump bright green 43,193,81; badge
grey 202,201,202).
2026-08-19 22:46:57 +01:00
FuelBoard Contributor bdfbbc605f live activity: centre content vertically + darken pump circle
A GeometryReader hands its content the full proposed frame and pins it
top-left, so the Lock Screen card sat high instead of vertically centred
when the activity container offered extra height. Wrap the branch in
.frame(maxWidth:.infinity, maxHeight:.infinity, alignment:.center).

Also darken the pump circle to 82% of the raw fuel tint (mix 18% black) so
the white pump symbol keeps contrast against bright Lock Screen/card, esp.
for the light yellow premium / cyan diesel palette colours.
2026-08-19 22:26:37 +01:00
FuelBoard Contributor 2c6e63f6b1 live activity: tint pump glyph by monitored fuel
Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A,
cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and
Live Activity all read one definition (was app-only in StationsView, so the
widget couldn't see it). Tint the Live Activity rich card pump icon and the
Dynamic Island compactLeading pump by context.state.fuel.tintColor.
2026-08-19 22:18:56 +01:00
FuelBoard Contributor a6620d36f7 live activity: select rich vs compact by actual GeometryReader width
ViewThatFits measures each child's IDEAL (untruncated) width to decide
fit, and a flexible .frame(maxWidth:.infinity) middle column does not cap
that ideal — so a long station name (e.g. 5-mi Phoenix Filling Stations)
inflated richBody's ideal past the iPhone Lock Screen width and fell back
to the compact strip. Replace the ViewThatFits branch with a
GeometryReader that reads the ACTUAL proposed width and shows the full
three-column card at/above 280pt, compact below. Content-independent, so a
long name can no longer collapse the iPhone card.
2026-08-19 22:08:07 +01:00
FuelBoard Contributor 227cce4aa6 live activity: fix compact strip string interpolation (double backslash)
The compact CarPlay/Watch strip's second line had escaped interpolation
(\\( instead of \\() so it rendered the literal placeholder text
"context.state.stationName · context.state.distanceText" instead of the
actual station name and distance. Normalize to single-backslash interpolation.
2026-08-19 20:01:14 +01:00
FuelBoard Contributor 01bb3ba905 live activity: compact CarPlay strip shows fuel + price + station distance
Restore .supplementalActivityFamilies([.small]) + a ViewThatFits compact
fallback so CarPlay's small slot renders our shared compact strip (fuel type,
bold price, station · distance) instead of falling back to the Dynamic Island
compact closure, which showed the app name and omitted the distance the user
wants. Full iPhone/iPad card is preserved via the richMinWidth gate on
richBody plus its flexible, truncating middle column (long names can no longer
collapse it).
2026-08-19 18:31:36 +01:00
FuelBoard Contributor 5d26509034 live activity: always render full card (drop compact fallback)
Long station names inflated richBody's IDEAL width, so ViewThatFits (which
measures ideal, untruncated text) decided 'rich doesn't fit' and fell back to
the compact strip — that's why 5-mi / long-named activities rendered small
while 10-15-mi / short names stayed full. With .supplementalActivityFamilies
already removed, the body is only ever Lock-Screen width, so drop ViewThatFits
and compactBody entirely and always render the full three-column card; the
station caption line-limits + scale-downs + tail-truncates in place.
2026-08-19 16:39:49 +01:00
FuelBoard Contributor 221b96097d live activity: drop .small family so iPhone/iPad show full-width card
The active activity was rendering the narrow .small form on the iPhone Lock
Screen (squeezed, small text) because .supplementalActivityFamilies([.small])
made iOS eligible to present it that way. Remove it so iPhone/iPad always get
the full three-column card; CarPlay's small form still comes from the Dynamic
Island compact closures. Keep compactBody as a narrow-width safety fallback.
2026-08-19 16:09:02 +01:00
FuelBoard Contributor b012232012 live activity: robust full-vs-compact split; scale long station names
The adaptive layout picks the full three-column view when there's Lock
Screen width and a compact price strip in the small CarPlay/Watch slot.
Tighten the discriminant (rich ≥290pt, compact ≤220pt) so iPhone/iPad
always get the full view and only genuinely small slots get the compact
strip. Long station names used to clip at inconsistent points across
the rich/compact/Dynamic Island views; both bodies now scale + tail-
truncate so the caption stays consistent regardless of station name.
2026-08-19 13:58:54 +01:00
4 changed files with 141 additions and 49 deletions
+43
View File
@@ -1,7 +1,22 @@
import ActivityKit
import SwiftUI
@main
struct FuelBoardApp: App {
init() {
#if DEBUG
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
// Live Activity with a long station name so the Lock Screen / island
// layout can be rendered in the Simulator for visual QA.
let args = ProcessInfo.processInfo.arguments
if let idx = args.firstIndex(of: "-qaLiveActivity"),
args.indices.contains(idx + 1),
let fuel = FuelType(rawValue: args[idx + 1]) {
startQALiveActivity(fuel: fuel)
}
#endif
}
var body: some Scene {
WindowGroup {
ContentView()
@@ -9,6 +24,34 @@ struct FuelBoardApp: App {
}
}
#if DEBUG
private func startQALiveActivity(fuel: FuelType) {
let state = FuelBoardLiveActivityAttributes.ContentState(
fuel: fuel,
stationID: "qa-phoenix",
stationName: "Phoenix Filling Stations",
brand: "Phoenix",
pricePence: 1499,
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
distanceKM: 8.0,
lat: 51.5,
lng: -0.12,
updatedAt: Date()
)
let attrs = FuelBoardLiveActivityAttributes()
do {
let activity = try Activity.request(
attributes: attrs,
content: .init(state: state, staleDate: nil),
pushType: nil
)
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
} catch {
print("QA-LIVE-ACTIVITY FAILED: \(error)")
}
}
#endif
/// Handles deep links that end up in the app. Widget taps arrive here in
/// two cases:
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
-11
View File
@@ -317,17 +317,6 @@ extension FuelType {
case .diesel: return "Diesel"
}
}
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the
/// fuel-type tab icons and the title icon.
var tintColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
/// Fuel-type selector styled like a segmented control, with a coloured pump
@@ -4,27 +4,25 @@
// Shows the cheapest station for the pinned fuel within the app's chosen
// radius. Tapping anywhere opens Apple Maps directions to that station.
//
// ADAPTIVE LAYOUT: ActivityConfiguration shares ONE content view across the
// Lock Screen, banner, and the CarPlay small slot there is no per-platform
// closure. So this view provides two layouts and lets ViewThatFits pick by
// available width:
// richBody the full three-column design (glyph · fuel+station · price),
// wins wherever there's Lock Screen width (it carries an
// explicit minWidth so it can never be squeezed into the car).
// compactBody a minimal price-strip (glyph+fuel+price, station caption
// below) that wins in the CarPlay/Apple Watch Smart Stack
// small slot.
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
// declared (see note on the config), so iPhone/iPad always render the full
// three-column card (`richBody`) with proper text sizes. `compactBody` is kept
// as a safety fallback should any surface ever hand this view a narrow width.
//
// IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent
// it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
// CarPlay's small form comes from the Dynamic Island compact closures.
import ActivityKit
import SwiftUI
import WidgetKit
/// The Live Activity itself registered in the widget bundle alongside the
/// regular price widget. No CarPlay entitlement involved: this renders on the
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+).
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's
/// small Live Activity slot display-only there (FuelBoard is not a
/// CarPlay-enabled app, so car-side taps can't launch anything).
/// regular price widget. No CarPlay entitlement involved: renders on the
/// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
/// (incl. the island's compact form used in the car, display-only FuelBoard
/// is not a CarPlay-enabled app, so car-side taps can't launch anything).
struct FuelBoardLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
@@ -43,7 +41,7 @@ struct FuelBoardLiveActivity: Widget {
}
} compactLeading: {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
.foregroundStyle(context.state.fuel.tintColor)
} compactTrailing: {
FuelBoardLiveActivityPriceView(context: context)
} minimal: {
@@ -52,6 +50,14 @@ struct FuelBoardLiveActivity: Widget {
}
}
.supplementalActivityFamilies([.small])
// Why `.small` is kept: it lets the SHARED body render a compact form
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
// instead of falling back to the Dynamic Island compact closure
// which could NOT show the station distance the user wants on CarPlay.
// The full-width iPhone/iPad card is protected by the `richMinWidth`
// gate on `richBody` + its flexible, truncating middle column, so
// iPhone/iPad still get the full card; only truly small space picks
// the compact strip below.
}
}
@@ -59,27 +65,59 @@ struct FuelBoardLiveActivity: Widget {
private struct FuelBoardLiveActivityView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
/// Below this ACTUAL proposed width we show the compact strip (CarPlay
/// small / Watch smart stack); at/above it we show the full card. The
/// decision is made from the real width the system hands the body, read
/// via a background GeometryReader NOT ViewThatFits ideal-width
/// measurement (that's broken for truncating text: a long station name
/// inflated the "ideal" width past the iPhone Lock Screen and collapsed
/// the full card).
private let compactWidthThreshold: CGFloat = 280
/// Measured slot width (drives the rich-vs-compact branch). Measured in a
/// background GeometryReader so it does NOT act as the layout container:
/// a GeometryReader root pins content top-left, and forcing a
/// maxHeight:.infinity frame on it over-claims the whole proposed height,
/// centring the content below true vertical centre (bigger gap above) on
/// the Lock Screen. Measuring behind the scenes keeps the content
/// intrinsic-sized so the system vertically centres it itself.
@State private var slotWidth: CGFloat = 400
var body: some View {
Link(destination: context.state.mapsURL) {
ViewThatFits(in: .horizontal) {
// rich first wins on full-width Lock Screen / banner
richBody
// CarPlay / Watch small slot is far narrower than this,
// so ViewThatFits reliably falls through to compactBody.
.frame(minWidth: 280)
// compact fallback the car's small supplemental slot
compactBody
Group {
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
// full Lock Screen width (>= threshold) rich card, no matter
// how long the station name is. Truly small slots (CarPlay /
// Watch) offer much less compact strip.
if slotWidth >= compactWidthThreshold {
richBody
} else {
compactBody
}
}
// Fill the card width so the background measure reads the real
// slot, not the intrinsic content width.
.frame(maxWidth: .infinity)
// Side-channel width measurement never the layout container.
.background(
GeometryReader { geo in
Color.clear
.onAppear { slotWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in slotWidth = w }
}
)
}
}
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
private var richBody: some View {
HStack(spacing: 12) {
// LEFT station brand glyph
Image(systemName: "fuelpump.circle.fill")
.font(.system(size: 32))
.foregroundStyle(.green, .white)
// LEFT station brand glyph: the fuel-tinted pump on its own. No
// background circle behind it (user request).
Image(systemName: "fuelpump.fill")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(context.state.fuel.tintColor)
.frame(width: 40, height: 40)
// MIDDLE fuel + station
@@ -90,6 +128,8 @@ private struct FuelBoardLiveActivityView: View {
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -106,16 +146,15 @@ private struct FuelBoardLiveActivityView: View {
.padding()
}
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
/// glyph + fuel left, bold price right, truncated station below.
/// Minimal strip for small space (CarPlay small / Watch smart stack):
/// fuel type + bold price on one line, station · distance below.
/// Deliberately no app name and no "Tap for directions" CarPlay is
/// display-only, and the user's asks here are just fuel + price + distance.
private var compactBody: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(.green)
Text(context.state.fuel.displayName)
.font(.caption2.weight(.semibold))
.font(.caption.bold())
.lineLimit(1)
Spacer(minLength: 4)
FuelStore.priceTextAttributed(context.state.pricePence,
@@ -127,6 +166,8 @@ private struct FuelBoardLiveActivityView: View {
.font(.system(size: 9))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.truncationMode(.tail)
}
.padding(8)
}
+23 -4
View File
@@ -8,6 +8,7 @@
// keychain app-group defaults fallback.
import Foundation
import SwiftUI
import Security
#if canImport(AppIntents)
import AppIntents
@@ -99,6 +100,20 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
}
}
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
/// definition.
extension FuelType {
var tintColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
#if canImport(AppIntents)
extension FuelType: AppEnum {}
#endif
@@ -919,15 +934,19 @@ struct FuelStore {
}
// MARK: Onboarding the app shows the intro screen on first launch only
// (a test button in the Alerts tab re-opens it). Stored in the app group
// so the widget can see it too if ever needed.
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
// (with an app-group mirror) for the same reason as favourites/distance
// unit: free SideStore accounts don't provision the app-group container,
// so an app-group-only flag silently fails to save AND reloads as false,
// making onboarding re-appear on every launch. Keychain survives reinstall
// and is shared with the extension.
static func loadHasCompletedOnboarding() -> Bool {
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
}
static func saveHasCompletedOnboarding(_ completed: Bool) {
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
}
// MARK: Low-level keychain helpers