Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d26509034 | ||
|
|
221b96097d | ||
|
|
b012232012 | ||
|
|
727ef6e58f | ||
|
|
4c222cdf55 |
@@ -5,7 +5,7 @@ import WidgetKit
|
|||||||
/// The single floating notification banner. Network/offline state and tip
|
/// The single floating notification banner. Network/offline state and tip
|
||||||
/// outcomes all funnel through ONE style and render via the same chrome in
|
/// outcomes all funnel through ONE style and render via the same chrome in
|
||||||
/// ContentView — never pushing layout, always overlaid.
|
/// ContentView — never pushing layout, always overlaid.
|
||||||
enum AppBanner: Equatable {
|
enum AppBanner: Hashable {
|
||||||
case offlineDump(date: String)
|
case offlineDump(date: String)
|
||||||
case connectionProblem
|
case connectionProblem
|
||||||
case tip(TipStore.TipOutcome)
|
case tip(TipStore.TipOutcome)
|
||||||
@@ -84,6 +84,16 @@ struct ContentView: View {
|
|||||||
/// network/offline strip (ContentView renders it above the tabs).
|
/// network/offline strip (ContentView renders it above the tabs).
|
||||||
@StateObject private var tipStore = TipStore()
|
@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
|
/// 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
|
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||||
/// redundant — the whole country sorted nearest-first, because "nearest"
|
/// redundant — the whole country sorted nearest-first, because "nearest"
|
||||||
@@ -485,22 +495,75 @@ struct ContentView: View {
|
|||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
rootTabView
|
rootTabView
|
||||||
if let banner = activeBanner {
|
if let banner = currentBanner {
|
||||||
// Floating near the top of the screen, over the nav area —
|
// Floating near the top of the screen, over the nav area —
|
||||||
// overlays content (never pushes it) and sits above the
|
// overlays content (never pushes it) and sits above the
|
||||||
// main content so it doesn't cover or block the list/pill
|
// 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)
|
floatingBanner(banner)
|
||||||
.padding(.top, geo.safeAreaInsets.top + 10)
|
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||||
.transition(.asymmetric(
|
.opacity(bannerOpacity)
|
||||||
insertion: .move(edge: .top).combined(with: .opacity),
|
.offset(y: bannerOffset)
|
||||||
// Slide up + fade on dismiss/timeout (one style for all).
|
|
||||||
removal: .move(edge: .top).combined(with: .opacity)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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 {
|
private var rootTabView: some View {
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ final class TipStore: ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
/// A banner outcome: the exact copy to show plus its icon + tint.
|
/// A banner outcome: the exact copy to show plus its icon + tint.
|
||||||
struct TipOutcome: Equatable {
|
struct TipOutcome: Hashable {
|
||||||
let message: String
|
let message: String
|
||||||
let icon: String
|
let icon: String
|
||||||
let tint: Color
|
let tint: Color
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -51,7 +49,12 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
.font(.caption2.bold().monospacedDigit())
|
.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 {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: context.state.mapsURL) {
|
||||||
ViewThatFits(in: .horizontal) {
|
// Always the full three-column card. The station caption is
|
||||||
// rich first — wins on full-width Lock Screen / banner
|
// line-limited + scale-down + tail-truncated, so a LONG station
|
||||||
richBody
|
// name truncates in place instead of inflating this view's ideal
|
||||||
// CarPlay / Watch small slot is far narrower than this,
|
// width and tricking ViewThatFits into falling back to the compact
|
||||||
// so ViewThatFits reliably falls through to compactBody.
|
// strip (that is exactly what made 5-mi / long-named activities
|
||||||
.frame(minWidth: 280)
|
// render small while 10-15-mi / short names stayed full).
|
||||||
// compact fallback — the car's small supplemental slot
|
//
|
||||||
compactBody
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +95,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,30 +113,7 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
|
// NOTE: `compactBody` was removed — always render `richBody` (see body).
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||||
|
|||||||
Reference in New Issue
Block a user