Scaffold FuelBoard: petrol price widget + app (sample data, Maps deep-links, provider seam for Fuel Finder API)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
build/
|
||||||
|
build-release/
|
||||||
|
.DS_Store
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 */;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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." }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user