commit b0a5b5a326613d40e5be44eccc65a52f8a330b97 Author: FuelBoard Contributor Date: Tue Aug 11 13:54:56 2026 +0100 Scaffold FuelBoard: petrol price widget + app (sample data, Maps deep-links, provider seam for Fuel Finder API) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7446aae --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +build-release/ +.DS_Store +*.xcuserstate +xcuserdata/ diff --git a/Config/App-Info.plist b/Config/App-Info.plist new file mode 100644 index 0000000..161d11e --- /dev/null +++ b/Config/App-Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + FuelBoard + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSLocationWhenInUseUsageDescription + FuelBoard uses your location to find the cheapest nearby petrol stations. + UILaunchScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/Config/App.entitlements b/Config/App.entitlements new file mode 100644 index 0000000..3eea433 --- /dev/null +++ b/Config/App.entitlements @@ -0,0 +1,15 @@ + + + + + com.apple.security.application-groups + + group.com.apt.fuelboard + + keychain-access-groups + + $(AppIdentifierPrefix)com.apt.fuelboard.shared + $(AppIdentifierPrefix)com.apt.fuelboard + + + diff --git a/Config/Widget-Info.plist b/Config/Widget-Info.plist new file mode 100644 index 0000000..106f2cd --- /dev/null +++ b/Config/Widget-Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + FuelBoard Prices + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Config/Widget.entitlements b/Config/Widget.entitlements new file mode 100644 index 0000000..3eea433 --- /dev/null +++ b/Config/Widget.entitlements @@ -0,0 +1,15 @@ + + + + + com.apple.security.application-groups + + group.com.apt.fuelboard + + keychain-access-groups + + $(AppIdentifierPrefix)com.apt.fuelboard.shared + $(AppIdentifierPrefix)com.apt.fuelboard + + + diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c3ac083 --- /dev/null +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -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 = ""; + }; + BB0000000000000000000009 /* FuelBoardWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FuelBoardWidgets; + sourceTree = ""; + }; + BB000000000000000000000A /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; +/* 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 = ""; + }; + BB000000000000000000000E /* Products */ = { + isa = PBXGroup; + children = ( + BB0000000000000000000007 /* FuelBoard.app */, + BB0000000000000000000002 /* FuelBoardWidgets.appex */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift new file mode 100644 index 0000000..26eedcc --- /dev/null +++ b/FuelBoard/ContentView.swift @@ -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. + } +} diff --git a/FuelBoard/FuelBoardApp.swift b/FuelBoard/FuelBoardApp.swift new file mode 100644 index 0000000..d635841 --- /dev/null +++ b/FuelBoard/FuelBoardApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct FuelBoardApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/FuelBoardWidgets/FuelBoardWidgetsBundle.swift b/FuelBoardWidgets/FuelBoardWidgetsBundle.swift new file mode 100644 index 0000000..87e49dc --- /dev/null +++ b/FuelBoardWidgets/FuelBoardWidgetsBundle.swift @@ -0,0 +1,9 @@ +import WidgetKit +import SwiftUI + +@main +struct FuelBoardWidgetsBundle: WidgetBundle { + var body: some Widget { + FuelPriceWidget() + } +} diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift new file mode 100644 index 0000000..9fc72d8 --- /dev/null +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -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) -> 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) + } +} diff --git a/Shared/FuelPriceProvider.swift b/Shared/FuelPriceProvider.swift new file mode 100644 index 0000000..fe4bdd8 --- /dev/null +++ b/Shared/FuelPriceProvider.swift @@ -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 ` 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." } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift new file mode 100644 index 0000000..26dc89a --- /dev/null +++ b/Shared/FuelStore.swift @@ -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) + } +}