Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28bb245c93 | ||
|
|
d8412c132a | ||
|
|
1d62204a7a | ||
|
|
bdfbbc605f | ||
|
|
2c6e63f6b1 | ||
|
|
a6620d36f7 | ||
|
|
227cce4aa6 | ||
|
|
01bb3ba905 | ||
|
|
5d26509034 | ||
|
|
221b96097d | ||
|
|
b012232012 | ||
|
|
727ef6e58f | ||
|
|
4c222cdf55 |
@@ -5,7 +5,7 @@ import WidgetKit
|
||||
/// The single floating notification banner. Network/offline state and tip
|
||||
/// outcomes all funnel through ONE style and render via the same chrome in
|
||||
/// ContentView — never pushing layout, always overlaid.
|
||||
enum AppBanner: Equatable {
|
||||
enum AppBanner: Hashable {
|
||||
case offlineDump(date: String)
|
||||
case connectionProblem
|
||||
case tip(TipStore.TipOutcome)
|
||||
@@ -84,6 +84,16 @@ struct ContentView: View {
|
||||
/// network/offline strip (ContentView renders it above the tabs).
|
||||
@StateObject private var tipStore = TipStore()
|
||||
|
||||
// --- Explicit banner animation state (robust fade-out) ---
|
||||
// SwiftUI's removal transition for the banner proved unreliable here
|
||||
// (the tip faded to nothing only in the cross-nil case, and even then
|
||||
// inconsistently). Drive opacity/offset explicitly instead so a
|
||||
// dismissal ALWAYS fades + slides up on ANY banner change.
|
||||
@State private var currentBanner: AppBanner?
|
||||
@State private var bannerOpacity: Double = 0
|
||||
@State private var bannerOffset: CGFloat = 0
|
||||
@State private var bannerClearTask: Task<Void, Never>?
|
||||
|
||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
||||
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||
/// redundant — the whole country sorted nearest-first, because "nearest"
|
||||
@@ -485,22 +495,75 @@ struct ContentView: View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .top) {
|
||||
rootTabView
|
||||
if let banner = activeBanner {
|
||||
if let banner = currentBanner {
|
||||
// Floating near the top of the screen, over the nav area —
|
||||
// overlays content (never pushes it) and sits above the
|
||||
// main content so it doesn't cover or block the list/pill
|
||||
// beneath it.
|
||||
// beneath it. Opacity/offset are driven explicitly by
|
||||
// reflectBanner so the fade-out reliably animates.
|
||||
floatingBanner(banner)
|
||||
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .top).combined(with: .opacity),
|
||||
// Slide up + fade on dismiss/timeout (one style for all).
|
||||
removal: .move(edge: .top).combined(with: .opacity)
|
||||
))
|
||||
.opacity(bannerOpacity)
|
||||
.offset(y: bannerOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: activeBanner)
|
||||
.task(id: activeBanner) {
|
||||
// Fires on launch with the current banner AND whenever it changes —
|
||||
// unlike onChange(computed) which can miss the first non-nil value
|
||||
// when dataStatus is set slightly after the view appears.
|
||||
reflectBanner(activeBanner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly animates the banner in/out — a robust replacement for the
|
||||
/// SwiftUI removal transition (which was silently not firing). Handles:
|
||||
/// - first appearance → fade + settle down from slightly above
|
||||
/// - any content change (tip→network, new tip) → fade the old out, then
|
||||
/// fade the new in — so a tip ALWAYS visibly fades away
|
||||
/// - dismissal → fade out + slide up, then clear after the fade
|
||||
private func reflectBanner(_ newBanner: AppBanner?) {
|
||||
guard newBanner != currentBanner else { return }
|
||||
bannerClearTask?.cancel()
|
||||
if let newBanner {
|
||||
if currentBanner == nil {
|
||||
present(newBanner)
|
||||
} else {
|
||||
// Crossfade: slide+fade the current out, then present the new.
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -28
|
||||
}
|
||||
bannerClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
present(newBanner)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.35)) {
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -28
|
||||
}
|
||||
bannerClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
currentBanner = nil
|
||||
bannerOpacity = 0
|
||||
bannerOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade a banner in from slightly above and settle it into place.
|
||||
private func present(_ banner: AppBanner) {
|
||||
currentBanner = banner
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -16
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
bannerOpacity = 1
|
||||
bannerOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
private var rootTabView: some View {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -604,7 +604,7 @@ final class TipStore: ObservableObject {
|
||||
]
|
||||
|
||||
/// A banner outcome: the exact copy to show plus its icon + tint.
|
||||
struct TipOutcome: Equatable {
|
||||
struct TipOutcome: Hashable {
|
||||
let message: String
|
||||
let icon: String
|
||||
let tint: Color
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user