Compare commits
25
Commits
@@ -1,6 +1,7 @@
|
|||||||
build/
|
build/
|
||||||
build-release/
|
build-release/
|
||||||
build-sim/
|
build-sim/
|
||||||
|
build-watch/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?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>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?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>FuelBoardWatch</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>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>WKAppBundleIdentifier</key>
|
||||||
|
<string>com.apt.fuelboard.watchapp</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.watchkit</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?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>FuelBoardWatch</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>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
</array>
|
||||||
|
<key>WKCompanionAppBundleIdentifier</key>
|
||||||
|
<string>com.apt.fuelboard</string>
|
||||||
|
<key>WKWatchKitApp</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
// !$*UTF8*$!
|
// !$*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;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
@@ -10,10 +7,26 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
ADCE5B643033A32D008C131C /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = ADCE5B4E3033A329008C131C;
|
||||||
|
remoteInfo = "FuelBoardWatch Watch App Extension";
|
||||||
|
};
|
||||||
|
ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = ADCE5B673033A32D008C131C;
|
||||||
|
remoteInfo = "FuelBoardWatch Watch App";
|
||||||
|
};
|
||||||
BB0000000000000000000003 /* PBXContainerItemProxy */ = {
|
BB0000000000000000000003 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = BB0000000000000000000004 /* Project object */;
|
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||||
@@ -24,6 +37,28 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed App Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||||
|
dstSubfolderSpec = 16;
|
||||||
|
files = (
|
||||||
|
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */,
|
||||||
|
);
|
||||||
|
name = "Embed Watch Content";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB0000000000000000000006 /* Embed App Extensions */ = {
|
BB0000000000000000000006 /* Embed App Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -38,11 +73,23 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = "FuelBoardWatch Watch App";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = "FuelBoardWatch Wrapper";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
BB0000000000000000000008 /* FuelBoard */ = {
|
BB0000000000000000000008 /* FuelBoard */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = FuelBoard;
|
path = FuelBoard;
|
||||||
@@ -61,6 +108,20 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
ADCE5B4C3033A329008C131C /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
ADCE5B6D3033A32E008C131C /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB000000000000000000000B /* Frameworks */ = {
|
BB000000000000000000000B /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -84,6 +145,8 @@
|
|||||||
BB0000000000000000000008 /* FuelBoard */,
|
BB0000000000000000000008 /* FuelBoard */,
|
||||||
BB0000000000000000000009 /* FuelBoardWidgets */,
|
BB0000000000000000000009 /* FuelBoardWidgets */,
|
||||||
BB000000000000000000000A /* Shared */,
|
BB000000000000000000000A /* Shared */,
|
||||||
|
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||||
|
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||||
BB000000000000000000000E /* Products */,
|
BB000000000000000000000E /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -93,6 +156,8 @@
|
|||||||
children = (
|
children = (
|
||||||
BB0000000000000000000007 /* FuelBoard.app */,
|
BB0000000000000000000007 /* FuelBoard.app */,
|
||||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
||||||
|
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
|
||||||
|
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -100,30 +165,50 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
BB000000000000000000000F /* FuelBoard */ = {
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
|
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
BB0000000000000000000011 /* Sources */,
|
ADCE5B4B3033A329008C131C /* Sources */,
|
||||||
BB000000000000000000000B /* Frameworks */,
|
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||||
BB0000000000000000000012 /* Resources */,
|
ADCE5B4D3033A329008C131C /* Resources */,
|
||||||
BB0000000000000000000006 /* Embed App Extensions */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
BB0000000000000000000013 /* PBXTargetDependency */,
|
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
BB0000000000000000000008 /* FuelBoard */,
|
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||||
BB000000000000000000000A /* Shared */,
|
|
||||||
);
|
);
|
||||||
name = FuelBoard;
|
name = "FuelBoardWatch Watch App Extension";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = FuelBoard;
|
productName = "FuelBoardWatch Watch App Extension";
|
||||||
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.watchkit2-extension";
|
||||||
|
};
|
||||||
|
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
||||||
|
buildPhases = (
|
||||||
|
ADCE5B6E3033A32E008C131C /* Resources */,
|
||||||
|
ADCE5B653033A32D008C131C /* Embed App Extensions */,
|
||||||
|
ADCE5B6D3033A32E008C131C /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
ADCE5B683033A32D008C131C /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||||
|
);
|
||||||
|
name = "FuelBoardWatch Watch App";
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = "FuelBoardWatch Watch App";
|
||||||
|
productReference = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */;
|
||||||
|
productType = "com.apple.product-type.application.watchapp2";
|
||||||
};
|
};
|
||||||
BB0000000000000000000005 /* FuelBoardWidgets */ = {
|
BB0000000000000000000005 /* FuelBoardWidgets */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
@@ -148,6 +233,33 @@
|
|||||||
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
|
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
|
BB000000000000000000000F /* FuelBoard */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
|
||||||
|
buildPhases = (
|
||||||
|
BB0000000000000000000011 /* Sources */,
|
||||||
|
BB000000000000000000000B /* Frameworks */,
|
||||||
|
BB0000000000000000000012 /* Resources */,
|
||||||
|
ADCE5B5B3033A32C008C131C /* Embed Watch Content */,
|
||||||
|
BB0000000000000000000006 /* Embed App Extensions */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
ADCE5B703033A32F008C131C /* PBXTargetDependency */,
|
||||||
|
BB0000000000000000000013 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
BB0000000000000000000008 /* FuelBoard */,
|
||||||
|
BB000000000000000000000A /* Shared */,
|
||||||
|
);
|
||||||
|
name = FuelBoard;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = FuelBoard;
|
||||||
|
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -155,15 +267,18 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 1600;
|
LastSwiftUpdateCheck = 2630;
|
||||||
LastUpgradeCheck = 1600;
|
LastUpgradeCheck = 1600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
BB000000000000000000000F = {
|
ADCE5B4E3033A329008C131C = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 26.3;
|
||||||
};
|
};
|
||||||
BB0000000000000000000005 = {
|
BB0000000000000000000005 = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
};
|
};
|
||||||
|
BB000000000000000000000F = {
|
||||||
|
CreatedOnToolsVersion = 16.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
|
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
|
||||||
@@ -182,11 +297,27 @@
|
|||||||
targets = (
|
targets = (
|
||||||
BB000000000000000000000F /* FuelBoard */,
|
BB000000000000000000000F /* FuelBoard */,
|
||||||
BB0000000000000000000005 /* FuelBoardWidgets */,
|
BB0000000000000000000005 /* FuelBoardWidgets */,
|
||||||
|
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
||||||
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
ADCE5B4D3033A329008C131C /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
ADCE5B6E3033A32E008C131C /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB0000000000000000000012 /* Resources */ = {
|
BB0000000000000000000012 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -204,6 +335,13 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
ADCE5B4B3033A329008C131C /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB0000000000000000000011 /* Sources */ = {
|
BB0000000000000000000011 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -221,6 +359,16 @@
|
|||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
ADCE5B683033A32D008C131C /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */;
|
||||||
|
targetProxy = ADCE5B643033A32D008C131C /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
ADCE5B703033A32F008C131C /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */;
|
||||||
|
targetProxy = ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
BB0000000000000000000013 /* PBXTargetDependency */ = {
|
BB0000000000000000000013 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = BB0000000000000000000005 /* FuelBoardWidgets */;
|
target = BB0000000000000000000005 /* FuelBoardWidgets */;
|
||||||
@@ -229,6 +377,262 @@
|
|||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
ADCE5B573033A32B008C131C /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
|
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
ADCE5B583033A32B008C131C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
|
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
ADCE5B6B3033A32D008C131C /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
ADCE5B6C3033A32D008C131C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
BB0000000000000000000018 /* Debug */ = {
|
BB0000000000000000000018 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -292,8 +696,9 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/App-Info.plist;
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -314,8 +719,9 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/App-Info.plist;
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -336,8 +742,9 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -360,8 +767,9 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -380,11 +788,20 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
BB0000000000000000000018 /* Debug */,
|
ADCE5B573033A32B008C131C /* Debug */,
|
||||||
BB0000000000000000000019 /* Release */,
|
ADCE5B583033A32B008C131C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
ADCE5B6B3033A32D008C131C /* Debug */,
|
||||||
|
ADCE5B6C3033A32D008C131C /* Release */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
@@ -407,6 +824,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BB0000000000000000000018 /* Debug */,
|
||||||
|
BB0000000000000000000019 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = BB0000000000000000000004 /* Project object */;
|
rootObject = BB0000000000000000000004 /* Project object */;
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ struct ContentView: View {
|
|||||||
// fires on every fix incl. background significant-change
|
// fires on every fix incl. background significant-change
|
||||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
|
||||||
|
/// stations around the current fix. The app owns routing — the widget and
|
||||||
|
/// Live Activity only read the cached result. Falls back to the last saved
|
||||||
|
/// location so it can run before the first fresh GPS fix arrives.
|
||||||
|
private func refreshRoadDistancesIfNeeded() {
|
||||||
|
let origin = location ?? FuelStore.loadLocation()
|
||||||
|
guard let origin else { return }
|
||||||
|
Task {
|
||||||
|
await RoadDistanceService.refreshIfNeeded(
|
||||||
|
stations: stations,
|
||||||
|
lat: origin.lat,
|
||||||
|
lng: origin.lng
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +321,7 @@ struct ContentView: View {
|
|||||||
#else
|
#else
|
||||||
let shouldSkipOnboarding = false
|
let shouldSkipOnboarding = false
|
||||||
#endif
|
#endif
|
||||||
if FuelStore.loadHasCompletedOnboarding()
|
if !FuelStore.shouldShowOnboarding()
|
||||||
|| shouldSkipOnboarding {
|
|| shouldSkipOnboarding {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
// Geofences and the Live Activity must follow the user even in
|
// Geofences and the Live Activity must follow the user even in
|
||||||
@@ -317,6 +334,10 @@ struct ContentView: View {
|
|||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
// Compute road distances early (throttled; falls back to the
|
||||||
|
// last saved location) so distance surfaces are road-matched
|
||||||
|
// as soon as stations are available.
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
// Skipped under the force-* hooks so the banner stays up.
|
// Skipped under the force-* hooks so the banner stays up.
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -362,7 +383,11 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
// No network fetch on foreground — pull-to-refresh is the override.
|
refreshRoadDistancesIfNeeded()
|
||||||
|
if FuelStore.hasPendingWatchRefreshRequest {
|
||||||
|
Task { await handlePendingWatchRefreshRequest() }
|
||||||
|
}
|
||||||
|
// No network fetch on foreground unless the watch explicitly requests one.
|
||||||
} else {
|
} else {
|
||||||
locationManager.stopForegroundTracking()
|
locationManager.stopForegroundTracking()
|
||||||
}
|
}
|
||||||
@@ -377,6 +402,7 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -808,6 +834,11 @@ struct ContentView: View {
|
|||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handlePendingWatchRefreshRequest() async {
|
||||||
|
guard FuelStore.hasPendingWatchRefreshRequest else { return }
|
||||||
|
await refresh(force: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches fresh prices, but only when the cache is stale — unless
|
/// Fetches fresh prices, but only when the cache is stale — unless
|
||||||
/// `force` is true (pull-to-refresh is the manual override).
|
/// `force` is true (pull-to-refresh is the manual override).
|
||||||
private func refresh(force: Bool = false) async {
|
private func refresh(force: Bool = false) async {
|
||||||
@@ -826,6 +857,9 @@ struct ContentView: View {
|
|||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
FuelStore.saveLastRefresh()
|
||||||
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||||
|
FuelStore.markWatchRefreshHandled()
|
||||||
|
}
|
||||||
// Persist envelope metadata (source, station count, GOV.UK
|
// Persist envelope metadata (source, station count, GOV.UK
|
||||||
// dataset update time) for the Settings → About section — the
|
// dataset update time) for the Settings → About section — the
|
||||||
// live chain records whichever leg served the fetch.
|
// live chain records whichever leg served the fetch.
|
||||||
@@ -848,6 +882,9 @@ struct ContentView: View {
|
|||||||
// Live data restored — any status banner no longer applies.
|
// Live data restored — any status banner no longer applies.
|
||||||
dataStatus = .live
|
dataStatus = .live
|
||||||
} catch {
|
} catch {
|
||||||
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||||
|
FuelStore.markWatchRefreshHandled()
|
||||||
|
}
|
||||||
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||||||
if FuelStore.loadStations().isEmpty {
|
if FuelStore.loadStations().isEmpty {
|
||||||
// No cached prices — last resort is the bundled REAL dump
|
// No cached prices — last resort is the bundled REAL dump
|
||||||
@@ -949,7 +986,7 @@ struct StationRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
if let location {
|
if let location {
|
||||||
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct FuelBoardApp: App {
|
struct FuelBoardApp: App {
|
||||||
|
init() {
|
||||||
|
#if DEBUG
|
||||||
|
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
|
||||||
|
// Live Activity with a long station name so the Lock Screen / island
|
||||||
|
// layout can be rendered in the Simulator for visual QA.
|
||||||
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
if let idx = args.firstIndex(of: "-qaLiveActivity"),
|
||||||
|
args.indices.contains(idx + 1),
|
||||||
|
let fuel = FuelType(rawValue: args[idx + 1]) {
|
||||||
|
startQALiveActivity(fuel: fuel)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
@@ -9,6 +24,34 @@ struct FuelBoardApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func startQALiveActivity(fuel: FuelType) {
|
||||||
|
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||||
|
fuel: fuel,
|
||||||
|
stationID: "qa-phoenix",
|
||||||
|
stationName: "Phoenix Filling Stations",
|
||||||
|
brand: "Phoenix",
|
||||||
|
pricePence: 1499,
|
||||||
|
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
||||||
|
distanceKM: 8.0,
|
||||||
|
lat: 51.5,
|
||||||
|
lng: -0.12,
|
||||||
|
updatedAt: Date()
|
||||||
|
)
|
||||||
|
let attrs = FuelBoardLiveActivityAttributes()
|
||||||
|
do {
|
||||||
|
let activity = try Activity.request(
|
||||||
|
attributes: attrs,
|
||||||
|
content: .init(state: state, staleDate: nil),
|
||||||
|
pushType: nil
|
||||||
|
)
|
||||||
|
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
|
||||||
|
} catch {
|
||||||
|
print("QA-LIVE-ACTIVITY FAILED: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Handles deep links that end up in the app. Widget taps arrive here in
|
/// Handles deep links that end up in the app. Widget taps arrive here in
|
||||||
/// two cases:
|
/// two cases:
|
||||||
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ enum LiveActivityManager {
|
|||||||
brand: best.brand,
|
brand: best.brand,
|
||||||
pricePence: price,
|
pricePence: price,
|
||||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
||||||
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
|
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
|
||||||
lat: best.lat,
|
lat: best.lat,
|
||||||
lng: best.lng,
|
lng: best.lng,
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
|
||||||
|
// nearby stations and caches them (keychain) so widgets + Live Activity can
|
||||||
|
// show real driving distance instead of straight-line haversine.
|
||||||
|
//
|
||||||
|
// Runs only in the APP: MKDirections is network-bound and the widget extension
|
||||||
|
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
|
||||||
|
// here, not in the widget. The widget/Live Activity just read the cache.
|
||||||
|
//
|
||||||
|
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
||||||
|
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
|
||||||
|
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum RoadDistanceService {
|
||||||
|
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
||||||
|
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
|
||||||
|
/// still get real road distances instead of a straight-line fallback.
|
||||||
|
static let candidatesPerPass = 40
|
||||||
|
/// Only route stations within this straight-line radius (km). Covers the
|
||||||
|
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
|
||||||
|
/// every station a widget/Live Activity/list can actually show gets routed.
|
||||||
|
static let maxRadiusKM: Double = 25
|
||||||
|
/// Don't route again more often than this (minutes).
|
||||||
|
static let throttleMinutes: Double = 10
|
||||||
|
/// Recompute when the user moves more than this (metres) from the last
|
||||||
|
/// source location.
|
||||||
|
static let moveThresholdMeters: Double = 400
|
||||||
|
|
||||||
|
/// Refreshes the cached road distances for the in-radius stations around
|
||||||
|
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||||
|
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
||||||
|
guard !stations.isEmpty else { return }
|
||||||
|
|
||||||
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
||||||
|
if let cache = FuelStore.loadRoadDistances() {
|
||||||
|
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
||||||
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||||
|
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate stations: the nearest-by-straight-line subset that the UI
|
||||||
|
// could actually display, capped so a pass stays bounded.
|
||||||
|
let nearest = stations
|
||||||
|
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||||
|
.prefix(candidatesPerPass)
|
||||||
|
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||||
|
|
||||||
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||||
|
var entries: [String: CachedRoadDistance] = [:]
|
||||||
|
for station in nearest {
|
||||||
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
|
if let meters = await roadMeters(from: origin, to: dest) {
|
||||||
|
// Store the exact pin that was routed so the display layer can
|
||||||
|
// refuse to serve this value if the station later appears with
|
||||||
|
// a different coordinate (corrected pin / other data source).
|
||||||
|
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||||
|
lat: station.lat,
|
||||||
|
lng: station.lng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !entries.isEmpty else { return }
|
||||||
|
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
||||||
|
// Wake the widgets so the new road distances surface immediately.
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||||
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||||
|
let request = MKDirections.Request()
|
||||||
|
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
||||||
|
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
||||||
|
request.transportType = .automobile
|
||||||
|
request.requestsAlternateRoutes = false
|
||||||
|
do {
|
||||||
|
let response = try await MKDirections(request: request).calculate()
|
||||||
|
return response.routes.first?.distance
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Straight-line haversine distance between two coordinates, in metres.
|
||||||
|
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
||||||
|
let r = 6371000.0
|
||||||
|
let dLat = (lat2 - lat1) * .pi / 180
|
||||||
|
let dLng = (lng2 - lng1) * .pi / 180
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
||||||
|
sin(dLng / 2) * sin(dLng / 2)
|
||||||
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,7 +125,7 @@ struct CheapestFuelIntent: AppIntent {
|
|||||||
struct DirectionsToCheapestFuelIntent: AppIntent {
|
struct DirectionsToCheapestFuelIntent: AppIntent {
|
||||||
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
|
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
|
||||||
static var description = IntentDescription(
|
static var description = IntentDescription(
|
||||||
"Opens Apple Maps directions to the cheapest station selling a fuel near you, using the latest cached prices."
|
"Opens directions to the cheapest station selling a fuel near you, using the latest cached prices."
|
||||||
)
|
)
|
||||||
|
|
||||||
@Parameter(title: "Fuel")
|
@Parameter(title: "Fuel")
|
||||||
@@ -285,7 +285,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
|
|||||||
struct DirectionsToFavouriteFuelIntent: AppIntent {
|
struct DirectionsToFavouriteFuelIntent: AppIntent {
|
||||||
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
|
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
|
||||||
static var description = IntentDescription(
|
static var description = IntentDescription(
|
||||||
"Opens Apple Maps directions to your top favourite station for a fuel."
|
"Opens directions to your top favourite station for a fuel."
|
||||||
)
|
)
|
||||||
|
|
||||||
@Parameter(title: "Fuel")
|
@Parameter(title: "Fuel")
|
||||||
@@ -404,7 +404,7 @@ struct FuelMessage: View {
|
|||||||
/// Shortcut slot and adds no phrase.
|
/// Shortcut slot and adds no phrase.
|
||||||
struct OpenDirectionsIntent: AppIntent {
|
struct OpenDirectionsIntent: AppIntent {
|
||||||
static var title: LocalizedStringResource = "Directions"
|
static var title: LocalizedStringResource = "Directions"
|
||||||
static var description = IntentDescription("Opens Apple Maps directions to the station.")
|
static var description = IntentDescription("Opens directions to the station.")
|
||||||
static var isDiscoverable: Bool = false
|
static var isDiscoverable: Bool = false
|
||||||
|
|
||||||
@Parameter var stationName: String
|
@Parameter var stationName: String
|
||||||
@@ -489,19 +489,14 @@ struct FuelMessageSnippetIntent: SnippetIntent {
|
|||||||
|
|
||||||
struct FuelBoardShortcuts: AppShortcutsProvider {
|
struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||||
static var appShortcuts: [AppShortcut] {
|
static var appShortcuts: [AppShortcut] {
|
||||||
// Generic parameterized shortcut — matches whatever fuel word Siri
|
// NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
|
||||||
// resolves. Parameter resolution is flaky on-device ("cheapest diesel"
|
// ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
|
||||||
// matched, "cheapest unleaded" didn't), which is why the fixed-fuel
|
// shortcuts for the same intent (parameter resolution was already flaky
|
||||||
// entries below carry the fuel word as LITERAL phrase text.
|
// on-device), which degraded Siri's NLU ranking and caused the
|
||||||
AppShortcut(
|
// hit-and-miss "can't do that, searching in app" fallback. Now Siri
|
||||||
intent: CheapestFuelIntent(),
|
// routes on literal fuel words only, and the freed slot keeps us under
|
||||||
phrases: [
|
// the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
|
||||||
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me",
|
// metadata-processor requirement) and mirrors how people actually ask.
|
||||||
],
|
|
||||||
shortTitle: "Cheapest Fuel",
|
|
||||||
systemImageName: "fuelpump"
|
|
||||||
)
|
|
||||||
|
|
||||||
AppShortcut(
|
AppShortcut(
|
||||||
intent: CheapestFuelIntent(fuel: .e10),
|
intent: CheapestFuelIntent(fuel: .e10),
|
||||||
phrases: [
|
phrases: [
|
||||||
@@ -509,6 +504,10 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
|
|||||||
"Ask \(.applicationName) for the cheapest petrol near me",
|
"Ask \(.applicationName) for the cheapest petrol near me",
|
||||||
"Find the cheapest petrol near me \(.applicationName)",
|
"Find the cheapest petrol near me \(.applicationName)",
|
||||||
"Find the cheapest unleaded near me \(.applicationName)",
|
"Find the cheapest unleaded near me \(.applicationName)",
|
||||||
|
"What's the cheapest unleaded near me \(.applicationName)",
|
||||||
|
"What's the cheapest petrol near me \(.applicationName)",
|
||||||
|
"Cheapest unleaded near me \(.applicationName)",
|
||||||
|
"Where's the cheapest petrol \(.applicationName)",
|
||||||
],
|
],
|
||||||
shortTitle: "Cheapest Unleaded",
|
shortTitle: "Cheapest Unleaded",
|
||||||
systemImageName: "fuelpump"
|
systemImageName: "fuelpump"
|
||||||
|
|||||||
@@ -317,17 +317,6 @@ extension FuelType {
|
|||||||
case .diesel: return "Diesel"
|
case .diesel: return "Diesel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
|
||||||
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the
|
|
||||||
/// fuel-type tab icons and the title icon.
|
|
||||||
var tintColor: Color {
|
|
||||||
switch self {
|
|
||||||
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
|
||||||
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
|
||||||
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ struct TrendsView: View {
|
|||||||
var onHistoryRecovered: (() -> Void)? = nil
|
var onHistoryRecovered: (() -> Void)? = nil
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
@State private var fuel: FuelType = .e10
|
@State private var fuel: FuelType = .e10
|
||||||
@State private var rangeDays: Int = 30
|
@State private var rangeDays: Int = 30
|
||||||
@@ -34,6 +35,13 @@ struct TrendsView: View {
|
|||||||
@State private var loadFailed = false
|
@State private var loadFailed = false
|
||||||
@State private var firstSnapshot: String?
|
@State private var firstSnapshot: String?
|
||||||
|
|
||||||
|
/// Per-series draw-in waterline: maps stationID → how many leading points
|
||||||
|
/// are revealed, so each line traces left→right on first appearance.
|
||||||
|
/// Series reveal in a short staggered cascade so the draw is clearly
|
||||||
|
/// visible even with only 2 points per line. Stays full after the first
|
||||||
|
/// reveal so range/mode switches morph instead of re-tracing.
|
||||||
|
@State private var revealed: [String: Int] = [:]
|
||||||
|
|
||||||
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
|
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
|
||||||
/// opens where the user was — same pattern as FavouritesView.
|
/// opens where the user was — same pattern as FavouritesView.
|
||||||
init(favourites: [FavouriteEntry],
|
init(favourites: [FavouriteEntry],
|
||||||
@@ -157,7 +165,13 @@ struct TrendsView: View {
|
|||||||
// pointer probe already done above.
|
// pointer probe already done above.
|
||||||
loadFailed = firstSnapshot == nil
|
loadFailed = firstSnapshot == nil
|
||||||
}
|
}
|
||||||
|
// Always redraw the lines on a selection change. `.task(id:)` fires on
|
||||||
|
// the initial appear and on every fuel/range switch (there's no
|
||||||
|
// periodic refetch in this sheet), so a staggered per-series draw-in
|
||||||
|
// replays exactly when the user picks 7/30/90 (or switches fuel) while
|
||||||
|
// a width-stable morph keeps the x-axis/y-range from jumping abruptly.
|
||||||
series = fetched
|
series = fetched
|
||||||
|
revealSeries(fetched)
|
||||||
// A failure with no data IS a connection problem — raise the global
|
// A failure with no data IS a connection problem — raise the global
|
||||||
// banner so the user isn't stuck with a silent retry state. Success
|
// banner so the user isn't stuck with a silent retry state. Success
|
||||||
// clears it (only if the banner is the connection banner).
|
// clears it (only if the banner is the connection banner).
|
||||||
@@ -168,6 +182,32 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replays the staggered per-series draw-in for the given histories:
|
||||||
|
/// resets the waterline, then cascades each station's line left→right,
|
||||||
|
/// 0.18s apart, on every range/fuel selection. Reduce Motion jump-cuts
|
||||||
|
/// straight to the full state.
|
||||||
|
private func revealSeries(_ histories: [StationHistory]) {
|
||||||
|
let hasPoints = histories.contains { !$0.points.isEmpty }
|
||||||
|
guard hasPoints else { return }
|
||||||
|
if reduceMotion {
|
||||||
|
revealed = histories.reduce(into: [:]) { $0[$1.stationID] = $1.points.count }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revealed = [:]
|
||||||
|
let cascadeNS = UInt64(0.18 * 1_000_000_000)
|
||||||
|
for (i, h) in histories.enumerated() {
|
||||||
|
let sid = h.stationID
|
||||||
|
let total = h.points.count
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(i) * cascadeNS)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.5)) {
|
||||||
|
revealed[sid] = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func yLabel(_ pence: Double) -> String {
|
private func yLabel(_ pence: Double) -> String {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .price:
|
case .price:
|
||||||
@@ -330,6 +370,9 @@ struct TrendsView: View {
|
|||||||
.font(.system(size: 30, weight: .bold, design: .default))
|
.font(.system(size: 30, weight: .bold, design: .default))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
|
// Roll the digits to the new figure on range/mode change
|
||||||
|
// (fires inside the animated transaction above).
|
||||||
|
.contentTransition(.numericText(value: headAvg))
|
||||||
if let delta = headlineDelta, delta != 0 {
|
if let delta = headlineDelta, delta != 0 {
|
||||||
Label(
|
Label(
|
||||||
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
||||||
@@ -389,13 +432,13 @@ struct TrendsView: View {
|
|||||||
// type-checker's budget.)
|
// type-checker's budget.)
|
||||||
Chart {
|
Chart {
|
||||||
ForEach(displaySeries) { history in
|
ForEach(displaySeries) { history in
|
||||||
ForEach(history.points) { point in
|
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||||
areaMark(point, series: history.name,
|
areaMark(point, series: history.name,
|
||||||
color: seriesColor(index(of: history.stationID)))
|
color: seriesColor(index(of: history.stationID)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ForEach(displaySeries) { history in
|
ForEach(displaySeries) { history in
|
||||||
ForEach(history.points) { point in
|
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||||
lineMark(point, series: history.name,
|
lineMark(point, series: history.name,
|
||||||
color: seriesColor(index(of: history.stationID)))
|
color: seriesColor(index(of: history.stationID)))
|
||||||
}
|
}
|
||||||
@@ -424,6 +467,9 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 190)
|
.frame(height: 190)
|
||||||
|
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
|
||||||
|
// lines + axis gliding to the rebased series.
|
||||||
|
.animation(.easeInOut(duration: 0.35), value: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One gradient-filled area band under a single point of a series.
|
/// One gradient-filled area band under a single point of a series.
|
||||||
|
|||||||
@@ -620,3 +620,82 @@ final class OfflineDataLabelTests: XCTestCase {
|
|||||||
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
|
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Road distance cache
|
||||||
|
|
||||||
|
final class RoadDistanceCacheTests: XCTestCase {
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
// Keychain persists across invocations, so a cache left by an earlier
|
||||||
|
// test or run would pollute these. Overwrite with an empty, far-away
|
||||||
|
// cache (source at (0,0)) so every test starts from a clean slate.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||||||
|
lat: lat, lng: lng, prices: [:], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayDistanceFallsBackToStraightLineWhenNoCache() {
|
||||||
|
// London user, station ~ London -> no cache -> straight-line haversine.
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceUsedWhenCachedNear() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
// Cache a road distance of 3.2 km for this station from the user's fix.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceNilWhenOriginFar() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
// Cache built in London, but the user is now ~200 km away -> stale.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
let meters = FuelStore.roadDistanceMeters(for: s, userLat: 53.4808, userLng: -2.2426)
|
||||||
|
XCTAssertNil(meters)
|
||||||
|
// And display falls back to straight-line.
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceUsedForOtherStationNotFound() {
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
// A station that isn't in the cache falls back to straight-line.
|
||||||
|
let s = station("z", 51.51, -0.13)
|
||||||
|
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoadDistanceNotServedWhenStationPinDiffers() {
|
||||||
|
// Route a road distance to station "a" at pin P1.
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
// The SAME station id appears with a moved pin (corrected coordinate /
|
||||||
|
// different embedded vs live source): the cached route to P1 must NOT
|
||||||
|
// be served — it belongs to a different location.
|
||||||
|
let moved = station("a", 51.5400, -0.1600)
|
||||||
|
let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
|
||||||
|
"road value routed to the old pin leaked onto a different coordinate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Install identity
|
||||||
|
|
||||||
|
final class InstallIdentityTests: XCTestCase {
|
||||||
|
func testFreshInstallIsStableAfterFirstCall() {
|
||||||
|
// The first call seeds the common install id (local == keychain), so
|
||||||
|
// any subsequent call in the same process must report NOT-fresh. This
|
||||||
|
// holds regardless of persisted keychain/defaults state from prior runs.
|
||||||
|
_ = FuelStore.isFreshInstall()
|
||||||
|
XCTAssertFalse(FuelStore.isFreshInstall())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
private enum WatchFuelType: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case e10
|
||||||
|
case e5
|
||||||
|
case diesel
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .e10: return "Unleaded"
|
||||||
|
case .e5: return "Premium"
|
||||||
|
case .diesel: return "Diesel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortName: String {
|
||||||
|
switch self {
|
||||||
|
case .e10: return "E10"
|
||||||
|
case .e5: return "E5"
|
||||||
|
case .diesel: return "B7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .e10: return .green
|
||||||
|
case .e5: return .yellow
|
||||||
|
case .diesel: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .e10: return "fuelpump.fill"
|
||||||
|
case .e5: return "fuelpump.circle.fill"
|
||||||
|
case .diesel: return "drop.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortRank: Int {
|
||||||
|
switch self {
|
||||||
|
case .e10: return 0
|
||||||
|
case .e5: return 1
|
||||||
|
case .diesel: return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchDistanceUnit: String, Codable {
|
||||||
|
case miles
|
||||||
|
case kilometers
|
||||||
|
|
||||||
|
func format(km: Double) -> String {
|
||||||
|
switch self {
|
||||||
|
case .miles:
|
||||||
|
return String(format: "%.1f mi", km * 0.621371)
|
||||||
|
case .kilometers:
|
||||||
|
return String(format: "%.1f km", km)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchPriceStyle: String, Codable {
|
||||||
|
case stationSign
|
||||||
|
case poundsPence
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchCoordinate {
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchStation: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let brand: String
|
||||||
|
let address: String
|
||||||
|
let postcode: String
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
let prices: [WatchFuelType: Double]
|
||||||
|
let priceUpdated: TimeInterval?
|
||||||
|
|
||||||
|
func distanceKM(to location: WatchCoordinate) -> Double {
|
||||||
|
let r = 6371.0
|
||||||
|
let dLat = (location.lat - lat) * .pi / 180
|
||||||
|
let dLng = (location.lng - lng) * .pi / 180
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2)
|
||||||
|
+ cos(lat * .pi / 180) * cos(location.lat * .pi / 180)
|
||||||
|
* sin(dLng / 2) * sin(dLng / 2)
|
||||||
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchFavouriteEntry: Codable, Identifiable {
|
||||||
|
let station: WatchStation
|
||||||
|
let fuel: WatchFuelType
|
||||||
|
|
||||||
|
var id: String { "\(fuel.rawValue)|\(station.id)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchFavouriteRow: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let priceText: String
|
||||||
|
let distanceText: String?
|
||||||
|
let isCheapest: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchRefreshState {
|
||||||
|
case idle(lastUpdated: String)
|
||||||
|
case stale(lastUpdated: String)
|
||||||
|
case checking
|
||||||
|
case requested(lastUpdated: String)
|
||||||
|
case needsPhone
|
||||||
|
case noData
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .idle(let lastUpdated): return "Updated \(lastUpdated)"
|
||||||
|
case .stale(let lastUpdated): return "Cache from \(lastUpdated)"
|
||||||
|
case .checking: return "Checking…"
|
||||||
|
case .requested(let lastUpdated): return "Requested · \(lastUpdated)"
|
||||||
|
case .needsPhone: return "Refresh on iPhone"
|
||||||
|
case .noData: return "No cached prices yet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .idle: return .secondary
|
||||||
|
case .stale: return .yellow
|
||||||
|
case .checking: return .yellow
|
||||||
|
case .requested: return .green
|
||||||
|
case .needsPhone: return .orange
|
||||||
|
case .noData: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .idle: return "clock"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .checking: return "arrow.triangle.2.circlepath"
|
||||||
|
case .requested: return "checkmark.circle"
|
||||||
|
case .needsPhone: return "iphone"
|
||||||
|
case .noData: return "tray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchFuelCache {
|
||||||
|
static let appGroupSuite = "group.com.apt.fuelboard"
|
||||||
|
static let favouritesKey = "fuelboard.favourites"
|
||||||
|
static let stationsKey = "fuelboard.stations"
|
||||||
|
static let fuelKey = "fuelboard.selectedFuel"
|
||||||
|
static let distanceUnitKey = "fuelboard.distanceUnit"
|
||||||
|
static let priceStyleKey = "fuelboard.priceDisplayStyle"
|
||||||
|
static let locationKey = "fuelboard.lastLocation"
|
||||||
|
static let lastRefreshKey = "fuelboard.lastRefresh"
|
||||||
|
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest"
|
||||||
|
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled"
|
||||||
|
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
||||||
|
|
||||||
|
static func defaults() -> UserDefaults? {
|
||||||
|
UserDefaults(suiteName: appGroupSuite)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadSelectedFuel() -> WatchFuelType {
|
||||||
|
guard let raw = defaults()?.string(forKey: fuelKey), let fuel = WatchFuelType(rawValue: raw) else {
|
||||||
|
return .e10
|
||||||
|
}
|
||||||
|
return fuel
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadDistanceUnit() -> WatchDistanceUnit {
|
||||||
|
guard let raw = defaults()?.string(forKey: distanceUnitKey), let unit = WatchDistanceUnit(rawValue: raw) else {
|
||||||
|
return .miles
|
||||||
|
}
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadPriceStyle() -> WatchPriceStyle {
|
||||||
|
guard let raw = defaults()?.string(forKey: priceStyleKey), let style = WatchPriceStyle(rawValue: raw) else {
|
||||||
|
return .stationSign
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadLocation() -> WatchCoordinate? {
|
||||||
|
guard let raw = defaults()?.string(forKey: locationKey) else { return nil }
|
||||||
|
let parts = raw.split(separator: ",").compactMap { Double($0) }
|
||||||
|
guard parts.count >= 2 else { return nil }
|
||||||
|
return WatchCoordinate(lat: parts[0], lng: parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadLastRefresh() -> Date? {
|
||||||
|
guard let raw = defaults()?.string(forKey: lastRefreshKey), let ts = TimeInterval(raw) else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isCacheFresh(lastRefresh: Date?) -> Bool {
|
||||||
|
guard let lastRefresh else { return false }
|
||||||
|
return Date().timeIntervalSince(lastRefresh) < refreshInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestRefresh(_ date: Date = Date()) {
|
||||||
|
defaults()?.set(String(date.timeIntervalSince1970), forKey: watchRefreshRequestKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRefreshRequest() -> Date? {
|
||||||
|
guard let raw = defaults()?.string(forKey: watchRefreshRequestKey), let ts = TimeInterval(raw) else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRefreshHandled() -> Date? {
|
||||||
|
guard let raw = defaults()?.string(forKey: watchRefreshHandledKey), let ts = TimeInterval(raw) else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func wasRefreshHandled(since requestDate: Date) -> Bool {
|
||||||
|
guard let handled = loadRefreshHandled() else { return false }
|
||||||
|
return handled >= requestDate
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadStations() -> [WatchStation] {
|
||||||
|
guard let data = defaults()?.data(forKey: stationsKey),
|
||||||
|
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return stations
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadFavourites() -> [WatchFavouriteEntry] {
|
||||||
|
guard let data = defaults()?.data(forKey: favouritesKey),
|
||||||
|
let favourites = try? JSONDecoder().decode([WatchFavouriteEntry].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return favourites
|
||||||
|
}
|
||||||
|
|
||||||
|
static func refreshedFavourites(_ favourites: [WatchFavouriteEntry], from stations: [WatchStation]) -> [WatchFavouriteEntry] {
|
||||||
|
favourites.map { favourite in
|
||||||
|
guard let fresh = stations.first(where: { $0.id == favourite.station.id }) else { return favourite }
|
||||||
|
return WatchFavouriteEntry(station: fresh, fuel: favourite.fuel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func cheapestFavourite(in favourites: [WatchFavouriteEntry], fuel: WatchFuelType) -> WatchFavouriteEntry? {
|
||||||
|
var best: WatchFavouriteEntry?
|
||||||
|
for favourite in favourites where favourite.fuel == fuel {
|
||||||
|
guard let price = favourite.station.prices[fuel] else { continue }
|
||||||
|
guard let current = best, let currentPrice = current.station.prices[fuel] else {
|
||||||
|
best = favourite
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if price < currentPrice {
|
||||||
|
best = favourite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
static func priceText(_ pence: Double, style: WatchPriceStyle) -> String {
|
||||||
|
switch style {
|
||||||
|
case .stationSign:
|
||||||
|
return String(format: "%.1f", pence)
|
||||||
|
case .poundsPence:
|
||||||
|
let tenths = Int((pence * 10).rounded())
|
||||||
|
let whole = tenths / 1000
|
||||||
|
let major = (tenths % 1000) / 10
|
||||||
|
let minor = tenths % 10
|
||||||
|
let superscripts: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
|
return String(format: "£%d.%02d", whole, major) + String(superscripts[minor]) + "/L"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel()
|
||||||
|
@State private var refreshState: WatchRefreshState = .noData
|
||||||
|
@State private var favourites: [WatchFavouriteEntry] = []
|
||||||
|
@State private var stations: [WatchStation] = []
|
||||||
|
@State private var lastLocation: WatchCoordinate?
|
||||||
|
@State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit()
|
||||||
|
@State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $fuel) {
|
||||||
|
ForEach(WatchFuelType.allCases) { fuelCase in
|
||||||
|
fuelPage(for: fuelCase)
|
||||||
|
.tag(fuelCase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.verticalPage)
|
||||||
|
.onAppear(perform: reloadFromCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func fuelPage(for fuelCase: WatchFuelType) -> some View {
|
||||||
|
let fuelFavourites = favouritesForFuel(fuelCase)
|
||||||
|
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
|
||||||
|
let rows = rows(for: fuelCase)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header(for: fuelCase)
|
||||||
|
|
||||||
|
if let cheapest {
|
||||||
|
cheapestCard(for: fuelCase, favourite: cheapest)
|
||||||
|
} else {
|
||||||
|
emptyCheapestCard(for: fuelCase)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusRow
|
||||||
|
|
||||||
|
Button {
|
||||||
|
runCheckNow()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: refreshState.icon)
|
||||||
|
Text("Check now")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(fuelCase.color)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Favourites")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if rows.isEmpty {
|
||||||
|
Text("No favourites for \(fuelCase.displayName.lowercased()) yet.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(rows) { row in
|
||||||
|
favouriteRow(row, fuel: fuelCase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func favouritesForFuel(_ fuelCase: WatchFuelType) -> [WatchFavouriteEntry] {
|
||||||
|
favourites.filter { $0.fuel == fuelCase }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rows(for fuelCase: WatchFuelType) -> [WatchFavouriteRow] {
|
||||||
|
let fuelFavourites = favouritesForFuel(fuelCase)
|
||||||
|
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
|
||||||
|
|
||||||
|
return fuelFavourites.map { favourite in
|
||||||
|
let distanceText = lastLocation.map { distanceUnit.format(km: favourite.station.distanceKM(to: $0)) }
|
||||||
|
let priceText = favourite.station.prices[fuelCase].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—"
|
||||||
|
return WatchFavouriteRow(
|
||||||
|
id: favourite.id,
|
||||||
|
name: favourite.station.name,
|
||||||
|
priceText: priceText,
|
||||||
|
distanceText: distanceText,
|
||||||
|
isCheapest: favourite.id == cheapest?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(for fuel: WatchFuelType) -> some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Image(systemName: fuel.symbol)
|
||||||
|
.foregroundStyle(fuel.color)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("FuelBoard")
|
||||||
|
.font(.headline)
|
||||||
|
Text(fuel.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(fuel.shortName)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(fuel.color.opacity(0.18), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Cheapest favourite")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(favourite.station.name)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(favourite.station.prices[fuel].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
Spacer()
|
||||||
|
if let location = lastLocation {
|
||||||
|
Text(distanceUnit.format(km: favourite.station.distanceKM(to: location)))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emptyCheapestCard(for fuel: WatchFuelType) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Cheapest favourite")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No favourite saved")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Add favourites on iPhone to see them here.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusRow: some View {
|
||||||
|
Label(refreshState.label, systemImage: refreshState.icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(refreshState.tint)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(favourite.name)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(favourite.isCheapest ? .semibold : .regular)
|
||||||
|
.lineLimit(1)
|
||||||
|
if favourite.isCheapest {
|
||||||
|
Text("TOP")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(fuel.color.opacity(0.18), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let distanceText = favourite.distanceText {
|
||||||
|
Text(distanceText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(favourite.priceText)
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadFromCache() {
|
||||||
|
stations = WatchFuelCache.loadStations()
|
||||||
|
favourites = WatchFuelCache.refreshedFavourites(WatchFuelCache.loadFavourites(), from: stations)
|
||||||
|
distanceUnit = WatchFuelCache.loadDistanceUnit()
|
||||||
|
priceStyle = WatchFuelCache.loadPriceStyle()
|
||||||
|
lastLocation = WatchFuelCache.loadLocation()
|
||||||
|
refreshState = statusFromCache()
|
||||||
|
|
||||||
|
let availableFuels = Set(favourites.map(\.fuel))
|
||||||
|
if !availableFuels.isEmpty, !availableFuels.contains(fuel) {
|
||||||
|
fuel = availableFuels.sorted { $0.sortRank < $1.sortRank }.first ?? .e10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusFromCache() -> WatchRefreshState {
|
||||||
|
guard !stations.isEmpty else { return .noData }
|
||||||
|
guard let lastRefresh = WatchFuelCache.loadLastRefresh() else { return .stale(lastUpdated: "earlier") }
|
||||||
|
let formatted = relativeRefreshText(for: lastRefresh)
|
||||||
|
return WatchFuelCache.isCacheFresh(lastRefresh: lastRefresh)
|
||||||
|
? .idle(lastUpdated: formatted)
|
||||||
|
: .stale(lastUpdated: formatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runCheckNow() {
|
||||||
|
refreshState = .checking
|
||||||
|
let requestDate = Date()
|
||||||
|
WatchFuelCache.requestRefresh(requestDate)
|
||||||
|
Task {
|
||||||
|
for _ in 0..<12 {
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
if WatchFuelCache.wasRefreshHandled(since: requestDate) {
|
||||||
|
await MainActor.run {
|
||||||
|
reloadFromCache()
|
||||||
|
let label = WatchFuelCache.loadLastRefresh().map(relativeRefreshText(for:)) ?? "just now"
|
||||||
|
refreshState = .requested(lastUpdated: label)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
reloadFromCache()
|
||||||
|
refreshState = .needsPhone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relativeRefreshText(for date: Date) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .short
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct FuelBoardWatchApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
} minimal: {
|
} minimal: {
|
||||||
@@ -49,12 +49,15 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
.font(.caption2.bold().monospacedDigit())
|
.font(.caption2.bold().monospacedDigit())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NOTE: deliberately NO `.supplementalActivityFamilies([.small])`.
|
.supplementalActivityFamilies([.small])
|
||||||
// That modifier makes iOS eligible to render this activity in the
|
// Why `.small` is kept: it lets the SHARED body render a compact form
|
||||||
// narrow `.small` form on the iPhone/iPad Lock Screen, which is what
|
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
||||||
// produced the squeezed, small-text card. Dropping it keeps the
|
// instead of falling back to the Dynamic Island compact closure —
|
||||||
// full-width Lock Screen card on iPhone/iPad; CarPlay still shows a
|
// which could NOT show the station distance the user wants on CarPlay.
|
||||||
// small form via the Dynamic Island compact closures below.
|
// The full-width iPhone/iPad card is protected by the `richMinWidth`
|
||||||
|
// gate on `richBody` + its flexible, truncating middle column, so
|
||||||
|
// iPhone/iPad still get the full card; only truly small space picks
|
||||||
|
// the compact strip below.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,29 +65,59 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
private struct FuelBoardLiveActivityView: View {
|
private struct FuelBoardLiveActivityView: View {
|
||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
|
/// Below this ACTUAL proposed width we show the compact strip (CarPlay
|
||||||
|
/// small / Watch smart stack); at/above it we show the full card. The
|
||||||
|
/// decision is made from the real width the system hands the body, read
|
||||||
|
/// via a background GeometryReader — NOT ViewThatFits ideal-width
|
||||||
|
/// measurement (that's broken for truncating text: a long station name
|
||||||
|
/// inflated the "ideal" width past the iPhone Lock Screen and collapsed
|
||||||
|
/// the full card).
|
||||||
|
private let compactWidthThreshold: CGFloat = 280
|
||||||
|
|
||||||
|
/// Measured slot width (drives the rich-vs-compact branch). Measured in a
|
||||||
|
/// background GeometryReader so it does NOT act as the layout container:
|
||||||
|
/// a GeometryReader root pins content top-left, and forcing a
|
||||||
|
/// maxHeight:.infinity frame on it over-claims the whole proposed height,
|
||||||
|
/// centring the content below true vertical centre (bigger gap above) on
|
||||||
|
/// the Lock Screen. Measuring behind the scenes keeps the content
|
||||||
|
/// intrinsic-sized so the system vertically centres it itself.
|
||||||
|
@State private var slotWidth: CGFloat = 400
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: context.state.mapsURL) {
|
||||||
// Always the full three-column card. The station caption is
|
Group {
|
||||||
// line-limited + scale-down + tail-truncated, so a LONG station
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||||
// name truncates in place instead of inflating this view's ideal
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||||
// width and tricking ViewThatFits into falling back to the compact
|
// how long the station name is. Truly small slots (CarPlay /
|
||||||
// strip (that is exactly what made 5-mi / long-named activities
|
// Watch) offer much less → compact strip.
|
||||||
// render small while 10-15-mi / short names stayed full).
|
if slotWidth >= compactWidthThreshold {
|
||||||
//
|
richBody
|
||||||
// No ViewThatFits / compactBody: with `.supplementalActivityFamilies`
|
} else {
|
||||||
// removed, this body is only ever handed Lock-Screen width, so the
|
compactBody
|
||||||
// compact fallback was both dead weight and the cause of the bug.
|
}
|
||||||
richBody
|
}
|
||||||
|
// Fill the card width so the background measure reads the real
|
||||||
|
// slot, not the intrinsic content width.
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
// Side-channel width measurement — never the layout container.
|
||||||
|
.background(
|
||||||
|
GeometryReader { geo in
|
||||||
|
Color.clear
|
||||||
|
.onAppear { slotWidth = geo.size.width }
|
||||||
|
.onChange(of: geo.size.width) { _, w in slotWidth = w }
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
||||||
private var richBody: some View {
|
private var richBody: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// LEFT — station brand glyph
|
// LEFT — station brand glyph: the fuel-tinted pump on its own. No
|
||||||
Image(systemName: "fuelpump.circle.fill")
|
// background circle behind it (user request).
|
||||||
.font(.system(size: 32))
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green, .white)
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
// MIDDLE — fuel + station
|
// MIDDLE — fuel + station
|
||||||
@@ -113,7 +146,31 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: `compactBody` was removed — always render `richBody` (see body).
|
/// Minimal strip for small space (CarPlay small / Watch smart stack):
|
||||||
|
/// fuel type + bold price on one line, station · distance below.
|
||||||
|
/// Deliberately no app name and no "Tap for directions" — CarPlay is
|
||||||
|
/// display-only, and the user's asks here are just fuel + price + distance.
|
||||||
|
private var compactBody: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Text(context.state.fuel.displayName)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
|
style: context.state.priceDisplayStyle,
|
||||||
|
size: 15, weight: .bold)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
}
|
}
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
|
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
@@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
+181
-4
@@ -8,6 +8,7 @@
|
|||||||
// keychain → app-group defaults → fallback.
|
// keychain → app-group defaults → fallback.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
import Security
|
import Security
|
||||||
#if canImport(AppIntents)
|
#if canImport(AppIntents)
|
||||||
import AppIntents
|
import AppIntents
|
||||||
@@ -99,6 +100,20 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
||||||
|
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
|
||||||
|
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
|
||||||
|
/// definition.
|
||||||
|
extension FuelType {
|
||||||
|
var tintColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
||||||
|
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
||||||
|
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if canImport(AppIntents)
|
#if canImport(AppIntents)
|
||||||
extension FuelType: AppEnum {}
|
extension FuelType: AppEnum {}
|
||||||
#endif
|
#endif
|
||||||
@@ -355,6 +370,8 @@ struct FuelStore {
|
|||||||
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
||||||
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
||||||
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
||||||
|
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970)
|
||||||
|
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
|
||||||
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
||||||
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
||||||
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
|
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
|
||||||
@@ -862,6 +879,34 @@ struct FuelStore {
|
|||||||
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func requestWatchRefresh(_ date: Date = Date()) {
|
||||||
|
saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadWatchRefreshRequest() -> Date? {
|
||||||
|
if let raw = loadString(service: watchRefreshRequestKey), let ts = TimeInterval(raw) {
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func markWatchRefreshHandled(_ date: Date = Date()) {
|
||||||
|
saveString(String(date.timeIntervalSince1970), service: watchRefreshHandledKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadWatchRefreshHandled() -> Date? {
|
||||||
|
if let raw = loadString(service: watchRefreshHandledKey), let ts = TimeInterval(raw) {
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static var hasPendingWatchRefreshRequest: Bool {
|
||||||
|
guard let requested = loadWatchRefreshRequest() else { return false }
|
||||||
|
guard let handled = loadWatchRefreshHandled() else { return true }
|
||||||
|
return requested > handled
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Relay metadata — shown in Settings → About. Written after each
|
// MARK: Relay metadata — shown in Settings → About. Written after each
|
||||||
// successful full fetch so the About section reflects the live source.
|
// successful full fetch so the About section reflects the live source.
|
||||||
|
|
||||||
@@ -919,15 +964,127 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Onboarding — the app shows the intro screen on first launch only
|
// MARK: Onboarding — the app shows the intro screen on first launch only
|
||||||
// (a test button in the Alerts tab re-opens it). Stored in the app group
|
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
|
||||||
// so the widget can see it too if ever needed.
|
// (with an app-group mirror) for the same reason as favourites/distance
|
||||||
|
// unit: free SideStore accounts don't provision the app-group container,
|
||||||
|
// so an app-group-only flag silently fails to save AND reloads as false,
|
||||||
|
// making onboarding re-appear on every launch. Keychain survives reinstall
|
||||||
|
// and is shared with the extension.
|
||||||
|
|
||||||
static func loadHasCompletedOnboarding() -> Bool {
|
static func loadHasCompletedOnboarding() -> Bool {
|
||||||
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
|
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveHasCompletedOnboarding(_ completed: Bool) {
|
static func saveHasCompletedOnboarding(_ completed: Bool) {
|
||||||
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
|
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Install identity — distinguish a genuinely fresh install (where
|
||||||
|
// onboarding should replay) from later launches of the same install.
|
||||||
|
|
||||||
|
/// Keychain copy of the install id (survives reinstall).
|
||||||
|
private static let installIDKey = "fuelboard.installID"
|
||||||
|
/// Local (app-own container) copy — wiped on reinstall, persists across
|
||||||
|
/// normal launches. Free SideStore accounts have no app-group container,
|
||||||
|
/// so this app-own defaults domain is the reliable "same install" signal.
|
||||||
|
private static let localInstallIDKey = "fuelboard.installID.local"
|
||||||
|
|
||||||
|
/// True when this is a first-ever install OR the app was just reinstalled
|
||||||
|
/// (local install id missing/different from the keychain id). Seeds a fresh
|
||||||
|
/// id into both stores so the next launch within the same install is not a
|
||||||
|
/// "fresh install" any more.
|
||||||
|
static func isFreshInstall() -> Bool {
|
||||||
|
let local = UserDefaults.standard.string(forKey: localInstallIDKey)
|
||||||
|
let remote = loadString(service: installIDKey)
|
||||||
|
if let local, let remote, local == remote { return false }
|
||||||
|
// Fresh / mismatched install (or first launch). Pattern a common id so
|
||||||
|
// subsequent launches of this install are recognised as the same one.
|
||||||
|
let id = UUID().uuidString
|
||||||
|
UserDefaults.standard.set(id, forKey: localInstallIDKey)
|
||||||
|
saveString(id, service: installIDKey)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Onboarding should present when it hasn't been completed in this install
|
||||||
|
/// OR this is a freshly-installed app (so the walkthrough replays on a new
|
||||||
|
/// install, e.g. after a SideStore reinstall, without re-showing on every
|
||||||
|
/// ordinary launch). The install check always runs (it seeds the id) rather
|
||||||
|
/// than short-circuiting, so a brand-new install is recorded before the
|
||||||
|
/// user ever fills in onboarding.
|
||||||
|
static func shouldShowOnboarding() -> Bool {
|
||||||
|
let fresh = isFreshInstall()
|
||||||
|
return !loadHasCompletedOnboarding() || fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
||||||
|
|
||||||
|
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
||||||
|
/// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free
|
||||||
|
/// SideStore accounts where the app-group container isn't provisioned) so
|
||||||
|
/// the widget extension can read it too. Widget + Live Activity prefer
|
||||||
|
/// these over straight-line haversine for the displayed distance.
|
||||||
|
static let roadDistancesKey = "fuelboard.roadDistances"
|
||||||
|
|
||||||
|
/// How far (metres) the cache's source location may be from the current
|
||||||
|
/// user position before a cached road distance is treated as stale.
|
||||||
|
static let roadDistanceOriginToleranceMeters: Double = 600
|
||||||
|
|
||||||
|
/// How far (degrees) a station's stored coordinate may drift from the pin
|
||||||
|
/// a road distance was actually routed to before that cached value is
|
||||||
|
/// treated as belonging to a different station. Guards against one data
|
||||||
|
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
|
||||||
|
/// road distance that was computed for a different coordinate under the
|
||||||
|
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
|
||||||
|
/// catches any real pin change.
|
||||||
|
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
||||||
|
|
||||||
|
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
|
||||||
|
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||||
|
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
||||||
|
if let data = try? JSONEncoder().encode(cache) {
|
||||||
|
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRoadDistances() -> RoadDistanceCache? {
|
||||||
|
guard let raw = loadString(service: roadDistancesKey),
|
||||||
|
let data = Data(base64Encoded: raw),
|
||||||
|
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
|
||||||
|
else { return nil }
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached road distance (metres) to a station from the user's location, or
|
||||||
|
/// nil when not cached / the cache was built too far from where the user
|
||||||
|
/// is now / the station's own pin doesn't match the coordinate that was
|
||||||
|
/// routed.
|
||||||
|
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||||
|
guard let cache = loadRoadDistances(),
|
||||||
|
let entry = cache.entries[station.id] else { return nil }
|
||||||
|
// The cache is only valid near the location it was built from.
|
||||||
|
let dLat = (userLat - cache.sourceLat) * .pi / 180
|
||||||
|
let dLng = (userLng - cache.sourceLng) * .pi / 180
|
||||||
|
let r = 6371000.0
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) *
|
||||||
|
sin(dLng / 2) * sin(dLng / 2)
|
||||||
|
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
|
||||||
|
// Pin fingerprint: never serve a routed value for a coordinate we
|
||||||
|
// didn't actually route to. This is the guard that keeps embedded /
|
||||||
|
// live / cached station sets from injecting each other's road metres.
|
||||||
|
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
||||||
|
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
||||||
|
return entry.meters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance (km) to display for a station: cached ROAD distance when
|
||||||
|
/// available (matches Apple Maps), else straight-line haversine.
|
||||||
|
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
||||||
|
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
||||||
|
return meters / 1000.0
|
||||||
|
}
|
||||||
|
return station.distanceKM(to: userLat, lng2: userLng)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Low-level keychain helpers
|
// MARK: Low-level keychain helpers
|
||||||
@@ -989,3 +1146,23 @@ struct FuelStore {
|
|||||||
loadString(service: "widget.diag.\(intentType)")
|
loadString(service: "widget.diag.\(intentType)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached Apple-Maps road distances for nearby stations (see
|
||||||
|
/// A single cached road distance plus the station pin it was routed to. Keeping
|
||||||
|
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
|
||||||
|
/// *different* coordinate under the same ID — the guard that stops embedded /
|
||||||
|
/// live / cached station sets cross-contaminating the distance display.
|
||||||
|
struct CachedRoadDistance: Codable {
|
||||||
|
let meters: Double
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
||||||
|
/// the routed pin.
|
||||||
|
struct RoadDistanceCache: Codable {
|
||||||
|
let sourceLat: Double
|
||||||
|
let sourceLng: Double
|
||||||
|
let updatedAt: TimeInterval
|
||||||
|
let entries: [String: CachedRoadDistance]
|
||||||
|
}
|
||||||
|
|||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PROJECT="$PWD/FuelBoard.xcodeproj"
|
||||||
|
SCHEME="FuelBoard"
|
||||||
|
DERIVED_DATA="/tmp/fuelboard-build-ipas"
|
||||||
|
SERVE_DIR="/tmp/ipa-serve"
|
||||||
|
FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa"
|
||||||
|
SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa"
|
||||||
|
HOST="192.168.1.131"
|
||||||
|
PORT="8765"
|
||||||
|
|
||||||
|
APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
||||||
|
STAGE_FULL="/tmp/fuelboard-ipa-stage-full"
|
||||||
|
STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload"
|
||||||
|
|
||||||
|
echo "==> Refresh bundled dump"
|
||||||
|
"$PWD/scripts/refresh_bundled_dump.sh"
|
||||||
|
|
||||||
|
echo "==> Clean build dirs"
|
||||||
|
rm -rf "$DERIVED_DATA" "$STAGE_FULL" "$STAGE_SIDELOAD"
|
||||||
|
mkdir -p "$SERVE_DIR"
|
||||||
|
|
||||||
|
echo "==> Build Release app (generic iOS destination)"
|
||||||
|
xcodebuild \
|
||||||
|
-project "$PROJECT" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-derivedDataPath "$DERIVED_DATA" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY='' \
|
||||||
|
build >/tmp/fuelboard-build-ipas-xcodebuild.log
|
||||||
|
|
||||||
|
if [ ! -d "$APP_PATH" ]; then
|
||||||
|
echo "missing built app: $APP_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Package full watch-capable IPA"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, shutil, zipfile
|
||||||
|
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
|
stage = '/tmp/fuelboard-ipa-stage-full'
|
||||||
|
payload = os.path.join(stage, 'Payload')
|
||||||
|
out = '/tmp/ipa-serve/FuelBoard-watch.ipa'
|
||||||
|
if os.path.exists(stage):
|
||||||
|
shutil.rmtree(stage)
|
||||||
|
os.makedirs(payload, exist_ok=True)
|
||||||
|
shutil.copytree(app, os.path.join(payload, 'FuelBoard.app'))
|
||||||
|
tmp = out + '.tmp'
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
||||||
|
for root, dirs, files in os.walk(stage):
|
||||||
|
dirs.sort(); files.sort()
|
||||||
|
for name in dirs:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
zf.write(path, os.path.relpath(path, stage))
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
zf.write(path, os.path.relpath(path, stage))
|
||||||
|
os.replace(tmp, out)
|
||||||
|
print(out)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "==> Package sideload-safe IPA (strip Watch/)"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, shutil, zipfile
|
||||||
|
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
|
stage = '/tmp/fuelboard-ipa-stage-sideload'
|
||||||
|
payload = os.path.join(stage, 'Payload')
|
||||||
|
out = '/tmp/ipa-serve/FuelBoard-sideload.ipa'
|
||||||
|
if os.path.exists(stage):
|
||||||
|
shutil.rmtree(stage)
|
||||||
|
os.makedirs(payload, exist_ok=True)
|
||||||
|
dst = os.path.join(payload, 'FuelBoard.app')
|
||||||
|
shutil.copytree(app, dst)
|
||||||
|
watch_dir = os.path.join(dst, 'Watch')
|
||||||
|
if os.path.exists(watch_dir):
|
||||||
|
shutil.rmtree(watch_dir)
|
||||||
|
tmp = out + '.tmp'
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
||||||
|
for root, dirs, files in os.walk(stage):
|
||||||
|
dirs.sort(); files.sort()
|
||||||
|
for name in dirs:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
zf.write(path, os.path.relpath(path, stage))
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
zf.write(path, os.path.relpath(path, stage))
|
||||||
|
os.replace(tmp, out)
|
||||||
|
print(out)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "==> Verify artifacts"
|
||||||
|
ls -lh "$FULL_IPA" "$SIDELOAD_IPA"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, plistlib, json
|
||||||
|
base = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
|
paths = {
|
||||||
|
'host': os.path.join(base, 'Info.plist'),
|
||||||
|
'watch': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'Info.plist'),
|
||||||
|
'watch_ext': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'PlugIns', 'FuelBoardWatch Watch App Extension.appex', 'Info.plist'),
|
||||||
|
}
|
||||||
|
for name, path in paths.items():
|
||||||
|
print(f'## {name} {os.path.exists(path)}')
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
pl = plistlib.load(f)
|
||||||
|
print(json.dumps({k: pl.get(k) for k in ['CFBundleIdentifier','CFBundleShortVersionString','CFBundleVersion']}, indent=2))
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "==> URLs"
|
||||||
|
echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa"
|
||||||
|
echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa"
|
||||||
|
echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"
|
||||||
Reference in New Issue
Block a user