Scaffold FuelBoard: petrol price widget + app (sample data, Maps deep-links, provider seam for Fuel Finder API)

This commit is contained in:
FuelBoard Contributor
2026-08-11 13:54:56 +01:00
commit b0a5b5a326
12 changed files with 1191 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
build/
build-release/
.DS_Store
*.xcuserstate
xcuserdata/
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FuelBoard</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.apt.fuelboard</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.apt.fuelboard.shared</string>
<string>$(AppIdentifierPrefix)com.apt.fuelboard</string>
</array>
</dict>
</plist>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FuelBoard Prices</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.apt.fuelboard</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.apt.fuelboard.shared</string>
<string>$(AppIdentifierPrefix)com.apt.fuelboard</string>
</array>
</dict>
</plist>
+411
View File
@@ -0,0 +1,411 @@
// !$*UTF8*$!
// FuelBoard — hand-crafted project (objectVersion 77, synchronized folders).
// Targets: FuelBoard (app) + FuelBoardWidgets (widget extension, embedded).
// Shared/ is a synchronized root group included in BOTH targets.
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
BB0000000000000000000003 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = BB0000000000000000000004 /* Project object */;
proxyType = 1;
remoteGlobalIDString = BB0000000000000000000005;
remoteInfo = FuelBoardWidgets;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
BB0000000000000000000006 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
BB0000000000000000000008 /* FuelBoard */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = FuelBoard;
sourceTree = "<group>";
};
BB0000000000000000000009 /* FuelBoardWidgets */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = FuelBoardWidgets;
sourceTree = "<group>";
};
BB000000000000000000000A /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
BB000000000000000000000B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB000000000000000000000C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
BB000000000000000000000D = {
isa = PBXGroup;
children = (
BB0000000000000000000008 /* FuelBoard */,
BB0000000000000000000009 /* FuelBoardWidgets */,
BB000000000000000000000A /* Shared */,
BB000000000000000000000E /* Products */,
);
sourceTree = "<group>";
};
BB000000000000000000000E /* Products */ = {
isa = PBXGroup;
children = (
BB0000000000000000000007 /* FuelBoard.app */,
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
BB000000000000000000000F /* FuelBoard */ = {
isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
buildPhases = (
BB0000000000000000000011 /* Sources */,
BB000000000000000000000B /* Frameworks */,
BB0000000000000000000012 /* Resources */,
BB0000000000000000000006 /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
BB0000000000000000000013 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
BB0000000000000000000008 /* FuelBoard */,
BB000000000000000000000A /* Shared */,
);
name = FuelBoard;
packageProductDependencies = (
);
productName = FuelBoard;
productReference = BB0000000000000000000007 /* FuelBoard.app */;
productType = "com.apple.product-type.application";
};
BB0000000000000000000005 /* FuelBoardWidgets */ = {
isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000014 /* Build configuration list for PBXNativeTarget "FuelBoardWidgets" */;
buildPhases = (
BB0000000000000000000015 /* Sources */,
BB000000000000000000000C /* Frameworks */,
BB0000000000000000000016 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
BB0000000000000000000009 /* FuelBoardWidgets */,
BB000000000000000000000A /* Shared */,
);
name = FuelBoardWidgets;
packageProductDependencies = (
);
productName = FuelBoardWidgets;
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
BB0000000000000000000004 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1600;
TargetAttributes = {
BB000000000000000000000F = {
CreatedOnToolsVersion = 16.0;
};
BB0000000000000000000005 = {
CreatedOnToolsVersion = 16.0;
};
};
};
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = BB000000000000000000000D;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = BB000000000000000000000E /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
BB000000000000000000000F /* FuelBoard */,
BB0000000000000000000005 /* FuelBoardWidgets */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
BB0000000000000000000012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000016 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
BB0000000000000000000011 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000015 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
BB0000000000000000000013 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = BB0000000000000000000005 /* FuelBoardWidgets */;
targetProxy = BB0000000000000000000003 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
BB0000000000000000000018 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
BB0000000000000000000019 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
BB000000000000000000001A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
BB000000000000000000001B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
BB000000000000000000001C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
BB000000000000000000001D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB0000000000000000000018 /* Debug */,
BB0000000000000000000019 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB000000000000000000001A /* Debug */,
BB000000000000000000001B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BB0000000000000000000014 /* Build configuration list for PBXNativeTarget "FuelBoardWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB000000000000000000001C /* Debug */,
BB000000000000000000001D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = BB0000000000000000000004 /* Project object */;
}
+218
View File
@@ -0,0 +1,218 @@
import SwiftUI
import CoreLocation
import WidgetKit
struct Coordinate: Equatable {
let lat: Double
let lng: Double
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var stations: [FuelStation] = []
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var location: Coordinate? = {
if let loc = FuelStore.loadLocation() { return Coordinate(lat: loc.lat, lng: loc.lng) }
return nil
}()
@State private var isLoading = false
@State private var statusMessage = ""
@State private var locationManager = LocationManager()
private var sortedStations: [FuelStation] {
stations
.filter { $0.prices[selectedFuel] != nil }
.sorted { lhs, rhs in
// Prefer distance when we have a location, otherwise price.
guard let location else {
return lhs.prices[selectedFuel]! < rhs.prices[selectedFuel]!
}
let lScore = lhs.distanceKM(to: location.lat, lng2: location.lng) * 0.4 +
lhs.prices[selectedFuel]! * 0.001
let rScore = rhs.distanceKM(to: location.lat, lng2: location.lng) * 0.4 +
rhs.prices[selectedFuel]! * 0.001
return lScore < rScore
}
}
var body: some View {
NavigationStack {
List {
Section {
Text("Cheapest \(selectedFuel.displayName) near you. Tap a station for directions.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Fuel type") {
Picker("Fuel type", selection: $selectedFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
.pickerStyle(.segmented)
.onChange(of: selectedFuel) { _, newValue in
FuelStore.saveSelectedFuel(newValue)
WidgetCenter.shared.reloadAllTimelines()
}
}
Section("Stations") {
if isLoading {
HStack(spacing: 10) {
ProgressView()
Text("Fetching prices…")
}
} else if sortedStations.isEmpty {
Text("No \(selectedFuel.displayName) stations found.")
.foregroundStyle(.secondary)
} else {
ForEach(sortedStations) { station in
StationRow(station: station, fuel: selectedFuel, location: location)
}
}
}
Section("Location") {
if let location {
Text("Using location \(String(format: "%.4f, %.4f", location.lat, location.lng))")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("No location yet — allow location access to sort by distance.")
.font(.caption)
.foregroundStyle(.secondary)
}
Button("Update my location") {
locationManager.requestUpdate()
}
.font(.footnote)
}
if !statusMessage.isEmpty {
Section("Status") {
Text(statusMessage)
.font(.caption2)
.monospaced()
.textSelection(.enabled)
}
}
}
.navigationTitle("FuelBoard")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await refresh() }
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(isLoading)
}
}
.onAppear {
Task { await refresh() }
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task { await refresh() }
}
}
.onChange(of: locationManager.current) { _, newLocation in
if let newLocation {
location = newLocation
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
WidgetCenter.shared.reloadAllTimelines()
Task { await refresh() }
}
}
}
}
private func refresh() async {
isLoading = true
defer { isLoading = false }
do {
let fetched = try await FuelPriceProvider.active.fetchStations(near: location?.lat, lng: location?.lng, fuel: selectedFuel)
stations = fetched
FuelStore.saveStations(fetched)
WidgetCenter.shared.reloadAllTimelines()
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
} catch {
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached/sample data."
stations = FuelStore.loadStations().isEmpty ? SampleFuelProvider.sampleStations : FuelStore.loadStations()
}
}
}
struct StationRow: View {
let station: FuelStation
let fuel: FuelType
let location: Coordinate?
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(station.name)
.font(.headline)
Text("\(station.address), \(station.postcode)")
.font(.caption)
.foregroundStyle(.secondary)
if let location {
Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
if let price = station.prices[fuel] {
Text(String(format: "%.1fp", price))
.font(.title3.bold())
.foregroundStyle(.green)
}
Image(systemName: "arrow.triangle.turn.up.right.circle")
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.onTapGesture {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
}
}
}
// MARK: - Location manager
@MainActor
final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
@Published var current: Coordinate?
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
func requestUpdate() {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
manager.requestLocation()
case .authorizedWhenInUse, .authorizedAlways:
manager.requestLocation()
default:
break
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let loc = locations.last else { return }
current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Ignore the app still works price-sorted without location.
}
}
+10
View File
@@ -0,0 +1,10 @@
import SwiftUI
@main
struct FuelBoardApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct FuelBoardWidgetsBundle: WidgetBundle {
var body: some Widget {
FuelPriceWidget()
}
}
+164
View File
@@ -0,0 +1,164 @@
import WidgetKit
import SwiftUI
// FuelBoard widget cheapest petrol stations near you.
// systemMedium: top 4 stations with price + distance, each row opens Maps
// systemSmall: single cheapest station, whole widget opens Maps
// Taps deep-link to Apple Maps directions (http://maps.apple.com/?daddr=).
// Note: on the home screen Link opens Maps directly. Inside CarPlay the widget
// renders but can't launch Maps (widgets can only launch their own CarPlay app).
struct FuelPriceWidget: Widget {
let kind = "FuelPriceWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FuelPriceProvider2()) { entry in
FuelPriceWidgetView(entry: entry)
.containerBackground(for: .widget) {
Color(.systemBackground)
}
}
.configurationDisplayName("FuelBoard Prices")
.description("The cheapest fuel near you. Tap a station for directions.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
struct FuelPriceEntry: TimelineEntry {
let date: Date
let stations: [FuelStation] // already filtered + sorted for the selected fuel
let fuel: FuelType
let location: (lat: Double, lng: Double)?
}
struct FuelPriceProvider2: TimelineProvider {
func placeholder(in context: Context) -> FuelPriceEntry {
let fuel = FuelStore.loadSelectedFuel()
let sample = SampleFuelProvider.sampleStations
.filter { $0.prices[fuel] != nil }
.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil)
}
func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) {
completion(makeEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<FuelPriceEntry>) -> Void) {
let entry = makeEntry()
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
}
private func makeEntry() -> FuelPriceEntry {
let fuel = FuelStore.loadSelectedFuel()
let location = FuelStore.loadLocation().map { (lat: $0.lat, lng: $0.lng) }
var stations = FuelStore.loadStations()
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
let filtered = stations.filter { $0.prices[fuel] != nil }
let sorted: [FuelStation]
if let location {
sorted = filtered.sorted { lhs, rhs in
let lScore = lhs.distanceKM(to: location.lat, lng2: location.lng) * 0.4 + lhs.prices[fuel]! * 0.001
let rScore = rhs.distanceKM(to: location.lat, lng2: location.lng) * 0.4 + rhs.prices[fuel]! * 0.001
return lScore < rScore
}
} else {
sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
}
return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location)
}
}
struct FuelPriceWidgetView: View {
let entry: FuelPriceEntry
var body: some View {
Group {
if entry.stations.isEmpty {
VStack(spacing: 6) {
Image(systemName: "fuelpump")
.font(.title2)
.foregroundStyle(.secondary)
Text("No \(entry.fuel.displayName) stations")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if entry.stations.count == 1 || UIDevice.current.userInterfaceIdiom == .pad {
singleCheapest
} else {
stationList
}
}
}
private var singleCheapest: some View {
let station = entry.stations[0]
return VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
Text("Cheapest \(entry.fuel.displayName)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(station.brand)
.font(.headline)
.lineLimit(1)
if let price = station.prices[entry.fuel] {
Text(String(format: "%.1fp", price))
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.green)
}
if let location = entry.location {
Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
Text("Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetURL(station.mapsDirectionsURL)
}
private var stationList: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
Text("Cheapest \(entry.fuel.displayName)")
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text("Tap → Maps")
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(entry.stations.prefix(3)) { station in
Link(destination: station.mapsDirectionsURL ?? URL(string: "http://maps.apple.com")!) {
HStack(spacing: 8) {
Text(station.brand)
.font(.caption.weight(.semibold))
.lineLimit(1)
if let location = entry.location {
Text(String(format: "%.1f km", station.distanceKM(to: location.lat, lng2: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
if let price = station.prices[entry.fuel] {
Text(String(format: "%.1fp", price))
.font(.caption.weight(.bold))
.foregroundStyle(.green)
}
}
}
.buttonStyle(.plain)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
+112
View File
@@ -0,0 +1,112 @@
// FuelPriceProvider.swift data-source seam for FuelBoard.
//
// The official UK Fuel Finder API (api.fuelfinder.service.gov.uk) is the live
// source for station-level prices, but it requires GOV.UK One Login + OAuth 2.0
// client credentials. For the scaffold we ship SampleFuelProvider (realistic
// stations around a location, works offline), plus the FuelFinderProvider
// skeleton with the exact integration steps documented inline so wiring the
// live API later is a drop-in: change `activeProvider` to `.fuelFinder`.
import Foundation
// MARK: - Provider protocol
protocol FuelPriceProviding {
/// Fetch stations with prices. `location` may be nil (sort by price only).
/// Throws on failure so callers can fall back to cached/sample data.
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) async throws -> [FuelStation]
}
enum FuelPriceProvider {
static let active: FuelPriceProviding = SampleFuelProvider()
}
// MARK: - Sample provider (default for the scaffold)
/// Ships realistic stations around Cambridge (52.2053, 0.1218) so the app and
/// widget render meaningful data with zero setup. Prices in pence/litre.
struct SampleFuelProvider: FuelPriceProviding {
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) async throws -> [FuelStation] {
try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch
return Self.sampleStations
}
static let sampleStations: [FuelStation] = [
FuelStation(id: "s1", name: "Shell Cambridge Retail Park", brand: "Shell",
address: "12 Retail Park Way", postcode: "CB1 3EW",
lat: 52.1955, lng: 0.1380,
prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9],
priceUpdated: nil),
FuelStation(id: "s2", name: "Tesco Express Hills Road", brand: "Tesco",
address: "245 Hills Road", postcode: "CB2 8RP",
lat: 52.1809, lng: 0.1398,
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
priceUpdated: nil),
FuelStation(id: "s3", name: "BP Milton Road", brand: "BP",
address: "161 Milton Road", postcode: "CB4 1XE",
lat: 52.2160, lng: 0.1410,
prices: [.e10: 140.9, .diesel: 147.9],
priceUpdated: nil),
FuelStation(id: "s4", name: "Morrisons Newmarket Road", brand: "Morrisons",
address: "Newmarket Road", postcode: "CB5 8AA",
lat: 52.2164, lng: 0.1599,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
priceUpdated: nil),
FuelStation(id: "s5", name: "Sainsbury's Coldhams Lane", brand: "Sainsbury's",
address: "Coldhams Lane", postcode: "CB1 3HY",
lat: 52.2025, lng: 0.1589,
prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9],
priceUpdated: nil),
FuelStation(id: "s6", name: "Esso Cherry Hinton", brand: "Esso",
address: "Cherry Hinton Road", postcode: "CB1 9AP",
lat: 52.1854, lng: 0.1657,
prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9],
priceUpdated: nil),
FuelStation(id: "s7", name: "Gulf Fen Road", brand: "Gulf",
address: "Fen Road", postcode: "CB4 1UN",
lat: 52.2201, lng: 0.1470,
prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9],
priceUpdated: nil),
FuelStation(id: "s8", name: "Asda Beehive Centre", brand: "Asda",
address: "Coldhams Lane", postcode: "CB1 3ER",
lat: 52.1995, lng: 0.1641,
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
priceUpdated: nil),
]
}
// MARK: - Fuel Finder API provider (integration point)
/// Live provider skeleton for the official GOV.UK Fuel Finder API.
///
/// To activate:
/// 1. Sign in at developer.fuel-finder.service.gov.uk with GOV.UK One Login,
/// register an app, and generate client credentials (client_id + secret).
/// 2. Implement token fetch: POST to the token endpoint with OAuth 2.0
/// client_credentials grant access_token (expires in 1h).
/// 3. GET prices with `Authorization: Bearer <token>` and decode the
/// stations JSON into [FuelStation].
///
/// Endpoints (per gov.uk docs):
/// base: https://api.fuelfinder.service.gov.uk
/// auth: base + /v1/token
/// prices: base + /v1/prices (full snapshot)
/// price: base + /v1/prices/GB-12345 (single station)
///
/// Then switch `FuelPriceProvider.active` to `FuelFinderProvider(...)`.
struct FuelFinderProvider: FuelPriceProviding {
let baseURL = URL(string: "https://api.fuelfinder.service.gov.uk")!
let clientID: String
let clientSecret: String
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) async throws -> [FuelStation] {
// TODO: OAuth token GET /v1/prices map to FuelStation.
// The live API requires authentication; see notes above.
throw FuelProviderError.notImplemented
}
}
enum FuelProviderError: LocalizedError {
case notImplemented
var errorDescription: String? { "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials." }
}
+165
View File
@@ -0,0 +1,165 @@
// FuelBoard shared model compiled into BOTH the app and the widget extension.
// Kept Foundation-only so both targets can compile it (no SwiftUI dependency).
//
// Storage strategy (same as WidgetBoard proven on free SideStore accounts):
// App-group UserDefaults + Keychain (generic password). Keychain first:
// shared group $(AppIdentifierPrefix)com.apt.fuelboard.shared is listed FIRST
// in the entitlements so it is the default access group. Read order:
// keychain app-group defaults fallback.
import Foundation
import Security
// MARK: - Fuel types
enum FuelType: String, Codable, CaseIterable, Identifiable {
case e10 // Unleaded 95 (E10)
case e5 // Premium 97/98 (E5)
case diesel // B7 diesel
var id: String { rawValue }
var displayName: String {
switch self {
case .e10: return "E10 Unleaded"
case .e5: return "E5 Premium"
case .diesel: return "Diesel"
}
}
}
// MARK: - Station model
struct FuelStation: Identifiable, Codable, Equatable {
let id: String
let name: String
let brand: String
let address: String
let postcode: String
let lat: Double
let lng: Double
/// Price per fuel type in pence per litre. Missing type = station doesn't sell it.
let prices: [FuelType: Double]
/// Updated timestamp (seconds since 1970) nil for sample data.
let priceUpdated: TimeInterval?
/// Cheap haversine distance to a location, in km.
func distanceKM(to lat2: Double, lng2: Double) -> Double {
let r = 6371.0
let dLat = (lat2 - lat) * .pi / 180
let dLng = (lng2 - lng) * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat * .pi / 180) * cos(lat2 * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
/// Apple Maps directions URL used by the widget tap and app rows.
var mapsDirectionsURL: URL? {
URL(string: "http://maps.apple.com/?daddr=\(lat),\(lng)&t=d")
}
}
// MARK: - Shared store
struct FuelStore {
static let appGroupSuite = "group.com.apt.fuelboard"
static let stationsKey = "fuelboard.stations" // [FuelStation] JSON
static let locationKey = "fuelboard.lastLocation" // "lat,lng,timestamp"
static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value
// MARK: Stations
static func loadStations() -> [FuelStation] {
if let data = keychainData(service: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
return stations
}
if let defaults = UserDefaults(suiteName: appGroupSuite),
let data = defaults.data(forKey: stationsKey),
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
!stations.isEmpty {
return stations
}
return []
}
static func saveStations(_ stations: [FuelStation]) {
if let data = try? JSONEncoder().encode(stations) {
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey)
writeKeychain(data: data, service: stationsKey)
}
}
// MARK: Last known location ("lat,lng,unixTime")
static func loadLocation() -> (lat: Double, lng: Double, date: Date)? {
let raw = loadString(service: locationKey)
let parts = raw?.split(separator: ",").compactMap { Double($0) }
guard let parts, parts.count == 3 else { return nil }
return (parts[0], parts[1], Date(timeIntervalSince1970: parts[2]))
}
static func saveLocation(lat: Double, lng: Double, date: Date = Date()) {
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
}
// MARK: Selected fuel
static func loadSelectedFuel() -> FuelType {
if let raw = loadString(service: fuelKey), let fuel = FuelType(rawValue: raw) {
return fuel
}
return .e10
}
static func saveSelectedFuel(_ fuel: FuelType) {
saveString(fuel.rawValue, service: fuelKey)
}
// MARK: Low-level keychain helpers
private static func keychainData(service: String) -> Data? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return data
}
private static func writeKeychain(data: Data, service: String) {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: data,
]
SecItemAdd(addQuery as CFDictionary, nil)
}
private static func loadString(service: String) -> String? {
if let data = keychainData(service: service) {
return String(data: data, encoding: .utf8)
}
if let defaults = UserDefaults(suiteName: appGroupSuite) {
return defaults.string(forKey: service)
}
return nil
}
private static func saveString(_ value: String, service: String) {
UserDefaults(suiteName: appGroupSuite)?.set(value, forKey: service)
writeKeychain(data: Data(value.utf8), service: service)
}
}