Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d26509034 | ||
|
|
221b96097d | ||
|
|
b012232012 | ||
|
|
727ef6e58f | ||
|
|
4c222cdf55 | ||
|
|
cfd435d12a | ||
|
|
8936643e0e | ||
|
|
ba83f9e1c0 | ||
|
|
57dffb6c5e |
+155
-38
@@ -2,6 +2,15 @@ import SwiftUI
|
||||
import CoreLocation
|
||||
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: Hashable {
|
||||
case offlineDump(date: String)
|
||||
case connectionProblem
|
||||
case tip(TipStore.TipOutcome)
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -70,6 +79,20 @@ struct ContentView: View {
|
||||
@State private var selectedTab = 0
|
||||
@State private var locationManager = LocationManager()
|
||||
@StateObject private var monitor = ProximityMonitor()
|
||||
/// Owns tip purchases; the single instance is shared down to Settings.
|
||||
/// Its transient outcome rides the SAME status-banner chrome as the
|
||||
/// 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
|
||||
@@ -455,38 +478,91 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
statusBannerView
|
||||
rootTabView
|
||||
/// The one banner to show right now. Network/offline and tip outcomes all
|
||||
/// funnel here and render through the SAME floating chrome. A transient
|
||||
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
|
||||
/// network state for its ~3.5 s.
|
||||
private var activeBanner: AppBanner? {
|
||||
if let outcome = tipStore.outcome { return .tip(outcome) }
|
||||
switch dataStatus {
|
||||
case .live: return nil
|
||||
case .offlineDump(let date): return .offlineDump(date: date)
|
||||
case .connectionProblem: return .connectionProblem
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBannerView: some View {
|
||||
switch dataStatus {
|
||||
case .offlineDump(let date):
|
||||
let title = offlineTitle(date: date)
|
||||
statusBanner(
|
||||
icon: "wifi.slash",
|
||||
tint: .orange,
|
||||
title: title,
|
||||
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||
accessibilityLabel: date.isEmpty
|
||||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||
)
|
||||
case .connectionProblem:
|
||||
statusBanner(
|
||||
icon: "wifi.exclamationmark",
|
||||
tint: .red,
|
||||
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||
)
|
||||
case .live:
|
||||
EmptyView()
|
||||
private var mainContent: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .top) {
|
||||
rootTabView
|
||||
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. Opacity/offset are driven explicitly by
|
||||
// reflectBanner so the fade-out reliably animates.
|
||||
floatingBanner(banner)
|
||||
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||
.opacity(bannerOpacity)
|
||||
.offset(y: bannerOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,13 +593,51 @@ struct ContentView: View {
|
||||
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||
}
|
||||
|
||||
/// Shared status-strip chrome: a tappable card pinned above the tabs.
|
||||
/// Tapping retries the live fetch from ANY screen — no pull gesture
|
||||
/// needed, so the offline banner isn't trapped on the Stations tab.
|
||||
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
|
||||
Button {
|
||||
/// ONE floating banner style for all notifications. Network/offline and
|
||||
/// tip outcomes share the same chrome; only the tap action differs —
|
||||
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
|
||||
@ViewBuilder
|
||||
private func floatingBanner(_ banner: AppBanner) -> some View {
|
||||
switch banner {
|
||||
case .offlineDump(let date):
|
||||
let title = offlineTitle(date: date)
|
||||
statusBannerCard(
|
||||
icon: "wifi.slash", tint: .orange, title: title,
|
||||
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||
trailingIcon: "arrow.clockwise",
|
||||
accessibilityLabel: date.isEmpty
|
||||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||
) {
|
||||
Task { await refresh(force: true) }
|
||||
} label: {
|
||||
}
|
||||
case .connectionProblem:
|
||||
statusBannerCard(
|
||||
icon: "wifi.exclamationmark", tint: .red,
|
||||
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||
trailingIcon: "arrow.clockwise",
|
||||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||
) {
|
||||
Task { await refresh(force: true) }
|
||||
}
|
||||
case .tip(let outcome):
|
||||
statusBannerCard(
|
||||
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
|
||||
trailingIcon: "xmark", accessibilityLabel: outcome.message
|
||||
) {
|
||||
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single floating-banner chrome shared by every notification
|
||||
/// (network/offline + tip). Overlaid — never in flow — so it never moves
|
||||
/// the content beneath it.
|
||||
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
|
||||
trailingIcon: String, accessibilityLabel: String,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
@@ -533,12 +647,14 @@ struct ContentView: View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Image(systemName: trailingIcon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
@@ -551,13 +667,13 @@ struct ContentView: View {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
/// Pushes the current best-in-radius station into the Live Activity.
|
||||
@@ -645,6 +761,7 @@ struct ContentView: View {
|
||||
/// within the compiler's type-check budget.
|
||||
private var settingsTab: some View {
|
||||
SettingsView(
|
||||
tipStore: tipStore,
|
||||
distanceUnit: $distanceUnit,
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
alertsFuel: alertsFuel,
|
||||
|
||||
@@ -245,19 +245,27 @@ struct OnboardingView: View {
|
||||
) {
|
||||
if prompter.locationDenied {
|
||||
openSettings()
|
||||
} else if prompter.locationGranted {
|
||||
page = 2 // already decided — just move on
|
||||
} else {
|
||||
// The prompt fires on LEAVING this page (onChange(of:
|
||||
// page), forward advance only) — the description is on
|
||||
// screen until the user moves on.
|
||||
page = 2
|
||||
// Fire the system prompt NOW, while this page is still on
|
||||
// screen (after it's been read) and BEFORE advancing — so
|
||||
// the prompt never covers the next page's animation. Auto-
|
||||
// advance on grant moves us on once the user responds;
|
||||
// denying leaves the page's "Open Settings" path.
|
||||
prompter.requestLocation()
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
primaryButton(
|
||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
||||
) {
|
||||
// Prompt fires on leaving this page (same rule as Location).
|
||||
page = 3
|
||||
if prompter.notificationsDenied || prompter.notificationsGranted {
|
||||
page = 3 // decided either way — move on
|
||||
} else {
|
||||
// Same rule as Location: prompt now, before advancing.
|
||||
prompter.requestNotifications()
|
||||
}
|
||||
}
|
||||
case 3:
|
||||
// No permission on this page — prices need none. Straight on.
|
||||
|
||||
@@ -13,6 +13,9 @@ import WidgetKit
|
||||
/// freshness) is debug-only too — moved here so release builds don't expose
|
||||
/// relay plumbing.
|
||||
struct SettingsView: View {
|
||||
/// Shared tip-purchase store — owned by ContentView so its outcome can
|
||||
/// ride the app-wide status banner (above the tabs), not a local card.
|
||||
@ObservedObject var tipStore: TipStore
|
||||
@Binding var distanceUnit: DistanceUnit
|
||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||
@@ -53,9 +56,6 @@ struct SettingsView: View {
|
||||
/// Clears the armed fence (monitor.clearDebugFence).
|
||||
var onClearDebugFence: () -> Void = {}
|
||||
|
||||
@StateObject private var tipStore = TipStore()
|
||||
@State private var showTipAlert = false
|
||||
@State private var tipAlertMessage = ""
|
||||
@State private var testAlertMessage: String?
|
||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||
@@ -359,13 +359,6 @@ struct SettingsView: View {
|
||||
Text("About")
|
||||
}
|
||||
|
||||
if let message = tipStore.message {
|
||||
Section {
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.onAppear {
|
||||
@@ -610,9 +603,19 @@ final class TipStore: ObservableObject {
|
||||
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||
]
|
||||
|
||||
/// A banner outcome: the exact copy to show plus its icon + tint.
|
||||
struct TipOutcome: Hashable {
|
||||
let message: String
|
||||
let icon: String
|
||||
let tint: Color
|
||||
}
|
||||
|
||||
@Published private(set) var products: [String: Product] = [:]
|
||||
@Published private(set) var purchaseInProgress = false
|
||||
@Published private(set) var message: String?
|
||||
@Published private(set) var outcome: TipOutcome?
|
||||
|
||||
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
|
||||
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
||||
/// call — Ask to Buy approvals, payments finished on another device
|
||||
@@ -630,6 +633,7 @@ final class TipStore: ObservableObject {
|
||||
|
||||
deinit {
|
||||
updatesTask?.cancel()
|
||||
dismissTask?.cancel()
|
||||
}
|
||||
|
||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||
@@ -638,7 +642,30 @@ final class TipStore: ObservableObject {
|
||||
// Only acknowledge our own consumables (future products get their own).
|
||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||
await transaction.finish()
|
||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||
icon: "checkmark.circle.fill",
|
||||
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
|
||||
/// Re-triggering cancels the previous timer so rapid taps don't collide.
|
||||
private func showOutcome(_ message: String, icon: String, tint: Color) {
|
||||
dismissTask?.cancel()
|
||||
outcome = TipOutcome(message: message, icon: icon, tint: tint)
|
||||
dismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_500_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tap-to-dismiss — clears the banner and cancels the pending auto-dismiss.
|
||||
func dismissOutcome() {
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
|
||||
}
|
||||
|
||||
func displayPrice(for tier: TipTier) -> String {
|
||||
@@ -667,7 +694,11 @@ final class TipStore: ObservableObject {
|
||||
// Connect yet), still allow the attempt so the user sees a clear
|
||||
// outcome rather than a dead button.
|
||||
guard let product = products[tier.id] else {
|
||||
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
|
||||
icon: "info.circle.fill",
|
||||
tint: Color(.secondaryLabel)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -680,19 +711,35 @@ final class TipStore: ObservableObject {
|
||||
// Consume the consumable — otherwise StoreKit re-delivers
|
||||
// it through Transaction.updates on every launch.
|
||||
await transaction.finish()
|
||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||
icon: "checkmark.circle.fill",
|
||||
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||
)
|
||||
case .unverified:
|
||||
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
tint: .orange
|
||||
)
|
||||
}
|
||||
case .userCancelled:
|
||||
message = nil // silent — the user just closed the sheet
|
||||
dismissOutcome() // silent — the user just closed the sheet
|
||||
case .pending:
|
||||
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
||||
icon: "hourglass",
|
||||
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
||||
)
|
||||
@unknown default:
|
||||
message = nil
|
||||
dismissOutcome()
|
||||
}
|
||||
} catch {
|
||||
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
|
||||
showOutcome(
|
||||
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||
icon: "xmark.circle.fill",
|
||||
tint: .red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -51,7 +49,12 @@ struct FuelBoardLiveActivity: Widget {
|
||||
.font(.caption2.bold().monospacedDigit())
|
||||
}
|
||||
}
|
||||
.supplementalActivityFamilies([.small])
|
||||
// NOTE: deliberately NO `.supplementalActivityFamilies([.small])`.
|
||||
// That modifier makes iOS eligible to render this activity in the
|
||||
// narrow `.small` form on the iPhone/iPad Lock Screen, which is what
|
||||
// produced the squeezed, small-text card. Dropping it keeps the
|
||||
// full-width Lock Screen card on iPhone/iPad; CarPlay still shows a
|
||||
// small form via the Dynamic Island compact closures below.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,15 +64,17 @@ private struct FuelBoardLiveActivityView: View {
|
||||
|
||||
var body: some View {
|
||||
Link(destination: context.state.mapsURL) {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
// rich first — wins on full-width Lock Screen / banner
|
||||
// Always the full three-column card. The station caption is
|
||||
// line-limited + scale-down + tail-truncated, so a LONG station
|
||||
// name truncates in place instead of inflating this view's ideal
|
||||
// width and tricking ViewThatFits into falling back to the compact
|
||||
// strip (that is exactly what made 5-mi / long-named activities
|
||||
// render small while 10-15-mi / short names stayed full).
|
||||
//
|
||||
// No ViewThatFits / compactBody: with `.supplementalActivityFamilies`
|
||||
// removed, this body is only ever handed Lock-Screen width, so the
|
||||
// compact fallback was both dead weight and the cause of the bug.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +95,8 @@ private struct FuelBoardLiveActivityView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -106,30 +113,7 @@ private struct FuelBoardLiveActivityView: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
|
||||
/// glyph + fuel left, bold price right, truncated station below.
|
||||
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))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||
style: context.state.priceDisplayStyle,
|
||||
size: 15, weight: .bold)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
// NOTE: `compactBody` was removed — always render `richBody` (see body).
|
||||
}
|
||||
|
||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||
|
||||
@@ -216,10 +216,18 @@ enum FuelHistoryStore {
|
||||
// MARK: Network
|
||||
|
||||
/// The mirror pointer — used for the empty-state hint ("first snapshot
|
||||
/// landed …"). Non-fatal: nil just means no hint.
|
||||
static func fetchLatest(base: URL = mirrorBase,
|
||||
/// landed …"). Non-fatal: nil just means no hint. Deliberately bypasses
|
||||
/// the HTTP cache (reloadIgnoringLocalCacheData) so an OFFLINE fetch
|
||||
/// really fails instead of silently re-serving a cached pointer as a
|
||||
/// success — otherwise "no network" would look like fresh data and the
|
||||
/// offline banner would never fire. A short timeout surfaces the failure
|
||||
/// (and the banner) quickly on a connected-but-dead network.
|
||||
static func fetchLatest(base: URL = mirrorBase, timeout: TimeInterval = 10,
|
||||
session: URLSession = .shared) async -> MirrorLatest? {
|
||||
guard let (data, response) = try? await session.data(from: latestFileURL(base: base)),
|
||||
var req = URLRequest(url: latestFileURL(base: base))
|
||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
req.timeoutInterval = timeout
|
||||
guard let (data, response) = try? await session.data(for: req),
|
||||
(response as? HTTPURLResponse)?.statusCode == 200,
|
||||
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
|
||||
return nil
|
||||
|
||||
@@ -96,9 +96,13 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
||||
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
||||
data = cached
|
||||
} else {
|
||||
let (fetched, response) = try await URLSession.shared.data(
|
||||
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
|
||||
)
|
||||
// Bypass the HTTP cache + short timeout: an offline refresh must
|
||||
// FAIL (→ offline banner), never re-serve a stale cached dump as
|
||||
// a "successful" live fetch.
|
||||
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
|
||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
req.timeoutInterval = 10
|
||||
let (fetched, response) = try await URLSession.shared.data(for: req)
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
throw FuelProviderError.mirrorUnavailable
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user