Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10e3b54d0c | ||
|
|
28bb245c93 | ||
|
|
d8412c132a | ||
|
|
1d62204a7a | ||
|
|
bdfbbc605f | ||
|
|
2c6e63f6b1 | ||
|
|
a6620d36f7 | ||
|
|
227cce4aa6 | ||
|
|
01bb3ba905 | ||
|
|
5d26509034 | ||
|
|
221b96097d | ||
|
|
b012232012 |
@@ -1,7 +1,22 @@
|
|||||||
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct FuelBoardApp: App {
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
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
|
/// Handles deep links that end up in the app. Widget taps arrive here in
|
||||||
/// two cases:
|
/// two cases:
|
||||||
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
||||||
|
|||||||
@@ -317,17 +317,6 @@ extension FuelType {
|
|||||||
case .diesel: return "Diesel"
|
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
|
/// 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
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
||||||
//
|
//
|
||||||
// ADAPTIVE LAYOUT: ActivityConfiguration shares ONE content view across the
|
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
||||||
// Lock Screen, banner, and the CarPlay small slot — there is no per-platform
|
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
||||||
// closure. So this view provides two layouts and lets ViewThatFits pick by
|
// declared (see note on the config), so iPhone/iPad always render the full
|
||||||
// available width:
|
// three-column card (`richBody`) with proper text sizes. `compactBody` is kept
|
||||||
// • richBody — the full three-column design (glyph · fuel+station · price),
|
// as a safety fallback should any surface ever hand this view a narrow width.
|
||||||
// wins wherever there's Lock Screen width (it carries an
|
//
|
||||||
// explicit minWidth so it can never be squeezed into the car).
|
// IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent —
|
||||||
// • compactBody — a minimal price-strip (glyph+fuel+price, station caption
|
// it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
|
||||||
// below) that wins in the CarPlay/Apple Watch Smart Stack
|
// CarPlay's small form comes from the Dynamic Island compact closures.
|
||||||
// small slot.
|
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
/// The Live Activity itself — registered in the widget bundle alongside the
|
/// The Live Activity itself — registered in the widget bundle alongside the
|
||||||
/// regular price widget. No CarPlay entitlement involved: this renders on the
|
/// regular price widget. No CarPlay entitlement involved: renders on the
|
||||||
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+).
|
/// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
|
||||||
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's
|
/// (incl. the island's compact form used in the car, display-only — FuelBoard
|
||||||
/// small Live Activity slot — display-only there (FuelBoard is not a
|
/// is not a CarPlay-enabled app, so car-side taps can't launch anything).
|
||||||
/// CarPlay-enabled app, so car-side taps can't launch anything).
|
|
||||||
struct FuelBoardLiveActivity: Widget {
|
struct FuelBoardLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
||||||
@@ -43,7 +41,7 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
} minimal: {
|
} minimal: {
|
||||||
@@ -52,6 +50,14 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.supplementalActivityFamilies([.small])
|
.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 {
|
private struct FuelBoardLiveActivityView: View {
|
||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
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 {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: context.state.mapsURL) {
|
||||||
ViewThatFits(in: .horizontal) {
|
Group {
|
||||||
// rich first — wins on full-width Lock Screen / banner
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||||
richBody
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||||
// CarPlay / Watch small slot is far narrower than this,
|
// how long the station name is. Truly small slots (CarPlay /
|
||||||
// so ViewThatFits reliably falls through to compactBody.
|
// Watch) offer much less → compact strip.
|
||||||
.frame(minWidth: 280)
|
if slotWidth >= compactWidthThreshold {
|
||||||
// compact fallback — the car's small supplemental slot
|
richBody
|
||||||
compactBody
|
} 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.
|
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
||||||
private var richBody: some View {
|
private var richBody: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// LEFT — station brand glyph
|
// LEFT — station brand glyph: the fuel-tinted pump on its own. No
|
||||||
Image(systemName: "fuelpump.circle.fill")
|
// background circle behind it (user request).
|
||||||
.font(.system(size: 32))
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green, .white)
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
// MIDDLE — fuel + station
|
// MIDDLE — fuel + station
|
||||||
@@ -90,6 +128,8 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
@@ -106,16 +146,15 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
|
/// Minimal strip for small space (CarPlay small / Watch smart stack):
|
||||||
/// glyph + fuel left, bold price right, truncated station below.
|
/// 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 {
|
private var compactBody: some View {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
Image(systemName: "fuelpump.fill")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
Text(context.state.fuel.displayName)
|
Text(context.state.fuel.displayName)
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption.bold())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
@@ -127,6 +166,8 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
.font(.system(size: 9))
|
.font(.system(size: 9))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-4
@@ -8,6 +8,7 @@
|
|||||||
// keychain → app-group defaults → fallback.
|
// keychain → app-group defaults → fallback.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
import Security
|
import Security
|
||||||
#if canImport(AppIntents)
|
#if canImport(AppIntents)
|
||||||
import 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)
|
#if canImport(AppIntents)
|
||||||
extension FuelType: AppEnum {}
|
extension FuelType: AppEnum {}
|
||||||
#endif
|
#endif
|
||||||
@@ -919,15 +934,19 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Onboarding — the app shows the intro screen on first launch only
|
// 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
|
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
|
||||||
// so the widget can see it too if ever needed.
|
// (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 {
|
static func loadHasCompletedOnboarding() -> Bool {
|
||||||
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
|
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveHasCompletedOnboarding(_ completed: Bool) {
|
static func saveHasCompletedOnboarding(_ completed: Bool) {
|
||||||
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
|
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Low-level keychain helpers
|
// MARK: Low-level keychain helpers
|
||||||
|
|||||||
Reference in New Issue
Block a user