diff --git a/AppStoreScreenshots/5-stations-dark.png b/AppStoreScreenshots/5-stations-dark.png new file mode 100644 index 0000000..2602ebf Binary files /dev/null and b/AppStoreScreenshots/5-stations-dark.png differ diff --git a/AppStoreScreenshots/6-favourites-dark.png b/AppStoreScreenshots/6-favourites-dark.png new file mode 100644 index 0000000..10fe4de Binary files /dev/null and b/AppStoreScreenshots/6-favourites-dark.png differ diff --git a/AppStoreScreenshots/7-alerts-dark.png b/AppStoreScreenshots/7-alerts-dark.png new file mode 100644 index 0000000..f51c649 Binary files /dev/null and b/AppStoreScreenshots/7-alerts-dark.png differ diff --git a/AppStoreScreenshots/8-settings-dark.png b/AppStoreScreenshots/8-settings-dark.png new file mode 100644 index 0000000..88978da Binary files /dev/null and b/AppStoreScreenshots/8-settings-dark.png differ diff --git a/AppStoreScreenshots/9-widgets-dark.png b/AppStoreScreenshots/9-widgets-dark.png new file mode 100644 index 0000000..afa0118 Binary files /dev/null and b/AppStoreScreenshots/9-widgets-dark.png differ diff --git a/FuelBoard/Assets.xcassets/AppIconPreview.imageset/AppIconPreview.png b/FuelBoard/Assets.xcassets/AppIconPreview.imageset/AppIconPreview.png new file mode 100644 index 0000000..f2eb1ea Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIconPreview.imageset/AppIconPreview.png differ diff --git a/FuelBoard/Assets.xcassets/AppIconPreview.imageset/Contents.json b/FuelBoard/Assets.xcassets/AppIconPreview.imageset/Contents.json new file mode 100644 index 0000000..605d578 --- /dev/null +++ b/FuelBoard/Assets.xcassets/AppIconPreview.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "AppIconPreview.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 96147c4..9beac76 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -33,6 +33,7 @@ struct ContentView: View { @State private var isLoading = false @State private var statusMessage = "" @State private var showOnboarding = false + @State private var showWidgetMock = false @State private var selectedTab = 0 @State private var locationManager = LocationManager() @StateObject private var monitor = ProximityMonitor() @@ -172,6 +173,9 @@ struct ContentView: View { showOnboarding = false } } + .fullScreenCover(isPresented: $showWidgetMock) { + WidgetMockScreen() + } .sheet(item: $monitor.pendingStationMap) { request in StationMapView(request: request) } @@ -181,6 +185,7 @@ struct ContentView: View { // opens the given tab; `-skipOnboarding` skips onboarding // without touching the stored flag. let args = ProcessInfo.processInfo.arguments + showWidgetMock = args.contains("-widgets") if let i = args.firstIndex(of: "-tab"), i + 1 < args.count { switch args[i + 1] { case "favourites": selectedTab = 1 diff --git a/FuelBoard/WidgetMockScreen.swift b/FuelBoard/WidgetMockScreen.swift new file mode 100644 index 0000000..9cea0f0 --- /dev/null +++ b/FuelBoard/WidgetMockScreen.swift @@ -0,0 +1,80 @@ +import SwiftUI +import WidgetKit + +/// Full-screen widget preview (launch arg `-widgets`): the real app icon +/// plus the REAL widget faces (Shared/FuelPriceWidgetViews.swift — the same +/// code the widget extension renders) at small + medium sizes. Used for App +/// Store screenshot captures; not reachable from the UI. +struct WidgetMockScreen: View { + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.09, green: 0.10, blue: 0.14), + Color(red: 0.02, green: 0.03, blue: 0.06) + ], + startPoint: .top, endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 26) { + // App icon + name, home-screen style + HStack(spacing: 16) { + Image("AppIconPreview") + .resizable() + .frame(width: 76, height: 76) + .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text("FuelBoard") + .font(.title2.bold()) + .foregroundStyle(.white) + Text("Cheapest fuel near you") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.65)) + } + } + .padding(.top, 8) + + widgetCard(.systemSmall, label: "Small widget") + widgetCard(.systemMedium, label: "Medium widget") + + Spacer(minLength: 0) + } + .padding(30) + } + } + + private func widgetCard(_ family: WidgetFamily, label: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(label.uppercased()) + .font(.caption.weight(.semibold)) + .tracking(1) + .foregroundStyle(.white.opacity(0.5)) + FuelPriceWidgetContent(entry: mockEntry(family: family), family: family, isWidget: false) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + } + } + + /// Real stations (loaded dump), cheapest-first for the chosen fuel — the + /// same selection the widget's own provider makes. + private func mockEntry(family: WidgetFamily) -> FuelPriceWidgetEntryData { + let fuel = FuelType.e10 + let all = FuelStore.loadStations() + let selling = all.filter { $0.prices[fuel] != nil } + .sorted { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) } + let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) } + let count = family == .systemSmall ? 1 : 3 + return FuelPriceWidgetEntryData( + date: Date(), + stations: Array(selling.prefix(count)), + fuel: fuel, + sort: .cheapest, + isFavourites: false, + location: location, + locationSource: location != nil ? "live" : "none", + unit: FuelStore.loadDistanceUnit() + ) + } +} diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 545cbe6..d48a177 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -2,20 +2,9 @@ import WidgetKit import SwiftUI import AppIntents -// Fuel colour wheel — mirrors the app's palette in StationsView.swift -// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium -// (#FFD60A), cyan = diesel (#64D2FF). Kept here (widget target) because -// Shared/FuelStore.swift is Foundation-only by design; if the app's copy -// ever moves to Shared, DELETE this duplicate to avoid redeclaration. -private extension FuelType { - var fuelColor: 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 colour wheel lives in Shared/FuelPriceWidgetViews.swift (moved from +// here 2026-08-16 so the app's widget preview screen uses the same colours). +// Shared/FuelStore.swift stays Foundation-only by design. // FuelBoard widget — petrol stations near you. ONE widget kind, TWO sizes: // .systemSmall: single station (the first of the configured result — the @@ -84,14 +73,8 @@ struct FuelPriceWidget: Widget { } struct FuelPriceEntry: TimelineEntry { - let date: Date - let stations: [FuelStation] // already filtered + sorted per config - let fuel: FuelType - let sort: SortMode // per-widget: cheapest | closest - let isFavourites: Bool // favourites mode: pinned stations, cheapest-first - let location: Coordinate? - let locationSource: String // "live" | "cached" | "none" - let unit: DistanceUnit // user's display unit for distances + let data: FuelPriceWidgetEntryData + var date: Date { data.date } } struct FuelPriceTimelineProvider: AppIntentTimelineProvider { @@ -109,9 +92,11 @@ struct FuelPriceTimelineProvider Color { - guard let cheapest else { return .gray } - switch RAGRating.rating(price: price, cheapest: cheapest) { - case .green: return .green - case .amber: return .orange - case .red: return .red - } + FuelPriceWidgetContent(entry: entry.data, family: family) } } diff --git a/Shared/FuelPriceWidgetViews.swift b/Shared/FuelPriceWidgetViews.swift new file mode 100644 index 0000000..31e8a0d --- /dev/null +++ b/Shared/FuelPriceWidgetViews.swift @@ -0,0 +1,187 @@ +import SwiftUI +import WidgetKit + +// Shared widget rendering — compiled by BOTH the widget extension and the +// app target. The app uses it for the in-app widget preview screen (launch +// arg `-widgets`); the widget extension uses it for real timelines. +// WidgetKit is available to the app target, so WidgetFamily is fine here. +// NOTE: FuelBoardTests only symlinks specific Shared files, so this file is +// NOT compiled by the SPM test target (it needs WidgetKit). + +// Fuel colour wheel — mirrors the app's palette in StationsView.swift +// (FuelType.tintColor): green = unleaded (#30D158), yellow = premium +// (#FFD60A), cyan = diesel (#64D2FF). Lives here because both the widget and +// the app preview need it; Shared/FuelStore.swift stays Foundation-only. +// (Previously a private extension in the widget target — deleted on the move.) +private extension FuelType { + var fuelColor: 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 + } + } +} + +/// Plain data (no WidgetKit types) so both the widget and the app preview can +/// construct entries. The widget's `FuelPriceEntry: TimelineEntry` wraps this. +struct FuelPriceWidgetEntryData { + let date: Date + let stations: [FuelStation] // already filtered + sorted per config + let fuel: FuelType + let sort: SortMode // per-widget: cheapest | closest + let isFavourites: Bool // favourites mode: pinned stations, cheapest-first + let location: Coordinate? + let locationSource: String // "live" | "cached" | "none" + let unit: DistanceUnit // user's display unit for distances +} + +/// The widget face, parameterised by family so the app can preview it. +/// `isWidget` controls WidgetKit-only chrome (widgetURL / Link rows) — in-app +/// previews pass false. +struct FuelPriceWidgetContent: View { + let entry: FuelPriceWidgetEntryData + let family: WidgetFamily + var isWidget = true + + var body: some View { + Group { + if entry.stations.isEmpty { + VStack(spacing: 6) { + Image(systemName: entry.isFavourites ? "star" : "fuelpump") + .font(.title2) + .foregroundStyle(.secondary) + Text(entry.isFavourites + ? "No favourite \(entry.fuel.displayName) stations" + : "No \(entry.fuel.displayName) stations") + .font(.caption2) + .foregroundStyle(.secondary) + } + } else if family == .systemSmall { + singleStation + } else { + stationList + } + } + } + + private var heading: String { + if entry.isFavourites { return "Favourite \(entry.fuel.displayName)" } + return entry.sort == .closest + ? "Closest \(entry.fuel.displayName)" + : "Cheapest \(entry.fuel.displayName)" + } + + private var singleStation: some View { + let station = entry.stations[0] + return VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: "fuelpump.fill") + .foregroundStyle(entry.fuel.fuelColor) + Text(heading) + .font(.caption2) + .foregroundStyle(.secondary) + } + Text(station.name) + .font(.headline) + .lineLimit(1) + if let price = station.prices[entry.fuel] { + FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green) + } + if let location = entry.location { + Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + Text("Tap for directions") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil)) + } + + /// How many station rows each family can fit: medium widgets are short + /// (158pt) so 3 rows is the safe cap; large/extraLarge have ~2× the height + /// and fit 5 compact rows. Small widgets use singleStation instead. + private var maxRows: Int { + switch family { + case .systemMedium: return 3 + default: return 5 + } + } + + private var stationList: some View { + let cheapest = entry.stations.compactMap { $0.prices[entry.fuel] }.min() + return VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: "fuelpump.fill") + .foregroundStyle(entry.fuel.fuelColor) + Text(heading) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text(entry.isFavourites ? "cheapest first" + : (entry.locationSource == "none" ? "by price" : "near you")) + .font(.caption2) + .foregroundStyle(.secondary) + } + ForEach(entry.stations.prefix(maxRows)) { station in + if isWidget { + Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) { + row(station, cheapest: cheapest) + } + .buttonStyle(.plain) + } else { + row(station, cheapest: cheapest) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func row(_ station: FuelStation, cheapest: Double?) -> some View { + HStack(spacing: 8) { + Text(station.name) + .font(.caption.weight(.semibold)) + .lineLimit(1) + if let location = entry.location { + Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng))) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if let price = station.prices[entry.fuel] { + HStack(spacing: 4) { + Circle() + .fill(ragColor(for: price, cheapest: cheapest)) + .frame(width: 6, height: 6) + FuelStore.priceTextAttributed(price, size: 12, weight: .bold) + } + } + } + } + + private func ragColor(for price: Double, cheapest: Double?) -> Color { + guard let cheapest else { return .gray } + switch RAGRating.rating(price: price, cheapest: cheapest) { + case .green: return .green + case .amber: return .orange + case .red: return .red + } + } +} + +/// Applies widgetURL only when a URL is given (WidgetKit-only modifier). +private struct WidgetURLModifier: ViewModifier { + let url: URL? + func body(content: Content) -> some View { + if let url { + content.widgetURL(url) + } else { + content + } + } +} diff --git a/build-pm/ModuleCache.noindex/Session.modulevalidation b/build-pm/ModuleCache.noindex/Session.modulevalidation index b1a7dc3..a3a5931 100644 --- a/build-pm/ModuleCache.noindex/Session.modulevalidation +++ b/build-pm/ModuleCache.noindex/Session.modulevalidation @@ -1 +1 @@ -1786805147.717644: Module build session file for module cache at Path(_str: "/Users/apt/workspace/fuelboard/build-pm/ModuleCache.noindex") +1786807226.939194: Module build session file for module cache at Path(_str: "/Users/apt/workspace/fuelboard/build-pm/ModuleCache.noindex") diff --git a/build-pm/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache b/build-pm/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache index 83dbb70..2c3d560 100644 Binary files a/build-pm/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache and b/build-pm/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache differ diff --git a/build-pm/info.plist b/build-pm/info.plist index 969521e..8ff0d31 100644 --- a/build-pm/info.plist +++ b/build-pm/info.plist @@ -3,7 +3,7 @@ LastAccessedDate - 2026-08-15T14:45:45Z + 2026-08-15T15:20:26Z WorkspacePath /Users/apt/workspace/fuelboard/FuelBoard.xcodeproj