Compare 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/
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>2</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
@@ -41,8 +41,13 @@
|
|||||||
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.apt.fuelboard.smart-refresh</string>
|
||||||
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
<string>location</string>
|
<string>location</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
|
|||||||
@@ -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>FuelBoard</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>FuelBoard</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>
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.1</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>2</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@@ -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,266 @@
|
|||||||
/* 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 = 3;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
|
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 = 3;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
|
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 = 3;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
|
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 = 3;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
|
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 = {
|
||||||
@@ -291,14 +699,15 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
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 = 3;
|
||||||
|
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",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -313,14 +722,15 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
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 = 3;
|
||||||
|
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",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -335,15 +745,16 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
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 = 3;
|
||||||
|
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",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -359,15 +770,16 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
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 = 3;
|
||||||
|
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",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -380,11 +792,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 +828,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 */;
|
||||||
|
|||||||
@@ -1,38 +1,170 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images": [
|
||||||
{
|
{
|
||||||
"filename" : "AppIcon.png",
|
"filename": "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom": "universal",
|
||||||
"platform" : "ios",
|
"platform": "ios",
|
||||||
"size" : "1024x1024"
|
"size": "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appearances" : [
|
"appearances": [
|
||||||
{
|
{
|
||||||
"appearance" : "luminosity",
|
"appearance": "luminosity",
|
||||||
"value" : "dark"
|
"value": "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "AppIcon-dark.png",
|
"filename": "AppIcon-dark.png",
|
||||||
"idiom" : "universal",
|
"idiom": "universal",
|
||||||
"platform" : "ios",
|
"platform": "ios",
|
||||||
"size" : "1024x1024"
|
"size": "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appearances" : [
|
"appearances": [
|
||||||
{
|
{
|
||||||
"appearance" : "luminosity",
|
"appearance": "luminosity",
|
||||||
"value" : "tinted"
|
"value": "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "AppIcon-tinted.png",
|
"filename": "AppIcon-tinted.png",
|
||||||
"idiom" : "universal",
|
"idiom": "universal",
|
||||||
"platform" : "ios",
|
"platform": "ios",
|
||||||
"size" : "1024x1024"
|
"size": "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-48.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "24x24",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-55.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "27.5x27.5",
|
||||||
|
"subtype": "42mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-66.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "33x33",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-CompanionSettings-58.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "companionSettings",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-CompanionSettings-87.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "companionSettings",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-80.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "40x40",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-88.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "44x44",
|
||||||
|
"subtype": "40mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-92.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "46x46",
|
||||||
|
"subtype": "41mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-100.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "50x50",
|
||||||
|
"subtype": "44mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-102.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "51x51",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-108.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "54x54",
|
||||||
|
"subtype": "49mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-172.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "86x86",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-196.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "98x98",
|
||||||
|
"subtype": "42mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-216.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "108x108",
|
||||||
|
"subtype": "44mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-234.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "117x117",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-258.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "129x129",
|
||||||
|
"subtype": "49mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-Marketing-1024.png",
|
||||||
|
"idiom": "watch-marketing",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info": {
|
||||||
"author" : "xcode",
|
"author": "xcode",
|
||||||
"version" : 1
|
"version": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -16,10 +16,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@State private var stations: [FuelStation] = FuelStore.loadStations()
|
@State private var stations: [FuelStation] = FuelStore.loadStations()
|
||||||
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||||
|
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
|
||||||
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
||||||
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
||||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||||
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||||
|
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
||||||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||||
@@ -50,6 +52,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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +235,30 @@ struct ContentView: View {
|
|||||||
.sheet(item: $monitor.pendingStationMap) { request in
|
.sheet(item: $monitor.pendingStationMap) { request in
|
||||||
StationMapView(request: request)
|
StationMapView(request: request)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $monitor.pendingDetailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
||||||
|
let info = note.userInfo ?? [:]
|
||||||
|
if let id = info["stationID"] as? String,
|
||||||
|
let station = stations.first(where: { $0.id == id }) {
|
||||||
|
monitor.pendingDetailStation = station
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Station left the cache — fall back to the map sheet so
|
||||||
|
// the tap still shows something.
|
||||||
|
if let lat = info["stationLat"] as? Double,
|
||||||
|
let lng = info["stationLng"] as? Double {
|
||||||
|
let name = info["stationName"] as? String ?? "Station"
|
||||||
|
monitor.pendingStationMap = StationMapRequest(
|
||||||
|
name: name, latitude: lat, longitude: lng
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +301,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
if !favs.isEmpty {
|
if !favs.isEmpty {
|
||||||
FuelStore.saveFavourites(favs)
|
FuelStore.saveFavourites(favs)
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
||||||
@@ -304,7 +348,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 +361,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 +410,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()
|
||||||
}
|
}
|
||||||
@@ -371,12 +423,14 @@ struct ContentView: View {
|
|||||||
if let newLocation {
|
if let newLocation {
|
||||||
location = newLocation
|
location = newLocation
|
||||||
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
// Geofences follow the user's position, but the station list is
|
// Geofences follow the user's position, but the station list is
|
||||||
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
|
refreshRoadDistancesIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -388,6 +442,7 @@ struct ContentView: View {
|
|||||||
.onChange(of: selectedFuel) { _, _ in
|
.onChange(of: selectedFuel) { _, _ in
|
||||||
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
.onChange(of: stationLimit) { _, newValue in
|
.onChange(of: stationLimit) { _, newValue in
|
||||||
// Distance filter is LOCAL math now — the cache holds the full-UK
|
// Distance filter is LOCAL math now — the cache holds the full-UK
|
||||||
@@ -712,6 +767,7 @@ struct ContentView: View {
|
|||||||
topStationID: topStationID,
|
topStationID: topStationID,
|
||||||
location: location,
|
location: location,
|
||||||
favouriteIDs: favouriteIDs,
|
favouriteIDs: favouriteIDs,
|
||||||
|
stationTapAction: stationTapAction,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onRefresh: { await refresh(force: true) }
|
onRefresh: { await refresh(force: true) }
|
||||||
)
|
)
|
||||||
@@ -726,6 +782,7 @@ struct ContentView: View {
|
|||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
|
stationTapAction: stationTapAction,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onReorder: reorderFavourites,
|
onReorder: reorderFavourites,
|
||||||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||||
@@ -764,6 +821,8 @@ struct ContentView: View {
|
|||||||
tipStore: tipStore,
|
tipStore: tipStore,
|
||||||
distanceUnit: $distanceUnit,
|
distanceUnit: $distanceUnit,
|
||||||
priceDisplayStyle: $priceDisplayStyle,
|
priceDisplayStyle: $priceDisplayStyle,
|
||||||
|
stationTapAction: $stationTapAction,
|
||||||
|
dataRefreshMode: $dataRefreshMode,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
alertsRadiusKM: alertsRadius,
|
alertsRadiusKM: alertsRadius,
|
||||||
testAlertResult: monitor.lastTestResult,
|
testAlertResult: monitor.lastTestResult,
|
||||||
@@ -792,6 +851,7 @@ struct ContentView: View {
|
|||||||
favourites.append(key)
|
favourites.append(key)
|
||||||
}
|
}
|
||||||
FuelStore.saveFavourites(favourites)
|
FuelStore.saveFavourites(favourites)
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
@@ -803,11 +863,17 @@ struct ContentView: View {
|
|||||||
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
|
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
|
||||||
favourites = newOrder
|
favourites = newOrder
|
||||||
FuelStore.saveFavourites(favourites)
|
FuelStore.saveFavourites(favourites)
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
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 +892,10 @@ struct ContentView: View {
|
|||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
FuelStore.saveLastRefresh()
|
||||||
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||||
|
FuelStore.markWatchRefreshHandled()
|
||||||
|
}
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
// 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 +918,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
|
||||||
@@ -886,7 +959,9 @@ struct StationRow: View {
|
|||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
let isTopResult: Bool
|
let isTopResult: Bool
|
||||||
let isFavourite: Bool
|
let isFavourite: Bool
|
||||||
|
var tapAction: StationTapAction = .openMap
|
||||||
var onToggleFavourite: () -> Void = {}
|
var onToggleFavourite: () -> Void = {}
|
||||||
|
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||||
|
|
||||||
private var ragColor: Color {
|
private var ragColor: Color {
|
||||||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||||||
@@ -906,6 +981,21 @@ struct StationRow: View {
|
|||||||
return String(format: "%+.1fp", delta)
|
return String(format: "%+.1fp", delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleTap() {
|
||||||
|
switch tapAction {
|
||||||
|
case .openMap:
|
||||||
|
if let url = station.mapsDirectionsURL {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
case .showDetails:
|
||||||
|
showDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showDetails() {
|
||||||
|
onShowDetails(station)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// LEFT — round brand logo (or generic fuel pump fallback)
|
// LEFT — round brand logo (or generic fuel pump fallback)
|
||||||
@@ -942,6 +1032,12 @@ struct StationRow: View {
|
|||||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
.background(Capsule().fill(.blue.opacity(0.15)))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
}
|
}
|
||||||
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
|
Image(systemName: notice.symbolName)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel(notice.title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text("\(station.address), \(station.postcode)")
|
Text("\(station.address), \(station.postcode)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -949,7 +1045,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()
|
||||||
@@ -984,11 +1080,8 @@ struct StationRow: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture(perform: handleTap)
|
||||||
if let url = station.mapsDirectionsURL {
|
.onLongPressGesture(perform: showDetails)
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct FavouritesView: View {
|
|||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
|
let stationTapAction: StationTapAction
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
/// Persists a reordered favourites array (after drag-and-drop).
|
/// Persists a reordered favourites array (after drag-and-drop).
|
||||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||||
@@ -37,6 +38,9 @@ struct FavouritesView: View {
|
|||||||
/// Trends sheet (price history chart) presentation state.
|
/// Trends sheet (price history chart) presentation state.
|
||||||
@State private var showTrends = false
|
@State private var showTrends = false
|
||||||
|
|
||||||
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
private var activeFuel: FuelType {
|
private var activeFuel: FuelType {
|
||||||
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,7 @@ struct FavouritesView: View {
|
|||||||
location: Coordinate?,
|
location: Coordinate?,
|
||||||
distanceUnit: DistanceUnit,
|
distanceUnit: DistanceUnit,
|
||||||
priceDisplayStyle: PriceDisplayStyle,
|
priceDisplayStyle: PriceDisplayStyle,
|
||||||
|
stationTapAction: StationTapAction,
|
||||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||||
onHistoryUnavailable: (() -> Void)? = nil,
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
@@ -74,6 +79,7 @@ struct FavouritesView: View {
|
|||||||
self.location = location
|
self.location = location
|
||||||
self.distanceUnit = distanceUnit
|
self.distanceUnit = distanceUnit
|
||||||
self.priceDisplayStyle = priceDisplayStyle
|
self.priceDisplayStyle = priceDisplayStyle
|
||||||
|
self.stationTapAction = stationTapAction
|
||||||
self.onToggleFavourite = onToggleFavourite
|
self.onToggleFavourite = onToggleFavourite
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
self.onHistoryUnavailable = onHistoryUnavailable
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
@@ -129,7 +135,9 @@ struct FavouritesView: View {
|
|||||||
baselinePrice: cheapestPrice,
|
baselinePrice: cheapestPrice,
|
||||||
isTopResult: index == 0,
|
isTopResult: index == 0,
|
||||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
tapAction: stationTapAction,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onMove(perform: moveFavourite)
|
.onMove(perform: moveFavourite)
|
||||||
@@ -171,6 +179,13 @@ struct FavouritesView: View {
|
|||||||
onHistoryRecovered: onHistoryRecovered
|
onHistoryRecovered: onHistoryRecovered
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// QA hook: launch with `-showTrends` to open the sheet
|
// QA hook: launch with `-showTrends` to open the sheet
|
||||||
|
|||||||
@@ -1,13 +1,64 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import BackgroundTasks
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct FuelBoardApp: App {
|
struct FuelBoardApp: App {
|
||||||
|
init() {
|
||||||
|
WatchSyncManager.shared.activate()
|
||||||
|
#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()
|
||||||
.onOpenURL(perform: handleOpenURL)
|
.onOpenURL(perform: handleOpenURL)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
|
||||||
|
await SmartDataRefreshCoordinator.runBackgroundProbe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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:
|
||||||
@@ -18,9 +69,40 @@ struct FuelBoardApp: App {
|
|||||||
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
||||||
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
||||||
/// Apple Maps without ever launching this app.
|
/// Apple Maps without ever launching this app.
|
||||||
|
/// A `fuelboard://station?stationID=…&lat=…&lng=…&name=…` link (from a
|
||||||
|
/// widget or Live Activity tap when Settings → Tap station = More info)
|
||||||
|
/// posts a notification that ContentView observes to present the detail
|
||||||
|
/// sheet (falling back to the map sheet when the station has left
|
||||||
|
/// the cache).
|
||||||
private func handleOpenURL(_ url: URL) {
|
private func handleOpenURL(_ url: URL) {
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
let items = components.queryItems
|
else { return }
|
||||||
|
|
||||||
|
if components.scheme == "fuelboard",
|
||||||
|
components.host == "station" {
|
||||||
|
let items = components.queryItems ?? []
|
||||||
|
var userInfo: [String: Any] = [:]
|
||||||
|
if let id = items.first(where: { $0.name == "stationID" })?.value {
|
||||||
|
userInfo["stationID"] = id
|
||||||
|
}
|
||||||
|
if let lat = items.first(where: { $0.name == "lat" })?.value.flatMap(Double.init) {
|
||||||
|
userInfo["stationLat"] = lat
|
||||||
|
}
|
||||||
|
if let lng = items.first(where: { $0.name == "lng" })?.value.flatMap(Double.init) {
|
||||||
|
userInfo["stationLng"] = lng
|
||||||
|
}
|
||||||
|
if let name = items.first(where: { $0.name == "name" })?.value {
|
||||||
|
userInfo["stationName"] = name
|
||||||
|
}
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .fuelBoardShowStationDetail,
|
||||||
|
object: nil,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let items = components.queryItems
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
@Published var lastAlert: String?
|
@Published var lastAlert: String?
|
||||||
@Published private(set) var lastTestResult: String?
|
@Published private(set) var lastTestResult: String?
|
||||||
@Published var pendingStationMap: StationMapRequest?
|
@Published var pendingStationMap: StationMapRequest?
|
||||||
|
/// Station tapped via notification when Settings → Tap station = More info.
|
||||||
|
/// ContentView presents this as a StationDetailView sheet.
|
||||||
|
@Published var pendingDetailStation: FuelStation?
|
||||||
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
||||||
/// every location/stations change via `refreshDebugStatus()`.
|
/// every location/stations change via `refreshDebugStatus()`.
|
||||||
@Published private(set) var debugStatus: DebugLocationStatus?
|
@Published private(set) var debugStatus: DebugLocationStatus?
|
||||||
@@ -791,9 +794,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tapping a notification opens Apple Maps directions to the station from
|
/// Tapping a notification honours Settings → Tap station: Open map opens
|
||||||
/// the user's current location. If Apple Maps can't be opened (e.g. inside
|
/// Apple Maps directions to the station from the user's current location;
|
||||||
/// LiveContainer), an in-app map sheet with directions is shown instead.
|
/// More info opens the station's detail sheet in the app instead. If Apple
|
||||||
|
/// Maps can't be opened (e.g. inside LiveContainer), an in-app map sheet
|
||||||
|
/// with directions is shown instead.
|
||||||
///
|
///
|
||||||
/// When the notification carried tie-choice action buttons, the tapped
|
/// When the notification carried tie-choice action buttons, the tapped
|
||||||
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
||||||
@@ -818,9 +823,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
let tieName = ties[index]["name"] as? String,
|
let tieName = ties[index]["name"] as? String,
|
||||||
let tieLat = ties[index]["lat"] as? Double,
|
let tieLat = ties[index]["lat"] as? Double,
|
||||||
let tieLng = ties[index]["lng"] as? Double {
|
let tieLng = ties[index]["lng"] as? Double {
|
||||||
openDirections(to: tieName, latitude: tieLat, longitude: tieLng)
|
routeNotificationTap(name: tieName, latitude: tieLat, longitude: tieLng)
|
||||||
} else if !isTie, let name, let lat, let lng {
|
} else if !isTie, let name, let lat, let lng {
|
||||||
openDirections(to: name, latitude: lat, longitude: lng)
|
routeNotificationTap(name: name, latitude: lat, longitude: lng)
|
||||||
}
|
}
|
||||||
// Tie body tap: intentionally no directions — the user must pick
|
// Tie body tap: intentionally no directions — the user must pick
|
||||||
// an option button to choose a station.
|
// an option button to choose a station.
|
||||||
@@ -828,6 +833,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes a resolved notification tap through the user's Tap station
|
||||||
|
/// preference: Open map → Apple Maps; More info → in-app detail sheet
|
||||||
|
/// (falling back to the map sheet when the station isn't in cache).
|
||||||
|
@MainActor
|
||||||
|
private func routeNotificationTap(name: String, latitude: Double, longitude: Double) {
|
||||||
|
guard FuelStore.loadStationTapAction() == .showDetails else {
|
||||||
|
openDirections(to: name, latitude: latitude, longitude: longitude)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let station = stations.first(where: { $0.lat == latitude && $0.lng == longitude })
|
||||||
|
?? stations.first(where: { $0.name == name }) {
|
||||||
|
pendingDetailStation = station
|
||||||
|
} else {
|
||||||
|
// Station not in cache (e.g. data refreshed since the alert fired)
|
||||||
|
// — fall back to the map sheet so the tap still shows something.
|
||||||
|
pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens Apple Maps with driving directions to the station; falls back to
|
/// Opens Apple Maps with driving directions to the station; falls back to
|
||||||
/// the in-app map sheet when the hand-off fails.
|
/// the in-app map sheet when the hand-off fails.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ struct SettingsView: View {
|
|||||||
@ObservedObject var tipStore: TipStore
|
@ObservedObject var tipStore: TipStore
|
||||||
@Binding var distanceUnit: DistanceUnit
|
@Binding var distanceUnit: DistanceUnit
|
||||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||||
|
@Binding var stationTapAction: StationTapAction
|
||||||
|
@Binding var dataRefreshMode: DataRefreshMode
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
/// tab) so the test notification matches what real alerts will say.
|
/// tab) so the test notification matches what real alerts will say.
|
||||||
var alertsFuel: FuelType = .e10
|
var alertsFuel: FuelType = .e10
|
||||||
@@ -103,6 +105,7 @@ struct SettingsView: View {
|
|||||||
.onChange(of: distanceUnit) { _, newValue in
|
.onChange(of: distanceUnit) { _, newValue in
|
||||||
FuelStore.saveDistanceUnit(newValue)
|
FuelStore.saveDistanceUnit(newValue)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
Picker("Price display", selection: $priceDisplayStyle) {
|
Picker("Price display", selection: $priceDisplayStyle) {
|
||||||
ForEach(PriceDisplayStyle.allCases) { style in
|
ForEach(PriceDisplayStyle.allCases) { style in
|
||||||
@@ -113,6 +116,7 @@ struct SettingsView: View {
|
|||||||
.onChange(of: priceDisplayStyle) { _, newValue in
|
.onChange(of: priceDisplayStyle) { _, newValue in
|
||||||
FuelStore.savePriceDisplayStyle(newValue)
|
FuelStore.savePriceDisplayStyle(newValue)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Units")
|
Text("Units")
|
||||||
@@ -120,6 +124,46 @@ struct SettingsView: View {
|
|||||||
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Tap station", selection: $stationTapAction) {
|
||||||
|
ForEach(StationTapAction.allCases) { action in
|
||||||
|
Text(action.displayName).tag(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: stationTapAction) { _, newValue in
|
||||||
|
FuelStore.saveStationTapAction(newValue)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Tap action")
|
||||||
|
} footer: {
|
||||||
|
Text("Tapping a station either opens directions straight away or shows its details first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Data checking", selection: $dataRefreshMode) {
|
||||||
|
ForEach(DataRefreshMode.allCases) { mode in
|
||||||
|
Text(mode.displayName).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: dataRefreshMode) { _, newValue in
|
||||||
|
FuelStore.saveDataRefreshMode(newValue)
|
||||||
|
SmartDataRefreshScheduler.scheduleNextIfNeeded()
|
||||||
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(dataRefreshMode.summary)
|
||||||
|
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
} header: {
|
||||||
|
Text("Data checking")
|
||||||
|
} footer: {
|
||||||
|
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
onShowOnboarding()
|
onShowOnboarding()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Foundation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum SmartDataRefreshScheduler {
|
||||||
|
static let taskIdentifier = "com.apt.fuelboard.smart-refresh"
|
||||||
|
|
||||||
|
static func register() {
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
|
||||||
|
guard let task = task as? BGAppRefreshTask else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handle(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func scheduleNextIfNeeded() {
|
||||||
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier)
|
||||||
|
guard FuelStore.loadDataRefreshMode() == .smart else { return }
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval)
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("SMART-REFRESH schedule failed: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handle(_ task: BGAppRefreshTask) {
|
||||||
|
scheduleNextIfNeeded()
|
||||||
|
let refreshTask = Task {
|
||||||
|
let success = await SmartDataRefreshCoordinator.runBackgroundProbe()
|
||||||
|
task.setTaskCompleted(success: success)
|
||||||
|
}
|
||||||
|
task.expirationHandler = {
|
||||||
|
refreshTask.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SmartDataRefreshCoordinator {
|
||||||
|
@discardableResult
|
||||||
|
static func runBackgroundProbe(now: Date = Date()) async -> Bool {
|
||||||
|
guard FuelStore.loadDataRefreshMode() == .smart else { return true }
|
||||||
|
guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
FuelStore.saveLastSmartProbe(now)
|
||||||
|
guard !Task.isCancelled else { return false }
|
||||||
|
|
||||||
|
guard let latest = await FuelHistoryStore.fetchLatest() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let latestDay = latest.availableTo ?? latest.date
|
||||||
|
let latestUpdated = latest.dataUpdated
|
||||||
|
let cachedDay = MirrorFuelProvider.loadDumpCache()?.day
|
||||||
|
let savedUpdated = FuelStore.loadDataUpdated()
|
||||||
|
let shouldFetchFullDump = FuelStore.loadStations().isEmpty
|
||||||
|
|| !FuelStore.isCacheFresh(now: now)
|
||||||
|
|| (latestDay != nil && latestDay != cachedDay)
|
||||||
|
|| (latestUpdated != nil && latestUpdated != savedUpdated)
|
||||||
|
|
||||||
|
guard shouldFetchFullDump, !Task.isCancelled else { return true }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fetched = try await FuelPriceProvider.active.fetchStations(
|
||||||
|
near: nil,
|
||||||
|
lng: nil,
|
||||||
|
fuel: FuelStore.loadSelectedFuel(),
|
||||||
|
radiusKM: nil
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return false }
|
||||||
|
FuelStore.saveStations(fetched)
|
||||||
|
FuelStore.saveLastRefresh(now)
|
||||||
|
await WatchSyncManager.shared.pushSnapshot()
|
||||||
|
if let meta = LiveChainProvider.latestMeta {
|
||||||
|
FuelStore.saveRelayMeta(meta)
|
||||||
|
}
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import MapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
|
||||||
|
/// Shows a small map preview, the station name, address, distance from the
|
||||||
|
/// current location, and a Directions button that opens Apple Maps — the same
|
||||||
|
/// destination a direct tap would have opened.
|
||||||
|
struct StationDetailView: View {
|
||||||
|
let station: FuelStation
|
||||||
|
let location: Coordinate?
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var showingReportIssueConfirmation = false
|
||||||
|
|
||||||
|
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||||
|
|
||||||
|
private var stationCoordinate: CLLocationCoordinate2D {
|
||||||
|
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Map(position: .constant(.region(MKCoordinateRegion(
|
||||||
|
center: stationCoordinate,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
|
||||||
|
)))) {
|
||||||
|
Marker(station.name, coordinate: stationCoordinate)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.frame(height: 180)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
LabeledContent("Name", value: station.name)
|
||||||
|
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
|
||||||
|
if let location {
|
||||||
|
let km = FuelStore.displayDistanceKM(
|
||||||
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
|
)
|
||||||
|
LabeledContent("Distance", value: distanceUnit.format(km))
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
|
Label(notice.title, systemImage: notice.symbolName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
if let url = station.mapsDirectionsURL {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showingReportIssueConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(station.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Report") {
|
||||||
|
UIApplication.shared.open(reportIssueURL)
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Presentation-only notices derived from existing station metadata.
|
||||||
|
///
|
||||||
|
/// These do not alter `FuelStation`, persistence, filtering, sorting, or
|
||||||
|
/// navigation. They are intentionally small and reusable so additional
|
||||||
|
/// informational station notices can be added later from the UI layer.
|
||||||
|
enum StationNotice: Identifiable, Equatable {
|
||||||
|
case membershipRequired
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "membershipRequired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "Membership required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "creditcard.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func notices(for station: FuelStation) -> [StationNotice] {
|
||||||
|
isCostco(station) ? [.membershipRequired] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isCostco(_ station: FuelStation) -> Bool {
|
||||||
|
containsCostcoToken(station.brand) || containsCostcoToken(station.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func containsCostcoToken(_ value: String) -> Bool {
|
||||||
|
let tokens = value.uppercased().split { !$0.isLetter && !$0.isNumber }
|
||||||
|
return tokens.contains("COSTCO")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ struct StationsView: View {
|
|||||||
let topStationID: String?
|
let topStationID: String?
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let favouriteIDs: Set<String>
|
let favouriteIDs: Set<String>
|
||||||
|
let stationTapAction: StationTapAction
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
var onRefresh: () async -> Void = {}
|
var onRefresh: () async -> Void = {}
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ struct StationsView: View {
|
|||||||
/// info button in the header — keeps the list focused on stations.
|
/// info button in the header — keeps the list focused on stations.
|
||||||
@State private var showKey = false
|
@State private var showKey = false
|
||||||
|
|
||||||
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
||||||
/// directions context + "N/TOTAL stations updated". The numerator is the
|
/// directions context + "N/TOTAL stations updated". The numerator is the
|
||||||
/// current list pool; the denominator is the full UK station total from
|
/// current list pool; the denominator is the full UK station total from
|
||||||
@@ -38,6 +42,9 @@ struct StationsView: View {
|
|||||||
let unit = distanceUnit.label(for: Double(miles))
|
let unit = distanceUnit.label(for: Double(miles))
|
||||||
let fuel = selectedFuel.displayName
|
let fuel = selectedFuel.displayName
|
||||||
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
||||||
|
let tapHint = stationTapAction == .openMap
|
||||||
|
? "tap a station for directions."
|
||||||
|
: "tap a station for details."
|
||||||
let ratio: String
|
let ratio: String
|
||||||
if let total = FuelStore.loadStationCount() {
|
if let total = FuelStore.loadStationCount() {
|
||||||
ratio = "\(totalCount)/\(total)"
|
ratio = "\(totalCount)/\(total)"
|
||||||
@@ -48,9 +55,9 @@ struct StationsView: View {
|
|||||||
if sortMode == .closest {
|
if sortMode == .closest {
|
||||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · tap a station for directions."
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
|
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -126,7 +133,9 @@ struct StationsView: View {
|
|||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
isTopResult: station.id == topStationID,
|
isTopResult: station.id == topStationID,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
tapAction: stationTapAction,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +269,13 @@ struct StationsView: View {
|
|||||||
.presentationDetents([.fraction(0.6)])
|
.presentationDetents([.fraction(0.6)])
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,17 +333,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.
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||||
|
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WatchSyncManager: NSObject, WCSessionDelegate {
|
||||||
|
static let shared = WatchSyncManager()
|
||||||
|
|
||||||
|
private var pendingSnapshotPush = false
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
pendingSnapshotPush = true
|
||||||
|
let session = WCSession.default
|
||||||
|
if session.delegate !== self {
|
||||||
|
session.delegate = self
|
||||||
|
}
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushSnapshot() {
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
pendingSnapshotPush = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try session.updateApplicationContext(snapshotContext())
|
||||||
|
pendingSnapshotPush = false
|
||||||
|
} catch {
|
||||||
|
print("WATCH-SYNC push failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func snapshotContext() -> [String: Any] {
|
||||||
|
var context: [String: Any] = [:]
|
||||||
|
|
||||||
|
if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) {
|
||||||
|
context[FuelStore.favouritesKey] = favourites
|
||||||
|
}
|
||||||
|
// Do NOT send the full station dump to watchOS via WatchConnectivity.
|
||||||
|
// The UK dataset is megabytes large and exceeds application-context
|
||||||
|
// payload limits, which prevents any snapshot from arriving. The
|
||||||
|
// favourites payload already carries station snapshots + prices, so the
|
||||||
|
// watch can render favourites without the full list.
|
||||||
|
|
||||||
|
context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue
|
||||||
|
context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue
|
||||||
|
context[FuelStore.priceDisplayStyleKey] = FuelStore.loadPriceDisplayStyle().rawValue
|
||||||
|
|
||||||
|
if let lastRefresh = FuelStore.loadLastRefresh() {
|
||||||
|
context[FuelStore.lastRefreshKey] = String(lastRefresh.timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let handled = FuelStore.loadWatchRefreshHandled() {
|
||||||
|
context[FuelStore.watchRefreshHandledKey] = String(handled.timeIntervalSince1970)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let location = FuelStore.loadLocationWithDate() {
|
||||||
|
context[FuelStore.locationKey] = "\(location.coordinate.lat),\(location.coordinate.lng),\(location.date.timeIntervalSince1970)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||||
|
if let error {
|
||||||
|
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
if activationState == .activated, self.pendingSnapshotPush {
|
||||||
|
self.pushSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||||
|
|
||||||
|
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||||
|
Task { @MainActor in
|
||||||
|
WCSession.default.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
|
||||||
|
handleIncomingMessage(userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
|
||||||
|
handleIncomingMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func handleIncomingMessage(_ payload: [String: Any]) {
|
||||||
|
if payload["fuelboard.snapshotRequest"] as? String != nil {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.pushSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String,
|
||||||
|
let timestamp = TimeInterval(raw) else { return }
|
||||||
|
let date = Date(timeIntervalSince1970: timestamp)
|
||||||
|
Task { @MainActor in
|
||||||
|
FuelStore.requestWatchRefresh(date)
|
||||||
|
NotificationCenter.default.post(name: .fuelBoardWatchRefreshRequested, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,14 @@
|
|||||||
"Units" = "Units";
|
"Units" = "Units";
|
||||||
"Price display" = "Price display";
|
"Price display" = "Price display";
|
||||||
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
||||||
|
"Tap station" = "Tap station";
|
||||||
|
"Tap action" = "Tap action";
|
||||||
|
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
|
||||||
|
"Open map" = "Open map";
|
||||||
|
"More info" = "More info";
|
||||||
|
"Name" = "Name";
|
||||||
|
"Address" = "Address";
|
||||||
|
"Distance" = "Distance";
|
||||||
"Show introduction" = "Replay onboarding";
|
"Show introduction" = "Replay onboarding";
|
||||||
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
||||||
"Test alert notification (real data)" = "Test alert notification (real data)";
|
"Test alert notification (real data)" = "Test alert notification (real data)";
|
||||||
@@ -139,6 +147,12 @@
|
|||||||
"You" = "You";
|
"You" = "You";
|
||||||
"Done" = "Done";
|
"Done" = "Done";
|
||||||
"Directions" = "Directions";
|
"Directions" = "Directions";
|
||||||
|
"Report issue" = "Report issue";
|
||||||
|
"Membership required" = "Membership required";
|
||||||
|
"Report an error?" = "Report an error?";
|
||||||
|
"Cancel" = "Cancel";
|
||||||
|
"Report" = "Report";
|
||||||
|
"Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser." = "Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.";
|
||||||
|
|
||||||
/* Station row */
|
/* Station row */
|
||||||
"best" = "best";
|
"best" = "best";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/DataRefreshMode.swift
|
||||||
@@ -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,156 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-48.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "24x24",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-55.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "27.5x27.5",
|
||||||
|
"subtype": "42mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-66.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "33x33",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-CompanionSettings-58.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "companionSettings",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-CompanionSettings-87.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "companionSettings",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-80.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "40x40",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-88.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "44x44",
|
||||||
|
"subtype": "40mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-92.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "46x46",
|
||||||
|
"subtype": "41mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-100.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "50x50",
|
||||||
|
"subtype": "44mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-102.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "51x51",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-108.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "54x54",
|
||||||
|
"subtype": "49mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-172.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "86x86",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-196.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "98x98",
|
||||||
|
"subtype": "42mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-216.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "108x108",
|
||||||
|
"subtype": "44mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-234.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "117x117",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-258.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "129x129",
|
||||||
|
"subtype": "49mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-Marketing-1024.png",
|
||||||
|
"idiom": "watch-marketing",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-LongLook-88.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "longLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "44x44",
|
||||||
|
"subtype": "42mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-LongLook-100.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "longLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "50x50",
|
||||||
|
"subtype": "44mm"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String { "fuelpump.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
|
||||||
|
let latitude: Double
|
||||||
|
let longitude: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchRefreshState {
|
||||||
|
case idle(lastUpdated: String)
|
||||||
|
case stale(lastUpdated: String)
|
||||||
|
case checking
|
||||||
|
case requested(lastUpdated: String)
|
||||||
|
case checkedNoNewData(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 "Updated · \(lastUpdated)"
|
||||||
|
case .checkedNoNewData(let lastUpdated): return "No newer data · \(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 .checkedNoNewData: return .secondary
|
||||||
|
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 .checkedNoNewData: return "clock.badge.exclamationmark"
|
||||||
|
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 {
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@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()
|
||||||
|
|
||||||
|
private var displayedFuels: [WatchFuelType] {
|
||||||
|
let fuels = Set(favourites.map(\.fuel))
|
||||||
|
let sorted = fuels.sorted { $0.sortRank < $1.sortRank }
|
||||||
|
return sorted.isEmpty ? [fuel] : sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasAnyFavourites: Bool {
|
||||||
|
!favourites.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if hasAnyFavourites {
|
||||||
|
TabView(selection: $fuel) {
|
||||||
|
ForEach(displayedFuels) { fuelCase in
|
||||||
|
fuelPage(for: fuelCase)
|
||||||
|
.tag(fuelCase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.verticalPage)
|
||||||
|
} else {
|
||||||
|
emptyStateView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
reloadFromCache()
|
||||||
|
WatchSyncManager.shared.requestSnapshot()
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in
|
||||||
|
reloadFromCache()
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
if newPhase == .active {
|
||||||
|
reloadFromCache()
|
||||||
|
WatchSyncManager.shared.requestSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
latitude: favourite.station.lat,
|
||||||
|
longitude: favourite.station.lng
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyStateView: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Image(systemName: "fuelpump.fill")
|
||||||
|
.foregroundStyle(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
|
||||||
|
Text("FuelBoard")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
statusRow
|
||||||
|
|
||||||
|
Button {
|
||||||
|
runCheckNow()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: refreshState.icon)
|
||||||
|
Text("Check now")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("No favourites yet")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Add favourites on iPhone and they’ll appear here.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View {
|
||||||
|
Button {
|
||||||
|
openMaps(latitude: favourite.station.lat, longitude: favourite.station.lng)
|
||||||
|
} label: {
|
||||||
|
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)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.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 {
|
||||||
|
Button {
|
||||||
|
openMaps(latitude: favourite.latitude, longitude: favourite.longitude)
|
||||||
|
} label: {
|
||||||
|
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)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.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 {
|
||||||
|
let hasUsablePrices = !stations.isEmpty
|
||||||
|
|| favourites.contains { favourite in favourite.station.prices[fuel] != nil }
|
||||||
|
guard hasUsablePrices 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 previousLastRefresh = WatchFuelCache.loadLastRefresh()
|
||||||
|
let requestDate = Date()
|
||||||
|
WatchFuelCache.requestRefresh(requestDate)
|
||||||
|
WatchSyncManager.shared.requestRefresh(at: requestDate)
|
||||||
|
Task {
|
||||||
|
for _ in 0..<12 {
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
if WatchFuelCache.wasRefreshHandled(since: requestDate) {
|
||||||
|
await MainActor.run {
|
||||||
|
reloadFromCache()
|
||||||
|
let currentLastRefresh = WatchFuelCache.loadLastRefresh()
|
||||||
|
if let currentLastRefresh,
|
||||||
|
currentLastRefresh >= requestDate,
|
||||||
|
currentLastRefresh > (previousLastRefresh ?? .distantPast) {
|
||||||
|
refreshState = .requested(lastUpdated: relativeRefreshText(for: currentLastRefresh))
|
||||||
|
} else if let currentLastRefresh {
|
||||||
|
refreshState = .checkedNoNewData(lastUpdated: relativeRefreshText(for: currentLastRefresh))
|
||||||
|
} else {
|
||||||
|
refreshState = .noData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openMaps(latitude: Double, longitude: Double) {
|
||||||
|
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||||
|
let destination = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
|
||||||
|
_ = MKMapItem.openMaps(
|
||||||
|
with: [MKMapItem.forCurrentLocation(), destination],
|
||||||
|
launchOptions: [
|
||||||
|
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct FuelBoardWatchApp: App {
|
||||||
|
init() {
|
||||||
|
WatchSyncManager.shared.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let fuelBoardWatchDataDidUpdate = Notification.Name("FuelBoardWatchDataDidUpdate")
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WatchSyncManager: NSObject, WCSessionDelegate {
|
||||||
|
static let shared = WatchSyncManager()
|
||||||
|
|
||||||
|
private let suiteName = "group.com.apt.fuelboard"
|
||||||
|
private let snapshotRequestKey = "fuelboard.snapshotRequest"
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
let session = WCSession.default
|
||||||
|
if session.delegate !== self {
|
||||||
|
session.delegate = self
|
||||||
|
}
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRefresh(at date: Date = Date()) {
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
let raw = String(date.timeIntervalSince1970)
|
||||||
|
let payload = ["fuelboard.watchRefreshRequest": raw]
|
||||||
|
let session = WCSession.default
|
||||||
|
|
||||||
|
if session.isReachable {
|
||||||
|
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
||||||
|
} else {
|
||||||
|
session.transferUserInfo(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestSnapshot() {
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
|
||||||
|
let session = WCSession.default
|
||||||
|
|
||||||
|
if session.isReachable {
|
||||||
|
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
||||||
|
} else {
|
||||||
|
session.transferUserInfo(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sharedDefaults() -> UserDefaults? {
|
||||||
|
UserDefaults(suiteName: suiteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storeSnapshot(_ payload: [String: Any]) {
|
||||||
|
guard let defaults = sharedDefaults() else { return }
|
||||||
|
|
||||||
|
if let favourites = payload["fuelboard.favourites"] as? Data {
|
||||||
|
defaults.set(favourites, forKey: "fuelboard.favourites")
|
||||||
|
}
|
||||||
|
if let fuel = payload["fuelboard.selectedFuel"] as? String {
|
||||||
|
defaults.set(fuel, forKey: "fuelboard.selectedFuel")
|
||||||
|
}
|
||||||
|
if let distanceUnit = payload["fuelboard.distanceUnit"] as? String {
|
||||||
|
defaults.set(distanceUnit, forKey: "fuelboard.distanceUnit")
|
||||||
|
}
|
||||||
|
if let priceStyle = payload["fuelboard.priceDisplayStyle"] as? String {
|
||||||
|
defaults.set(priceStyle, forKey: "fuelboard.priceDisplayStyle")
|
||||||
|
}
|
||||||
|
if let location = payload["fuelboard.lastLocation"] as? String {
|
||||||
|
defaults.set(location, forKey: "fuelboard.lastLocation")
|
||||||
|
}
|
||||||
|
if let lastRefresh = payload["fuelboard.lastRefresh"] as? String {
|
||||||
|
defaults.set(lastRefresh, forKey: "fuelboard.lastRefresh")
|
||||||
|
}
|
||||||
|
if let handled = payload["fuelboard.watchRefreshHandled"] as? String {
|
||||||
|
defaults.set(handled, forKey: "fuelboard.watchRefreshHandled")
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.post(name: .fuelBoardWatchDataDidUpdate, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||||
|
if let error {
|
||||||
|
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if activationState == .activated {
|
||||||
|
requestSnapshot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
|
||||||
|
storeSnapshot(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
|
||||||
|
storeSnapshot(userInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-48.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "24x24",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-55.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "27.5x27.5",
|
||||||
|
"subtype": "42mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-NotificationCenter-66.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "notificationCenter",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "33x33",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-CompanionSettings-58.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "companionSettings",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-CompanionSettings-87.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "companionSettings",
|
||||||
|
"scale": "3x",
|
||||||
|
"size": "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-80.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "40x40",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-88.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "44x44",
|
||||||
|
"subtype": "40mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-92.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "46x46",
|
||||||
|
"subtype": "41mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-100.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "50x50",
|
||||||
|
"subtype": "44mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-102.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "51x51",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-AppLauncher-108.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "appLauncher",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "54x54",
|
||||||
|
"subtype": "49mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-172.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "86x86",
|
||||||
|
"subtype": "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-196.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "98x98",
|
||||||
|
"subtype": "42mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-216.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "108x108",
|
||||||
|
"subtype": "44mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-234.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "117x117",
|
||||||
|
"subtype": "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-QuickLook-258.png",
|
||||||
|
"idiom": "watch",
|
||||||
|
"role": "quickLook",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "129x129",
|
||||||
|
"subtype": "49mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "Watch-Marketing-1024.png",
|
||||||
|
"idiom": "watch-marketing",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
//
|
//
|
||||||
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
||||||
// Shows the cheapest station for the pinned fuel within the app's chosen
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
// radius. Tapping honours Settings → Tap station: Open map opens Apple Maps
|
||||||
|
// directions to that station, More info opens the detail sheet in the app.
|
||||||
//
|
//
|
||||||
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
||||||
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
||||||
@@ -31,30 +32,43 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityCompactView(context: context)
|
FuelBoardLiveActivityCompactView(context: context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
FuelBoardLiveActivityStationView(context: context)
|
FuelBoardLiveActivityStationView(context: context)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
|
}
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
|
}
|
||||||
} minimal: {
|
} minimal: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
Text(context.state.priceText)
|
Text(context.state.priceText)
|
||||||
.font(.caption2.bold().monospacedDigit())
|
.font(.caption2.bold().monospacedDigit())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NOTE: deliberately NO `.supplementalActivityFamilies([.small])`.
|
}
|
||||||
// That modifier makes iOS eligible to render this activity in the
|
.supplementalActivityFamilies([.small])
|
||||||
// narrow `.small` form on the iPhone/iPad Lock Screen, which is what
|
// Why `.small` is kept: it lets the SHARED body render a compact form
|
||||||
// produced the squeezed, small-text card. Dropping it keeps the
|
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
||||||
// full-width Lock Screen card on iPhone/iPad; CarPlay still shows a
|
// instead of falling back to the Dynamic Island compact closure —
|
||||||
// small form via the Dynamic Island compact closures below.
|
// which could NOT show the station distance the user wants on CarPlay.
|
||||||
|
// 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 +76,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: tapDestination) {
|
||||||
// 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 {
|
||||||
//
|
|
||||||
// No ViewThatFits / compactBody: with `.supplementalActivityFamilies`
|
|
||||||
// removed, this body is only ever handed Lock-Screen width, so the
|
|
||||||
// compact fallback was both dead weight and the cause of the bug.
|
|
||||||
richBody
|
richBody
|
||||||
|
} else {
|
||||||
|
compactBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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
|
||||||
@@ -105,7 +149,8 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
style: context.state.priceDisplayStyle,
|
style: context.state.priceDisplayStyle,
|
||||||
size: 22, weight: .bold)
|
size: 22, weight: .bold)
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -113,7 +158,36 @@ 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 + distance + bold price on one line, station name below.
|
||||||
|
/// Deliberately no app name and no full address — the watch-sized slot is
|
||||||
|
/// too tight, so the second line is reserved for the station name only.
|
||||||
|
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)
|
||||||
|
Text(context.state.distanceText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
|
style: context.state.priceDisplayStyle,
|
||||||
|
size: 15, weight: .bold)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Text(context.state.stationName)
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||||
@@ -148,10 +222,27 @@ private struct FuelBoardLiveActivityStationView: View {
|
|||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap routing shared by the Lock Screen card and the island: honours
|
||||||
|
/// Settings → Tap station (Open map → Maps, More info → in-app detail).
|
||||||
|
enum FuelBoardLiveActivityTap {
|
||||||
|
static func tapDestination(for state: FuelBoardLiveActivityAttributes.ContentState) -> URL {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails ? state.detailURL : state.mapsURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FuelBoardLiveActivityView {
|
||||||
|
/// Lock Screen tap honours Settings → Tap station.
|
||||||
|
var tapDestination: URL {
|
||||||
|
FuelBoardLiveActivityTap.tapDestination(for: context.state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ContentState display helpers
|
// MARK: - ContentState display helpers
|
||||||
@@ -173,4 +264,21 @@ extension FuelBoardLiveActivityAttributes.ContentState {
|
|||||||
var mapsURL: URL {
|
var mapsURL: URL {
|
||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to the station's detail sheet, used when
|
||||||
|
/// Settings → Tap station = More info. Carries coordinate/name fallback
|
||||||
|
/// so the app can still show the map sheet when the station has left
|
||||||
|
/// the cache.
|
||||||
|
var detailURL: URL {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: stationID),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: stationName),
|
||||||
|
]
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# FuelBoard Release Notes
|
||||||
|
|
||||||
|
## Next TestFlight build
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
- Added a new Data checking setting with Standard and Smart modes.
|
||||||
|
- Clarified the behavior and background-update expectations in Settings.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
- Added a dedicated **Data checking** section in Settings.
|
||||||
|
- Added two user-facing modes:
|
||||||
|
- **Standard** — Saves battery. Refreshes price data up to twice daily.
|
||||||
|
- **Smart** — Checks for newer data more often in the background and refreshes full prices only when an update is available.
|
||||||
|
- Smart mode now uses a lightweight metadata probe first, then only refreshes the full dataset if newer data is available.
|
||||||
|
- Added clear explanatory copy that background checks are best-effort and happen only when iOS allows.
|
||||||
|
- Kept larger refresh behavior conservative rather than implying exact hourly updates.
|
||||||
|
|
||||||
|
### Why
|
||||||
|
- The previous refresh model was conservative but not very configurable.
|
||||||
|
- The new setting makes the behavior clearer and gives users a choice between battery-friendly default behavior and a smarter background-check mode.
|
||||||
|
- The wording is designed to be honest about iOS background limits.
|
||||||
|
|
||||||
|
### Suggested TestFlight notes
|
||||||
|
- Added new Data checking modes in Settings: Standard and Smart.
|
||||||
|
- Smart mode now checks for newer data more intelligently in the background when iOS allows.
|
||||||
|
- Improved settings copy to better explain data freshness behavior.
|
||||||
|
|
||||||
|
### Suggested What to Test
|
||||||
|
- Open Settings and confirm **Data checking** appears as its own section.
|
||||||
|
- Confirm the two options read clearly and feel understandable:
|
||||||
|
- Standard
|
||||||
|
- Smart
|
||||||
|
- Switch between Standard and Smart and confirm the selection persists.
|
||||||
|
- Confirm the explanatory copy feels accurate and not misleading about iOS background behavior.
|
||||||
|
|
||||||
|
### Implementation note
|
||||||
|
- Standard keeps the existing cache-first refresh behavior.
|
||||||
|
- Smart adds a lightweight probe path before deciding whether to download the full data set.
|
||||||
|
- Background timing remains best-effort under iOS scheduling.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DataRefreshMode: String, CaseIterable, Identifiable {
|
||||||
|
case standard
|
||||||
|
case smart
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .standard: return "Standard"
|
||||||
|
case .smart: return "Smart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
switch self {
|
||||||
|
case .standard:
|
||||||
|
return "Saves battery. Refreshes price data up to twice daily."
|
||||||
|
case .smart:
|
||||||
|
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
||||||
// Everything dynamic lives in ContentState so updates — including a fuel
|
// Everything dynamic lives in ContentState so updates — including a fuel
|
||||||
// change — apply to the running activity without needing to restart it.
|
// change — apply to the running activity without needing to restart it.
|
||||||
// Tapping the activity opens Apple Maps driving directions (maps://).
|
// Tapping the activity honours Settings → Tap station: Open map opens Apple
|
||||||
|
// Maps driving directions (maps://), More info opens the detail sheet in
|
||||||
|
// the app (fuelboard://station).
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -89,17 +89,18 @@ 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 {
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
|
.modifier(WidgetURLModifier(url: isWidget ? station.tapDestinationURL : nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many station rows each family can fit: medium widgets are short
|
/// How many station rows each family can fit: medium widgets are short
|
||||||
@@ -129,7 +130,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
}
|
}
|
||||||
ForEach(entry.stations.prefix(maxRows)) { station in
|
ForEach(entry.stations.prefix(maxRows)) { station in
|
||||||
if isWidget {
|
if isWidget {
|
||||||
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
|
Link(destination: station.tapDestinationURL ?? URL(string: "maps://")!) {
|
||||||
row(station, cheapest: cheapest)
|
row(station, cheapest: cheapest)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -148,7 +149,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -193,6 +208,25 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Station tap action
|
||||||
|
|
||||||
|
/// What happens when a station row is tapped in the Stations/Favourites tabs:
|
||||||
|
/// open Apple Maps directions immediately (historic behaviour, default), or
|
||||||
|
/// show a detail sheet (name, address, distance, Directions button).
|
||||||
|
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case openMap // tap → Apple Maps directions straight away
|
||||||
|
case showDetails // tap → detail sheet, Directions button inside
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .openMap: return "Open map"
|
||||||
|
case .showDetails: return "More info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Station model
|
// MARK: - Station model
|
||||||
|
|
||||||
struct FuelStation: Identifiable, Codable, Equatable {
|
struct FuelStation: Identifiable, Codable, Equatable {
|
||||||
@@ -237,6 +271,30 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to this station's More info sheet. Carries the
|
||||||
|
/// stationID plus coordinate/name fallback so the app can still show the
|
||||||
|
/// map sheet when the station has left the cache.
|
||||||
|
var detailDeepLinkURL: URL? {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: id),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: name),
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget/Live Activity tap destination honouring Settings → Tap station:
|
||||||
|
/// Open map → Apple Maps directions; More info → in-app detail deep link.
|
||||||
|
var tapDestinationURL: URL? {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? detailDeepLinkURL
|
||||||
|
: widgetDirectionsURL
|
||||||
|
}
|
||||||
|
|
||||||
/// Name of the bundled brand logo asset, or nil if unknown.
|
/// Name of the bundled brand logo asset, or nil if unknown.
|
||||||
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
||||||
var brandImageName: String? {
|
var brandImageName: String? {
|
||||||
@@ -355,6 +413,10 @@ 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 dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
|
||||||
|
static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // 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
|
||||||
@@ -523,6 +585,22 @@ struct FuelStore {
|
|||||||
saveString(style.rawValue, service: priceDisplayStyleKey)
|
saveString(style.rawValue, service: priceDisplayStyleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Station tap action — open map vs detail sheet. Stored raw value;
|
||||||
|
// default open map preserves the historic tap behaviour for existing installs.
|
||||||
|
|
||||||
|
static let stationTapActionKey = "fuelboard.stationTapAction"
|
||||||
|
|
||||||
|
static func loadStationTapAction() -> StationTapAction {
|
||||||
|
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
return .openMap
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveStationTapAction(_ action: StationTapAction) {
|
||||||
|
saveString(action.rawValue, service: stationTapActionKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Superscript digit glyphs for the pounds & pence format's small raised
|
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||||
/// third digit (the forecourt style: "£1.29⁹").
|
/// third digit (the forecourt style: "£1.29⁹").
|
||||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
@@ -846,10 +924,24 @@ struct FuelStore {
|
|||||||
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
|
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Refresh policy — data is cached; the app only auto-refreshes
|
// MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps
|
||||||
// twice a day (pull-to-refresh is the manual override).
|
// the same full-dump freshness cap but may probe the tiny mirror pointer
|
||||||
|
// more often in the background and only refresh the full dump if it changed.
|
||||||
|
|
||||||
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
||||||
|
static let smartProbeInterval: TimeInterval = 4 * 60 * 60
|
||||||
|
|
||||||
|
static func loadDataRefreshMode() -> DataRefreshMode {
|
||||||
|
if let raw = loadString(service: dataRefreshModeKey),
|
||||||
|
let mode = DataRefreshMode(rawValue: raw) {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
return .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDataRefreshMode(_ mode: DataRefreshMode) {
|
||||||
|
saveString(mode.rawValue, service: dataRefreshModeKey)
|
||||||
|
}
|
||||||
|
|
||||||
static func loadLastRefresh() -> Date? {
|
static func loadLastRefresh() -> Date? {
|
||||||
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
|
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
|
||||||
@@ -862,6 +954,45 @@ struct FuelStore {
|
|||||||
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func loadLastSmartProbe() -> Date? {
|
||||||
|
if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) {
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveLastSmartProbe(_ date: Date = Date()) {
|
||||||
|
saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -914,20 +1045,141 @@ struct FuelStore {
|
|||||||
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
||||||
/// should be skipped (twice-a-day policy).
|
/// should be skipped (twice-a-day policy).
|
||||||
static var isCacheFresh: Bool {
|
static var isCacheFresh: Bool {
|
||||||
|
isCacheFresh(now: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isCacheFresh(now: Date) -> Bool {
|
||||||
guard let last = loadLastRefresh() else { return false }
|
guard let last = loadLastRefresh() else { return false }
|
||||||
return Date().timeIntervalSince(last) < refreshInterval
|
return now.timeIntervalSince(last) < refreshInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isSmartProbeDue(now: Date = Date()) -> Bool {
|
||||||
|
guard let last = loadLastSmartProbe() else { return true }
|
||||||
|
return now.timeIntervalSince(last) >= smartProbeInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 +1241,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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||