diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png index 1212077..6c9d281 100644 Binary files a/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon-dark.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png index 5b79482..cc974c0 100644 Binary files a/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon-tinted.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon.png index 2ef738b..df5922d 100644 Binary files a/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon.png and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/FuelBoard/Assets.xcassets/AppIconArtwork.imageset/AppIconArtwork.png b/FuelBoard/Assets.xcassets/AppIconArtwork.imageset/AppIconArtwork.png index 2ef738b..df5922d 100644 Binary files a/FuelBoard/Assets.xcassets/AppIconArtwork.imageset/AppIconArtwork.png and b/FuelBoard/Assets.xcassets/AppIconArtwork.imageset/AppIconArtwork.png differ diff --git a/FuelBoard/Assets.xcassets/AppIconPreview.imageset/AppIconPreview.png b/FuelBoard/Assets.xcassets/AppIconPreview.imageset/AppIconPreview.png index 2ef738b..df5922d 100644 Binary files a/FuelBoard/Assets.xcassets/AppIconPreview.imageset/AppIconPreview.png and b/FuelBoard/Assets.xcassets/AppIconPreview.imageset/AppIconPreview.png differ diff --git a/FuelBoard/Assets.xcassets/AppIconPreviewDark.imageset/AppIconPreviewDark.png b/FuelBoard/Assets.xcassets/AppIconPreviewDark.imageset/AppIconPreviewDark.png index 1212077..6c9d281 100644 Binary files a/FuelBoard/Assets.xcassets/AppIconPreviewDark.imageset/AppIconPreviewDark.png and b/FuelBoard/Assets.xcassets/AppIconPreviewDark.imageset/AppIconPreviewDark.png differ diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 08f5711..5b7e010 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -222,15 +222,36 @@ struct ContentView: View { // opens the given tab; `-skipOnboarding` skips onboarding // without touching the stored flag. let args = ProcessInfo.processInfo.arguments +#if DEBUG showWidgetMock = args.contains("-widgets") // `-seedFavourite ` pins the first matching station // for Unleaded via the normal save path (keychain + app group) // so screenshot captures can show a populated Favourites tab. - if let i = args.firstIndex(of: "-seedFavourite"), i + 1 < args.count { - let query = args[i + 1] - if let station = FuelStore.loadStations() - .first(where: { $0.name.localizedCaseInsensitiveContains(query) }) { - FuelStore.saveFavourites([FavouriteEntry(station: station, fuel: .e10)]) + // Repeatable: each occurrence adds another favourite (QA temp). + // Optional ":fuel" suffix (e10|e5|diesel) picks the + // fuel, so the fuel-aware Trends open can be verified (QA temp). + let seedQueries = args.enumerated() + .filter { $0.element == "-seedFavourite" } + .compactMap { i, _ in + i + 1 < args.count ? args[i + 1] : nil + } + if !seedQueries.isEmpty { + let stations = FuelStore.loadStations() + var favs = seedQueries.compactMap { query in + let parts = query.split(separator: ":", maxSplits: 1) + let name = String(parts[0]) + let fuel = parts.count > 1 + ? FuelType(rawValue: String(parts[1])) ?? .e10 + : FuelType.e10 + return stations.first { $0.name.localizedCaseInsensitiveContains(name) } + .map { FavouriteEntry(station: $0, fuel: fuel) } + } + if favs.isEmpty, let q = seedQueries.first, + let s = stations.first(where: { $0.name.localizedCaseInsensitiveContains(q.split(separator: ":").first.map(String.init) ?? q) }) { + favs = [FavouriteEntry(station: s, fuel: .e10)] + } + if !favs.isEmpty { + FuelStore.saveFavourites(favs) } } if let i = args.firstIndex(of: "-tab"), i + 1 < args.count { @@ -241,6 +262,14 @@ struct ContentView: View { default: selectedTab = 0 } } + // QA temp: `-fuel ` sets the app-wide selected + // fuel so the fuel-aware Trends open can be verified (simulates + // the user having switched to the diesel tab). + if let i = args.firstIndex(of: "-fuel"), i + 1 < args.count, + let f = FuelType(rawValue: args[i + 1]) { + selectedFuel = f + FuelStore.saveSelectedFuel(f) + } // `-forceOfflineDump` / `-forceConnectionProblem` simulate the two // failure legs for the screenshot harness. The auto-refresh below // is skipped so the banner stays up (a live fetch would clear it). @@ -254,6 +283,9 @@ struct ContentView: View { : FuelStore.loadStations() dataStatus = .connectionProblem } +#else + showWidgetMock = false +#endif // Onboarding runs first on a fresh install — it owns the initial // permission prompts (location, notifications, and the data/local // network probe on the Data page). Location tracking and the first @@ -261,8 +293,13 @@ struct ContentView: View { // Launch-arg hook (UI-testing/screenshot harness, same pattern as // StationsView's `-showKeySheet`): skip onboarding without // touching the stored flag. +#if DEBUG + let shouldSkipOnboarding = args.contains("-skipOnboarding") +#else + let shouldSkipOnboarding = false +#endif if FuelStore.loadHasCompletedOnboarding() - || args.contains("-skipOnboarding") { + || shouldSkipOnboarding { locationManager.startForegroundTracking() // Geofences must follow the user even in the background: // wire the delegate hook (fires on every fix incl. background @@ -284,7 +321,14 @@ struct ContentView: View { updateLiveActivity() // Refresh only when the cache is stale (twice-a-day policy). // Skipped under the force-* hooks so the banner stays up. - if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") && !args.contains("-forceHistoryFailure") { +#if DEBUG + let shouldSkipInitialRefresh = args.contains("-forceOfflineDump") + || args.contains("-forceConnectionProblem") + || args.contains("-forceHistoryFailure") +#else + let shouldSkipInitialRefresh = false +#endif + if !shouldSkipInitialRefresh { Task { await refresh() } } } else { diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index dbc9463..13faf17 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -172,11 +172,13 @@ struct FavouritesView: View { ) } .onAppear { +#if DEBUG // QA hook: launch with `-showTrends` to open the sheet // without a tap (same pattern as -showKeySheet). if ProcessInfo.processInfo.arguments.contains("-showTrends") { showTrends = true } +#endif } } } diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index 459798d..ee4c513 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -329,7 +329,7 @@ struct OnboardingView: View { /// Owns the system permission requests during onboarding and publishes /// their outcomes so the pages can reflect them live. @MainActor -final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { +final class OnboardingPermissionPrompter: NSObject, ObservableObject, CLLocationManagerDelegate { @Published private(set) var locationGranted = false @Published private(set) var locationDenied = false @Published private(set) var notificationsGranted = false @@ -358,11 +358,14 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur Task { @MainActor in switch settings.authorizationStatus { case .notDetermined: - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in - Task { @MainActor in - self.notificationsGranted = granted - self.notificationsDenied = !granted - } + do { + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + self.notificationsGranted = granted + self.notificationsDenied = !granted + } catch { + self.notificationsGranted = false + self.notificationsDenied = true } case .authorized: self.notificationsGranted = true diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 5feef33..1cba09b 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -408,7 +408,7 @@ struct SettingsView: View { Label("In range", systemImage: "location.circle.fill") Spacer() HStack(spacing: 6) { - if let coord = status.coordinate { + if status.coordinate != nil { Circle() .fill(inRangeColor(status.inRangeCount)) .frame(width: 10, height: 10) @@ -567,13 +567,17 @@ struct SettingsView: View { testAlertMessage = NSLocalizedString("Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again.", comment: "") default: // First time — ask, then fire if granted. - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in - Task { @MainActor in + Task { @MainActor in + do { + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) if granted { onTestAlert() } else { testAlertMessage = NSLocalizedString("Notifications weren't allowed, so no test alert was sent.", comment: "") } + } catch { + testAlertMessage = NSLocalizedString("Couldn't request notification permission right now. Please try again.", comment: "") } } } diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index 5ae274a..a2012fa 100644 --- a/FuelBoard/SiriShortcuts.swift +++ b/FuelBoard/SiriShortcuts.swift @@ -22,18 +22,6 @@ import SwiftUI /// "super unleaded") so Siri matches how people actually ask. The app UI's /// FuelType.displayName is untouched. -// MARK: - Fuel parameter - -extension FuelType: AppEnum { - static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel" - - static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [ - .e10: "Unleaded", - .e5: "Premium", - .diesel: "Diesel", - ] -} - // MARK: - Intent struct CheapestFuelIntent: AppIntent { diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index 094e98e..aed1fa6 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -44,7 +44,7 @@ struct StationsView: View { } else { ratio = "\(totalCount)" } - if let location { + if location != nil { if sortMode == .closest { return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated" } @@ -174,11 +174,13 @@ struct StationsView: View { .navigationTitle("FuelBoard") .navigationBarTitleDisplayMode(.large) .onAppear { +#if DEBUG // QA hook: launch with `-showKeySheet` to verify the legend // sheet without driving a tap (simctl has no tap command). if ProcessInfo.processInfo.arguments.contains("-showKeySheet") { showKey = true } +#endif } .toolbar { ToolbarItem(placement: .topBarTrailing) { diff --git a/FuelBoard/TrendsView.swift b/FuelBoard/TrendsView.swift index dfef426..b8303a5 100644 --- a/FuelBoard/TrendsView.swift +++ b/FuelBoard/TrendsView.swift @@ -13,6 +13,8 @@ struct TrendsView: View { /// All favourites (fuel-scoped entries) — the sheet derives the active /// fuel's stations and which fuels have favourites. let favourites: [FavouriteEntry] + /// The fuel the Favourites tab is showing when the Trends button is + /// tapped — the sheet must open on the same fuel, not always .e10. let selectedFuel: FuelType let priceDisplayStyle: PriceDisplayStyle @@ -32,6 +34,21 @@ struct TrendsView: View { @State private var loadFailed = false @State private var firstSnapshot: String? + /// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet + /// opens where the user was — same pattern as FavouritesView. + init(favourites: [FavouriteEntry], + selectedFuel: FuelType, + priceDisplayStyle: PriceDisplayStyle, + onHistoryUnavailable: (() -> Void)? = nil, + onHistoryRecovered: (() -> Void)? = nil) { + self.favourites = favourites + self.selectedFuel = selectedFuel + self.priceDisplayStyle = priceDisplayStyle + self.onHistoryUnavailable = onHistoryUnavailable + self.onHistoryRecovered = onHistoryRecovered + _fuel = State(initialValue: selectedFuel) + } + enum TrendsMode: String, CaseIterable, Identifiable { case price case vsCheapest @@ -85,7 +102,34 @@ struct TrendsView: View { } } + /// Y-domain hugging the displayed data (RevenueCat-style tight scale). + /// Without it Swift Charts pads a sparse series (e.g. 3 days of ~150.7p) + /// out to a 0→200 axis, wasting the plot area. Pad by ~4% of the range + /// (min 0.5p) so the line doesn't kiss the top edge. + private var yDomain: ClosedRange { + let values = displaySeries.flatMap { $0.points.map(\.pence) } + guard let lo = values.min(), let hi = values.max() else { + return 0...1 + } + let pad = max((hi - lo) * 0.04, 0.5) + return (lo - pad)...(hi + pad) + } + + /// Depth of each series' gradient fill band, in pence. Band instead of + /// fill-to-axis: with 2+ favourites, full-height unstacked fills overlap + /// and wash out the lower lines (observed on device 2026-08-17). A band + /// of ~18% of the y-range under each line keeps every area visible and + /// gives the RevenueCat "fade under the line" look. + private var fillBand: Double { + let values = displaySeries.flatMap { $0.points.map(\.pence) } + guard let lo = values.min(), let hi = values.max(), hi > lo else { + return 1.0 + } + return max((hi - lo) * 0.18, 0.8) + } + private func load() async { +#if DEBUG // QA hook: force the unreachable state for screenshots (same pattern // as -showTrends / -forceConnectionProblem). Runs before the fetch so // the retry state renders immediately with no spinner flash. @@ -95,6 +139,7 @@ struct TrendsView: View { onHistoryUnavailable?() return } +#endif isLoading = true loadFailed = false defer { isLoading = false } @@ -132,6 +177,38 @@ struct TrendsView: View { } } + // MARK: - RevenueCat-style headline metric + + /// Overall average across displayed series (mean of per-station means) — + /// the big figure in the card header, Price and vs-cheapest mode both. + private var headlineAverage: Double? { + let avgs = displaySeries.compactMap { FuelHistoryStore.averagePence($0.points) } + guard !avgs.isEmpty else { return nil } + return avgs.reduce(0, +) / Double(avgs.count) + } + + /// Change over the window: mean of each series' (last − first) point. + /// Positive = prices/gaps rose; negative = fell. Hidden while loading or + /// when any displayed series lacks two points. + private var headlineDelta: Double? { + let deltas = displaySeries.compactMap { history -> Double? in + guard let first = history.points.first?.pence, + let last = history.points.last?.pence else { return nil } + return last - first + } + guard deltas.count == displaySeries.count else { return nil } + return deltas.reduce(0, +) / Double(deltas.count) + } + + /// Good = prices fell (saving money) or the gap to cheapest narrowed. + private var deltaIsGood: Bool { + (headlineDelta ?? 0) <= 0 + } + + private var periodLabel: String { + "Last \(rangeDays) days" + } + var body: some View { NavigationStack { VStack(spacing: 14) { @@ -195,11 +272,7 @@ struct TrendsView: View { } else if !hasEnoughData { emptyState // single point — nothing to draw yet } else { - VStack(spacing: 12) { - chart - legend - legendFooter - } + revenueCatCard } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -228,21 +301,103 @@ struct TrendsView: View { .padding(.vertical, 24) } + /// RevenueCat-style card: rounded surface with a headline metric (period + /// average + change), the multi-series area chart, then the key. Mirrors + /// the mockup approved after the dashboard reference screenshot. + private var revenueCatCard: some View { + VStack(alignment: .leading, spacing: 10) { + metricHeader + metricFooter + chart + legend + legendFooter + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + } + + /// Headline row: big average figure, signed change, right-aligned period. + /// Colours stay on the app palette — green when the move is good + /// (prices fell / gap narrowed), the accent tint when it rose. + @ViewBuilder + private var metricHeader: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let headAvg = headlineAverage { + Text(yLabel(headAvg)) + .font(.system(size: 30, weight: .bold, design: .default)) + .monospacedDigit() + .foregroundStyle(.primary) + if let delta = headlineDelta, delta != 0 { + Label( + "\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p", + systemImage: deltaIsGood ? "arrow.down.right" : "arrow.up.right" + ) + .font(.system(size: 13, weight: .semibold)) + .labelStyle(.titleAndIcon) + .foregroundStyle(deltaIsGood ? Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) : Color.accentColor) + } + } + Spacer() + Text(periodLabel) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + /// One-line descriptor under the headline so the big figure (period + /// average across favourites) and the arrowed delta (change over the + /// window) are self-explanatory. + private var metricFooter: some View { + Text(chartFooterText) + .font(.caption2) + .foregroundStyle(.secondary) + } + + private var chartFooterText: String { + switch mode { + case .price: + return "Average across favourites · change since the first day" + case .vsCheapest: + return "Average gap above the day's cheapest · change since the first day" + } + } + private var chart: some View { // NOTE: each LineMark MUST carry an explicit `series:` — without it // Swift Charts merges every station's points into ONE polyline // (points connect across stations, so only the first station's line // is recognisable). The outer ForEach keeps one chart with N series; // per-mark foregroundStyle then colours each series from the palette. + // The AreaMark under each LineMark adds the gradient fill. + // + // stacking: .unstacked is essential — the standard stacking mode + // piles each series' area ON TOP of the previous series' area, so + // with 2+ favourites the fills land in the wrong place (seen on + // device 2026-08-17: fills shifted/overlapping vs the lines). + // .unstacked draws each area from its own line down to the axis, + // the RevenueCat look. + // Two passes: ALL AreaMarks first, then ALL LineMarks. Same-series + // marks composite in insertion order — if fills and lines interleave, + // a later series' fill paints OVER an earlier series' line (the + // bottom favourite's line vanished under the next fill; seen on + // device 2026-08-17). Drawing every fill before every line keeps all + // lines on top of all fills, RevenueCat style. (Mark builders are + // extracted into small helpers — the inline expression grew past the + // type-checker's budget.) Chart { ForEach(displaySeries) { history in ForEach(history.points) { point in - LineMark( - x: .value("Date", point.date), - y: .value("Price", point.pence), - series: .value("Station", history.name) - ) - .foregroundStyle(seriesColor(index(of: history.stationID))) + areaMark(point, series: history.name, + color: seriesColor(index(of: history.stationID))) + } + } + ForEach(displaySeries) { history in + ForEach(history.points) { point in + lineMark(point, series: history.name, + color: seriesColor(index(of: history.stationID))) } } } @@ -251,31 +406,83 @@ struct TrendsView: View { AxisGridLine() AxisTick() AxisValueLabel(format: .dateTime.month().day()) + .font(.caption2) + .foregroundStyle(.secondary) } } + .chartYScale(domain: yDomain) .chartYAxis { AxisMarks { value in AxisGridLine() AxisValueLabel { if let pence = value.as(Double.self) { Text(yLabel(pence)) + .font(.caption2) + .foregroundStyle(.secondary) } } } } - .frame(height: 260) + .frame(height: 190) + } + + /// One gradient-filled area band under a single point of a series. + /// yStart/yEnd bound the fill to `fillBand` pence under the line + /// instead of to the axis: full-height fills overlap and bury lower + /// lines when several favourites overlap (observed on device + /// 2026-08-17). + private func areaMark(_ point: PricePoint, series: String, + color: Color) -> some ChartContent { + AreaMark( + x: .value("Date", point.date), + yStart: .value("Price start", point.pence), + yEnd: .value("Price end", point.pence - fillBand), + series: .value("Station", series) + ) + .foregroundStyle( + LinearGradient( + colors: [color.opacity(0.30), color.opacity(0.02)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.monotone) + } + + /// One line segment point for a series, drawn above every fill. + private func lineMark(_ point: PricePoint, series: String, + color: Color) -> some ChartContent { + LineMark( + x: .value("Date", point.date), + y: .value("Price", point.pence), + series: .value("Station", series) + ) + .foregroundStyle(color) + .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + .interpolationMethod(.monotone) } private func index(of stationID: String) -> Int { orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0 } + /// Key rows in the same vertical order as the chart: most expensive at the + /// top working down to cheapest at the bottom (the chart's y-axis puts + /// the cheapest line lowest). Sorts by average price — the bracketed + /// figure — descending, so the first key row matches the top line. + private var legendSeries: [StationHistory] { + displaySeries.sorted { + (FuelHistoryStore.averagePence($0.points) ?? 0) + > (FuelHistoryStore.averagePence($1.points) ?? 0) + } + } + private var legend: some View { VStack(alignment: .leading, spacing: 4) { - ForEach(Array(displaySeries.enumerated()), id: \.element.stationID) { index, history in + ForEach(legendSeries, id: \.stationID) { history in HStack(spacing: 8) { Circle() - .fill(seriesColor(index)) + .fill(seriesColor(index(of: history.stationID))) .frame(width: 8, height: 8) Text(history.name) .font(.caption) diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 11e8025..763620f 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -3,6 +3,17 @@ // Lives in the widget extension (the standard host for ActivityConfiguration). // 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. import ActivityKit import SwiftUI @@ -17,7 +28,7 @@ import WidgetKit struct FuelBoardLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in - // Lock Screen / banner presentation + // Lock Screen / banner / CarPlay small slot presentation FuelBoardLiveActivityView(context: context) } dynamicIsland: { context in DynamicIsland { @@ -44,43 +55,81 @@ struct FuelBoardLiveActivity: Widget { } } -/// Lock Screen / banner body — the main presentation. +/// Lock Screen / banner / CarPlay body — adaptive. private struct FuelBoardLiveActivityView: View { let context: ActivityViewContext var body: some View { Link(destination: context.state.mapsURL) { - HStack(spacing: 12) { - // LEFT — station brand glyph - Image(systemName: "fuelpump.circle.fill") - .font(.system(size: 32)) - .foregroundStyle(.green, .white) - .frame(width: 40, height: 40) - - // MIDDLE — fuel + station - VStack(alignment: .leading, spacing: 2) { - Text("Cheapest \(context.state.fuel.displayName)") - .font(.headline) - Text("\(context.state.stationName) · \(context.state.distanceText)") - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - // RIGHT — price - VStack(alignment: .trailing, spacing: 2) { - FuelStore.priceTextAttributed(context.state.pricePence, - style: context.state.priceDisplayStyle, - size: 22, weight: .bold) - Text("Tap for directions") - .font(.caption2) - .foregroundStyle(.secondary) - } + 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 } - .padding() } } + + /// 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) + .frame(width: 40, height: 40) + + // MIDDLE — fuel + station + VStack(alignment: .leading, spacing: 2) { + Text("Cheapest \(context.state.fuel.displayName)") + .font(.headline) + Text("\(context.state.stationName) · \(context.state.distanceText)") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // RIGHT — price + VStack(alignment: .trailing, spacing: 2) { + FuelStore.priceTextAttributed(context.state.pricePence, + style: context.state.priceDisplayStyle, + size: 22, weight: .bold) + Text("Tap for directions") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .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) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(8) + } } /// Dynamic Island expanded regions + compact trailing — price only. @@ -140,4 +189,4 @@ extension FuelBoardLiveActivityAttributes.ContentState { var mapsURL: URL { URL(string: "maps://?daddr=\(lat),\(lng)&t=d")! } -} +} \ No newline at end of file diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index d48a177..35d357d 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -156,7 +156,7 @@ struct FuelPriceTimelineProvider Data? { - var query: [String: Any] = [ + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecReturnData as String: true,