Compare commits
@@ -1,6 +1,7 @@
|
||||
build/
|
||||
build-release/
|
||||
build-sim/
|
||||
build-watch/
|
||||
.DS_Store
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
@@ -144,6 +144,25 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
separate phase. Backlog ancestry: this was the original "relay could keep a
|
||||
rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
|
||||
visible half.
|
||||
- [ ] **Foresight Live Activity (heading-aware next station)** — investigate a
|
||||
driving-time surface that predicts the likely next station ahead of the user
|
||||
from cached data only (no extra source-data refresh on motion). Best v1 shape:
|
||||
Live Activity option, not widget-first, because widget space/refresh budgets
|
||||
are tight and the lock-screen/CarPlay compact slot better matches the use case.
|
||||
Prefer ONE FuelBoard Live Activity with a mode selector (e.g. `Nearby cheapest`
|
||||
vs `Foresight / next ahead`) rather than trying to run two simultaneous
|
||||
FuelBoard activities; iOS can support multiple activities, but FuelBoard's
|
||||
current implementation is single-activity and the compact surfaces are too
|
||||
constrained for two competing driving modes anyway. Viable MVP: infer heading
|
||||
from recent motion/course, keep only stations in a forward cone, score
|
||||
candidates by forward distance + lateral offset, and show the next-ahead
|
||||
station with all three fuel prices or a compact rotating/focused fuel
|
||||
treatment if space allows. Key caveat: heading is not full route awareness,
|
||||
so avoid overpromising "the next station you will reach" on motorways/opposite
|
||||
carriageways; frame as "next ahead" / "likely next stop" unless route-quality
|
||||
heuristics improve. Must include hysteresis/speed thresholds to avoid flapping,
|
||||
and should be tested specifically against compact Live Activity/CarPlay layouts
|
||||
where space is extremely limited.
|
||||
- [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
|
||||
trend, after the Trends UI lands (one-kind rule preserved).
|
||||
- [ ] **Price alerts for a specific station** — notify when a starred station's
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
@@ -41,8 +41,13 @@
|
||||
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.apt.fuelboard.smart-refresh</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>location</string>
|
||||
</array>
|
||||
<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>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.1</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// !$*UTF8*$!
|
||||
// FuelBoard — hand-crafted project (objectVersion 77, synchronized folders).
|
||||
// Targets: FuelBoard (app) + FuelBoardWidgets (widget extension, embedded).
|
||||
// Shared/ is a synchronized root group included in BOTH targets.
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
@@ -10,10 +7,26 @@
|
||||
objects = {
|
||||
|
||||
/* 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, ); }; };
|
||||
/* End PBXBuildFile 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 */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||
@@ -24,6 +37,28 @@
|
||||
/* End PBXContainerItemProxy 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 */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -38,11 +73,23 @@
|
||||
/* End PBXCopyFilesBuildPhase 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; };
|
||||
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference 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 */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = FuelBoard;
|
||||
@@ -61,6 +108,20 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
ADCE5B4C3033A329008C131C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B6D3033A32E008C131C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB000000000000000000000B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -84,6 +145,8 @@
|
||||
BB0000000000000000000008 /* FuelBoard */,
|
||||
BB0000000000000000000009 /* FuelBoardWidgets */,
|
||||
BB000000000000000000000A /* Shared */,
|
||||
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||
BB000000000000000000000E /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -93,6 +156,8 @@
|
||||
children = (
|
||||
BB0000000000000000000007 /* FuelBoard.app */,
|
||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
||||
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
|
||||
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -100,30 +165,50 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
BB000000000000000000000F /* FuelBoard */ = {
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
|
||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
||||
buildPhases = (
|
||||
BB0000000000000000000011 /* Sources */,
|
||||
BB000000000000000000000B /* Frameworks */,
|
||||
BB0000000000000000000012 /* Resources */,
|
||||
BB0000000000000000000006 /* Embed App Extensions */,
|
||||
ADCE5B4B3033A329008C131C /* Sources */,
|
||||
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||
ADCE5B4D3033A329008C131C /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
BB0000000000000000000013 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
BB0000000000000000000008 /* FuelBoard */,
|
||||
BB000000000000000000000A /* Shared */,
|
||||
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||
);
|
||||
name = FuelBoard;
|
||||
name = "FuelBoardWatch Watch App Extension";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = FuelBoard;
|
||||
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
productName = "FuelBoardWatch Watch App Extension";
|
||||
productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */;
|
||||
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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
@@ -148,6 +233,33 @@
|
||||
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
|
||||
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 */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -155,15 +267,18 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
BB000000000000000000000F = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
ADCE5B4E3033A329008C131C = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
BB0000000000000000000005 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
BB000000000000000000000F = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
|
||||
@@ -182,11 +297,27 @@
|
||||
targets = (
|
||||
BB000000000000000000000F /* FuelBoard */,
|
||||
BB0000000000000000000005 /* FuelBoardWidgets */,
|
||||
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
ADCE5B4D3033A329008C131C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B6E3033A32E008C131C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB0000000000000000000012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -204,6 +335,13 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
ADCE5B4B3033A329008C131C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB0000000000000000000011 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -221,6 +359,16 @@
|
||||
/* End PBXSourcesBuildPhase 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 */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = BB0000000000000000000005 /* FuelBoardWidgets */;
|
||||
@@ -229,6 +377,266 @@
|
||||
/* End PBXTargetDependency 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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -291,14 +699,15 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/App-Info.plist;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -313,14 +722,15 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/App-Info.plist;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -335,15 +745,16 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -359,15 +770,16 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -380,11 +792,20 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BB0000000000000000000018 /* Debug */,
|
||||
BB0000000000000000000019 /* Release */,
|
||||
ADCE5B573033A32B008C131C /* Debug */,
|
||||
ADCE5B583033A32B008C131C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ADCE5B6B3033A32D008C131C /* Debug */,
|
||||
ADCE5B6C3033A32D008C131C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -407,6 +828,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BB0000000000000000000018 /* Debug */,
|
||||
BB0000000000000000000019 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = BB0000000000000000000004 /* Project object */;
|
||||
|
||||
@@ -1,38 +1,170 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "AppIcon.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
"appearances": [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-dark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "AppIcon-dark.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
"appearances": [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
"appearance": "luminosity",
|
||||
"value": "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-tinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "AppIcon-tinted.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"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" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"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,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_circle_k_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_circle_k_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_essar_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_essar_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_gleaner_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_gleaner_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_highland_fuels_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_highland_fuels_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 970 B |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_maxol_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_maxol_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_murco_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_murco_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_spar_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_spar_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_the_co_operative_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_the_co_operative_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_valero_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_valero_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "brand_welcome_break_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_welcome_break_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -2,11 +2,21 @@ import SwiftUI
|
||||
import CoreLocation
|
||||
import WidgetKit
|
||||
|
||||
/// The single floating notification banner. Network/offline state and tip
|
||||
/// outcomes all funnel through ONE style and render via the same chrome in
|
||||
/// ContentView — never pushing layout, always overlaid.
|
||||
enum AppBanner: Hashable {
|
||||
case offlineDump(date: String)
|
||||
case connectionProblem
|
||||
case tip(TipStore.TipOutcome)
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@State private var stations: [FuelStation] = FuelStore.loadStations()
|
||||
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
|
||||
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
||||
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||
@@ -32,6 +42,41 @@ struct ContentView: View {
|
||||
private var effectiveAlertsRadiusKM: Double {
|
||||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
||||
}
|
||||
|
||||
private func installLocationUpdateHook() {
|
||||
locationManager.onLocationUpdate = { [weak monitor] in
|
||||
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
// The Live Activity follows the same wake-ups — this hook
|
||||
// fires on every fix incl. background significant-change
|
||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureBackgroundMotionTrackingIfNeeded() {
|
||||
if alertsEnabled || liveActivityEnabled {
|
||||
locationManager.startBackgroundTracking()
|
||||
}
|
||||
}
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var statusMessage = ""
|
||||
/// What data is on screen, driving which (if any) status banner shows
|
||||
@@ -52,6 +97,20 @@ struct ContentView: View {
|
||||
@State private var selectedTab = 0
|
||||
@State private var locationManager = LocationManager()
|
||||
@StateObject private var monitor = ProximityMonitor()
|
||||
/// Owns tip purchases; the single instance is shared down to Settings.
|
||||
/// Its transient outcome rides the SAME status-banner chrome as the
|
||||
/// network/offline strip (ContentView renders it above the tabs).
|
||||
@StateObject private var tipStore = TipStore()
|
||||
|
||||
// --- Explicit banner animation state (robust fade-out) ---
|
||||
// SwiftUI's removal transition for the banner proved unreliable here
|
||||
// (the tip faded to nothing only in the cross-nil case, and even then
|
||||
// inconsistently). Drive opacity/offset explicitly instead so a
|
||||
// dismissal ALWAYS fades + slides up on ANY banner change.
|
||||
@State private var currentBanner: AppBanner?
|
||||
@State private var bannerOpacity: Double = 0
|
||||
@State private var bannerOffset: CGFloat = 0
|
||||
@State private var bannerClearTask: Task<Void, Never>?
|
||||
|
||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
||||
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||
@@ -217,6 +276,7 @@ struct ContentView: View {
|
||||
}
|
||||
if !favs.isEmpty {
|
||||
FuelStore.saveFavourites(favs)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
}
|
||||
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
||||
@@ -263,27 +323,23 @@ struct ContentView: View {
|
||||
#else
|
||||
let shouldSkipOnboarding = false
|
||||
#endif
|
||||
if FuelStore.loadHasCompletedOnboarding()
|
||||
if !FuelStore.shouldShowOnboarding()
|
||||
|| shouldSkipOnboarding {
|
||||
locationManager.startForegroundTracking()
|
||||
// Geofences must follow the user even in the background:
|
||||
// wire the delegate hook (fires on every fix incl. background
|
||||
// significant-change wake-ups) to re-register the region
|
||||
// window around the new position. SwiftUI onChange alone
|
||||
// never runs while suspended, so alerts would otherwise stay
|
||||
// frozen around the last foreground fix.
|
||||
locationManager.onLocationUpdate = { [weak monitor] in
|
||||
monitor?.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
// The Live Activity follows the same wake-ups — this hook
|
||||
// fires on every fix incl. background significant-change
|
||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||
updateLiveActivity()
|
||||
}
|
||||
// Geofences and the Live Activity must follow the user even in
|
||||
// the background: wire the delegate hook (fires on every fix
|
||||
// incl. background significant-change wake-ups) so SwiftUI's
|
||||
// foreground-only onChange isn't the only update path.
|
||||
installLocationUpdateHook()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
monitor.setEnabled(alertsEnabled)
|
||||
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).
|
||||
// Skipped under the force-* hooks so the banner stays up.
|
||||
#if DEBUG
|
||||
@@ -310,6 +366,8 @@ struct ContentView: View {
|
||||
// empty on a brand-new install.
|
||||
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||||
locationManager.startForegroundTracking()
|
||||
installLocationUpdateHook()
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
@@ -327,7 +385,11 @@ struct ContentView: View {
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
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 {
|
||||
locationManager.stopForegroundTracking()
|
||||
}
|
||||
@@ -336,12 +398,14 @@ struct ContentView: View {
|
||||
if let newLocation {
|
||||
location = newLocation
|
||||
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
// Geofences follow the user's position, but the station list is
|
||||
// NOT re-fetched on every movement (cached, twice-a-day policy).
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
refreshRoadDistancesIfNeeded()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -353,6 +417,7 @@ struct ContentView: View {
|
||||
.onChange(of: selectedFuel) { _, _ in
|
||||
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
.onChange(of: stationLimit) { _, newValue in
|
||||
// Distance filter is LOCAL math now — the cache holds the full-UK
|
||||
@@ -373,9 +438,7 @@ struct ContentView: View {
|
||||
monitor.setEnabled(newValue)
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
if newValue {
|
||||
locationManager.startBackgroundTracking()
|
||||
}
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
}
|
||||
.onChange(of: alertsRadius) { _, newValue in
|
||||
FuelStore.saveAlertsRadius(newValue)
|
||||
@@ -406,6 +469,7 @@ struct ContentView: View {
|
||||
// Toggling the Live Activity on starts it with the current best
|
||||
// station; toggling off ends any running activity.
|
||||
FuelStore.saveLiveActivityEnabled(newValue)
|
||||
ensureBackgroundMotionTrackingIfNeeded()
|
||||
updateLiveActivity()
|
||||
}
|
||||
.onChange(of: liveActivityFuel) { _, newValue in
|
||||
@@ -444,38 +508,91 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
statusBannerView
|
||||
rootTabView
|
||||
/// The one banner to show right now. Network/offline and tip outcomes all
|
||||
/// funnel here and render through the SAME floating chrome. A transient
|
||||
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
|
||||
/// network state for its ~3.5 s.
|
||||
private var activeBanner: AppBanner? {
|
||||
if let outcome = tipStore.outcome { return .tip(outcome) }
|
||||
switch dataStatus {
|
||||
case .live: return nil
|
||||
case .offlineDump(let date): return .offlineDump(date: date)
|
||||
case .connectionProblem: return .connectionProblem
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBannerView: some View {
|
||||
switch dataStatus {
|
||||
case .offlineDump(let date):
|
||||
let title = offlineTitle(date: date)
|
||||
statusBanner(
|
||||
icon: "wifi.slash",
|
||||
tint: .orange,
|
||||
title: title,
|
||||
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||
accessibilityLabel: date.isEmpty
|
||||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||
)
|
||||
case .connectionProblem:
|
||||
statusBanner(
|
||||
icon: "wifi.exclamationmark",
|
||||
tint: .red,
|
||||
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||
)
|
||||
case .live:
|
||||
EmptyView()
|
||||
private var mainContent: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .top) {
|
||||
rootTabView
|
||||
if let banner = currentBanner {
|
||||
// Floating near the top of the screen, over the nav area —
|
||||
// overlays content (never pushes it) and sits above the
|
||||
// main content so it doesn't cover or block the list/pill
|
||||
// beneath it. Opacity/offset are driven explicitly by
|
||||
// reflectBanner so the fade-out reliably animates.
|
||||
floatingBanner(banner)
|
||||
.padding(.top, geo.safeAreaInsets.top + 10)
|
||||
.opacity(bannerOpacity)
|
||||
.offset(y: bannerOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: activeBanner) {
|
||||
// Fires on launch with the current banner AND whenever it changes —
|
||||
// unlike onChange(computed) which can miss the first non-nil value
|
||||
// when dataStatus is set slightly after the view appears.
|
||||
reflectBanner(activeBanner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly animates the banner in/out — a robust replacement for the
|
||||
/// SwiftUI removal transition (which was silently not firing). Handles:
|
||||
/// - first appearance → fade + settle down from slightly above
|
||||
/// - any content change (tip→network, new tip) → fade the old out, then
|
||||
/// fade the new in — so a tip ALWAYS visibly fades away
|
||||
/// - dismissal → fade out + slide up, then clear after the fade
|
||||
private func reflectBanner(_ newBanner: AppBanner?) {
|
||||
guard newBanner != currentBanner else { return }
|
||||
bannerClearTask?.cancel()
|
||||
if let newBanner {
|
||||
if currentBanner == nil {
|
||||
present(newBanner)
|
||||
} else {
|
||||
// Crossfade: slide+fade the current out, then present the new.
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -28
|
||||
}
|
||||
bannerClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
present(newBanner)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeOut(duration: 0.35)) {
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -28
|
||||
}
|
||||
bannerClearTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
currentBanner = nil
|
||||
bannerOpacity = 0
|
||||
bannerOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade a banner in from slightly above and settle it into place.
|
||||
private func present(_ banner: AppBanner) {
|
||||
currentBanner = banner
|
||||
bannerOpacity = 0
|
||||
bannerOffset = -16
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
bannerOpacity = 1
|
||||
bannerOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,13 +623,51 @@ struct ContentView: View {
|
||||
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||
}
|
||||
|
||||
/// Shared status-strip chrome: a tappable card pinned above the tabs.
|
||||
/// Tapping retries the live fetch from ANY screen — no pull gesture
|
||||
/// needed, so the offline banner isn't trapped on the Stations tab.
|
||||
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
|
||||
Button {
|
||||
/// ONE floating banner style for all notifications. Network/offline and
|
||||
/// tip outcomes share the same chrome; only the tap action differs —
|
||||
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
|
||||
@ViewBuilder
|
||||
private func floatingBanner(_ banner: AppBanner) -> some View {
|
||||
switch banner {
|
||||
case .offlineDump(let date):
|
||||
let title = offlineTitle(date: date)
|
||||
statusBannerCard(
|
||||
icon: "wifi.slash", tint: .orange, title: title,
|
||||
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||
trailingIcon: "arrow.clockwise",
|
||||
accessibilityLabel: date.isEmpty
|
||||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||
) {
|
||||
Task { await refresh(force: true) }
|
||||
} label: {
|
||||
}
|
||||
case .connectionProblem:
|
||||
statusBannerCard(
|
||||
icon: "wifi.exclamationmark", tint: .red,
|
||||
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||
trailingIcon: "arrow.clockwise",
|
||||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||
) {
|
||||
Task { await refresh(force: true) }
|
||||
}
|
||||
case .tip(let outcome):
|
||||
statusBannerCard(
|
||||
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
|
||||
trailingIcon: "xmark", accessibilityLabel: outcome.message
|
||||
) {
|
||||
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single floating-banner chrome shared by every notification
|
||||
/// (network/offline + tip). Overlaid — never in flow — so it never moves
|
||||
/// the content beneath it.
|
||||
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
|
||||
trailingIcon: String, accessibilityLabel: String,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
@@ -522,12 +677,14 @@ struct ContentView: View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Image(systemName: trailingIcon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
@@ -540,13 +697,13 @@ struct ContentView: View {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
/// Pushes the current best-in-radius station into the Live Activity.
|
||||
@@ -634,8 +791,10 @@ struct ContentView: View {
|
||||
/// within the compiler's type-check budget.
|
||||
private var settingsTab: some View {
|
||||
SettingsView(
|
||||
tipStore: tipStore,
|
||||
distanceUnit: $distanceUnit,
|
||||
priceDisplayStyle: $priceDisplayStyle,
|
||||
dataRefreshMode: $dataRefreshMode,
|
||||
alertsFuel: alertsFuel,
|
||||
alertsRadiusKM: alertsRadius,
|
||||
testAlertResult: monitor.lastTestResult,
|
||||
@@ -664,6 +823,7 @@ struct ContentView: View {
|
||||
favourites.append(key)
|
||||
}
|
||||
FuelStore.saveFavourites(favourites)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
@@ -675,11 +835,17 @@ struct ContentView: View {
|
||||
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
|
||||
favourites = newOrder
|
||||
FuelStore.saveFavourites(favourites)
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
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
|
||||
/// `force` is true (pull-to-refresh is the manual override).
|
||||
private func refresh(force: Bool = false) async {
|
||||
@@ -698,6 +864,10 @@ struct ContentView: View {
|
||||
stations = fetched
|
||||
FuelStore.saveStations(fetched)
|
||||
FuelStore.saveLastRefresh()
|
||||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||
FuelStore.markWatchRefreshHandled()
|
||||
}
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
// Persist envelope metadata (source, station count, GOV.UK
|
||||
// dataset update time) for the Settings → About section — the
|
||||
// live chain records whichever leg served the fetch.
|
||||
@@ -720,6 +890,9 @@ struct ContentView: View {
|
||||
// Live data restored — any status banner no longer applies.
|
||||
dataStatus = .live
|
||||
} catch {
|
||||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||
FuelStore.markWatchRefreshHandled()
|
||||
}
|
||||
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||||
if FuelStore.loadStations().isEmpty {
|
||||
// No cached prices — last resort is the bundled REAL dump
|
||||
@@ -789,14 +962,14 @@ struct StationRow: View {
|
||||
.padding(5)
|
||||
} else {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(Color.black.opacity(0.82))
|
||||
}
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
.background(Circle().fill(.white))
|
||||
.background(Circle().fill(Color.white))
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1))
|
||||
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
||||
|
||||
// MIDDLE — name, full address, distance
|
||||
@@ -821,7 +994,7 @@ struct StationRow: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
|
||||
@@ -1,13 +1,64 @@
|
||||
import ActivityKit
|
||||
import BackgroundTasks
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
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 {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.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
|
||||
/// two cases:
|
||||
|
||||
@@ -57,7 +57,7 @@ enum LiveActivityManager {
|
||||
brand: best.brand,
|
||||
pricePence: price,
|
||||
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,
|
||||
lng: best.lng,
|
||||
updatedAt: Date()
|
||||
|
||||
@@ -85,7 +85,7 @@ struct OnboardingView: View {
|
||||
private var welcomePage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
AnimatedOnboardingIcon(tint: .accentColor) {
|
||||
// The app icon itself, with the standard iOS rounded-corner
|
||||
// treatment (~22% of the size), so onboarding matches the
|
||||
// Home Screen.
|
||||
@@ -122,8 +122,7 @@ struct OnboardingView: View {
|
||||
private var locationPage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
|
||||
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
@@ -154,8 +153,7 @@ struct OnboardingView: View {
|
||||
private var notificationsPage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
|
||||
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
|
||||
Image(systemName: "bell.badge.fill")
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(.orange)
|
||||
@@ -186,8 +184,7 @@ struct OnboardingView: View {
|
||||
private var dataPage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
|
||||
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(.teal)
|
||||
@@ -248,19 +245,27 @@ struct OnboardingView: View {
|
||||
) {
|
||||
if prompter.locationDenied {
|
||||
openSettings()
|
||||
} else if prompter.locationGranted {
|
||||
page = 2 // already decided — just move on
|
||||
} else {
|
||||
// The prompt fires on LEAVING this page (onChange(of:
|
||||
// page), forward advance only) — the description is on
|
||||
// screen until the user moves on.
|
||||
page = 2
|
||||
// Fire the system prompt NOW, while this page is still on
|
||||
// screen (after it's been read) and BEFORE advancing — so
|
||||
// the prompt never covers the next page's animation. Auto-
|
||||
// advance on grant moves us on once the user responds;
|
||||
// denying leaves the page's "Open Settings" path.
|
||||
prompter.requestLocation()
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
primaryButton(
|
||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
||||
) {
|
||||
// Prompt fires on leaving this page (same rule as Location).
|
||||
page = 3
|
||||
if prompter.notificationsDenied || prompter.notificationsGranted {
|
||||
page = 3 // decided either way — move on
|
||||
} else {
|
||||
// Same rule as Location: prompt now, before advancing.
|
||||
prompter.requestNotifications()
|
||||
}
|
||||
}
|
||||
case 3:
|
||||
// No permission on this page — prices need none. Straight on.
|
||||
@@ -326,6 +331,99 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnimatedOnboardingIcon<Content: View>: View {
|
||||
enum Motion {
|
||||
case gentle
|
||||
case locationArrow
|
||||
case alarmBell
|
||||
case downloadArrow
|
||||
}
|
||||
|
||||
let tint: Color
|
||||
var motion: Motion = .gentle
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@State private var settled = false
|
||||
@State private var iconRotation: Double = 0
|
||||
@State private var iconOffsetY: CGFloat = 0
|
||||
@State private var iconScale: CGFloat = 0.92
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tint.opacity(0.10))
|
||||
.frame(width: 108, height: 108)
|
||||
.scaleEffect(settled ? 1.0 : 0.94)
|
||||
|
||||
Circle()
|
||||
.stroke(tint.opacity(0.18), lineWidth: 1)
|
||||
.frame(width: 118, height: 118)
|
||||
.scaleEffect(settled ? 1.03 : 0.97)
|
||||
.opacity(settled ? 1.0 : 0.55)
|
||||
|
||||
content
|
||||
.scaleEffect(iconScale)
|
||||
.rotationEffect(.degrees(iconRotation))
|
||||
.offset(y: iconOffsetY)
|
||||
}
|
||||
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
|
||||
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
|
||||
.onAppear {
|
||||
playAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private func playAnimation() {
|
||||
settled = false
|
||||
iconRotation = 0
|
||||
iconOffsetY = 4
|
||||
iconScale = 0.92
|
||||
|
||||
switch motion {
|
||||
case .gentle:
|
||||
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
|
||||
settled = true
|
||||
iconOffsetY = 0
|
||||
iconScale = 1.0
|
||||
}
|
||||
|
||||
case .locationArrow:
|
||||
iconRotation = -120
|
||||
withAnimation(.linear(duration: 0.45)) {
|
||||
settled = true
|
||||
iconRotation = 24
|
||||
iconOffsetY = 0
|
||||
iconScale = 1.02
|
||||
}
|
||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
|
||||
iconRotation = 0
|
||||
iconScale = 1.0
|
||||
}
|
||||
|
||||
case .alarmBell:
|
||||
settled = true
|
||||
iconOffsetY = 0
|
||||
iconScale = 1.0
|
||||
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
|
||||
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
|
||||
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
|
||||
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
|
||||
|
||||
case .downloadArrow:
|
||||
iconOffsetY = -22
|
||||
withAnimation(.easeIn(duration: 0.22)) {
|
||||
settled = true
|
||||
iconScale = 1.0
|
||||
iconOffsetY = 8
|
||||
}
|
||||
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
|
||||
iconOffsetY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the system permission requests during onboarding and publishes
|
||||
/// their outcomes so the pages can reflect them live.
|
||||
@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))
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,12 @@ import WidgetKit
|
||||
/// freshness) is debug-only too — moved here so release builds don't expose
|
||||
/// relay plumbing.
|
||||
struct SettingsView: View {
|
||||
/// Shared tip-purchase store — owned by ContentView so its outcome can
|
||||
/// ride the app-wide status banner (above the tabs), not a local card.
|
||||
@ObservedObject var tipStore: TipStore
|
||||
@Binding var distanceUnit: DistanceUnit
|
||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||
@Binding var dataRefreshMode: DataRefreshMode
|
||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||
/// tab) so the test notification matches what real alerts will say.
|
||||
var alertsFuel: FuelType = .e10
|
||||
@@ -53,9 +57,6 @@ struct SettingsView: View {
|
||||
/// Clears the armed fence (monitor.clearDebugFence).
|
||||
var onClearDebugFence: () -> Void = {}
|
||||
|
||||
@StateObject private var tipStore = TipStore()
|
||||
@State private var showTipAlert = false
|
||||
@State private var tipAlertMessage = ""
|
||||
@State private var testAlertMessage: String?
|
||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||
@@ -103,6 +104,7 @@ struct SettingsView: View {
|
||||
.onChange(of: distanceUnit) { _, newValue in
|
||||
FuelStore.saveDistanceUnit(newValue)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
Picker("Price display", selection: $priceDisplayStyle) {
|
||||
ForEach(PriceDisplayStyle.allCases) { style in
|
||||
@@ -113,6 +115,7 @@ struct SettingsView: View {
|
||||
.onChange(of: priceDisplayStyle) { _, newValue in
|
||||
FuelStore.savePriceDisplayStyle(newValue)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
} header: {
|
||||
Text("Units")
|
||||
@@ -120,6 +123,30 @@ 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).")
|
||||
}
|
||||
|
||||
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 {
|
||||
Button {
|
||||
onShowOnboarding()
|
||||
@@ -359,13 +386,6 @@ struct SettingsView: View {
|
||||
Text("About")
|
||||
}
|
||||
|
||||
if let message = tipStore.message {
|
||||
Section {
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.onAppear {
|
||||
@@ -610,9 +630,19 @@ final class TipStore: ObservableObject {
|
||||
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||
]
|
||||
|
||||
/// A banner outcome: the exact copy to show plus its icon + tint.
|
||||
struct TipOutcome: Hashable {
|
||||
let message: String
|
||||
let icon: String
|
||||
let tint: Color
|
||||
}
|
||||
|
||||
@Published private(set) var products: [String: Product] = [:]
|
||||
@Published private(set) var purchaseInProgress = false
|
||||
@Published private(set) var message: String?
|
||||
@Published private(set) var outcome: TipOutcome?
|
||||
|
||||
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
|
||||
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
||||
/// call — Ask to Buy approvals, payments finished on another device
|
||||
@@ -630,6 +660,7 @@ final class TipStore: ObservableObject {
|
||||
|
||||
deinit {
|
||||
updatesTask?.cancel()
|
||||
dismissTask?.cancel()
|
||||
}
|
||||
|
||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||
@@ -638,7 +669,30 @@ final class TipStore: ObservableObject {
|
||||
// Only acknowledge our own consumables (future products get their own).
|
||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||
await transaction.finish()
|
||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||
icon: "checkmark.circle.fill",
|
||||
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
|
||||
/// Re-triggering cancels the previous timer so rapid taps don't collide.
|
||||
private func showOutcome(_ message: String, icon: String, tint: Color) {
|
||||
dismissTask?.cancel()
|
||||
outcome = TipOutcome(message: message, icon: icon, tint: tint)
|
||||
dismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_500_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tap-to-dismiss — clears the banner and cancels the pending auto-dismiss.
|
||||
func dismissOutcome() {
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
|
||||
}
|
||||
|
||||
func displayPrice(for tier: TipTier) -> String {
|
||||
@@ -667,7 +721,11 @@ final class TipStore: ObservableObject {
|
||||
// Connect yet), still allow the attempt so the user sees a clear
|
||||
// outcome rather than a dead button.
|
||||
guard let product = products[tier.id] else {
|
||||
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
|
||||
icon: "info.circle.fill",
|
||||
tint: Color(.secondaryLabel)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -680,19 +738,35 @@ final class TipStore: ObservableObject {
|
||||
// Consume the consumable — otherwise StoreKit re-delivers
|
||||
// it through Transaction.updates on every launch.
|
||||
await transaction.finish()
|
||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||
icon: "checkmark.circle.fill",
|
||||
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||
)
|
||||
case .unverified:
|
||||
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
tint: .orange
|
||||
)
|
||||
}
|
||||
case .userCancelled:
|
||||
message = nil // silent — the user just closed the sheet
|
||||
dismissOutcome() // silent — the user just closed the sheet
|
||||
case .pending:
|
||||
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
|
||||
showOutcome(
|
||||
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
||||
icon: "hourglass",
|
||||
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
||||
)
|
||||
@unknown default:
|
||||
message = nil
|
||||
dismissOutcome()
|
||||
}
|
||||
} catch {
|
||||
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
|
||||
showOutcome(
|
||||
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||
icon: "xmark.circle.fill",
|
||||
tint: .red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ struct CheapestFuelIntent: AppIntent {
|
||||
struct DirectionsToCheapestFuelIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
|
||||
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")
|
||||
@@ -285,7 +285,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
|
||||
struct DirectionsToFavouriteFuelIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
|
||||
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")
|
||||
@@ -404,7 +404,7 @@ struct FuelMessage: View {
|
||||
/// Shortcut slot and adds no phrase.
|
||||
struct OpenDirectionsIntent: AppIntent {
|
||||
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
|
||||
|
||||
@Parameter var stationName: String
|
||||
@@ -489,19 +489,14 @@ struct FuelMessageSnippetIntent: SnippetIntent {
|
||||
|
||||
struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
// Generic parameterized shortcut — matches whatever fuel word Siri
|
||||
// resolves. Parameter resolution is flaky on-device ("cheapest diesel"
|
||||
// matched, "cheapest unleaded" didn't), which is why the fixed-fuel
|
||||
// entries below carry the fuel word as LITERAL phrase text.
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me",
|
||||
],
|
||||
shortTitle: "Cheapest Fuel",
|
||||
systemImageName: "fuelpump"
|
||||
)
|
||||
|
||||
// NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
|
||||
// ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
|
||||
// shortcuts for the same intent (parameter resolution was already flaky
|
||||
// on-device), which degraded Siri's NLU ranking and caused the
|
||||
// hit-and-miss "can't do that, searching in app" fallback. Now Siri
|
||||
// routes on literal fuel words only, and the freed slot keeps us under
|
||||
// the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
|
||||
// metadata-processor requirement) and mirrors how people actually ask.
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(fuel: .e10),
|
||||
phrases: [
|
||||
@@ -509,6 +504,10 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
"Ask \(.applicationName) for the cheapest petrol near me",
|
||||
"Find the cheapest petrol 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",
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,17 +317,6 @@ extension FuelType {
|
||||
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
|
||||
@@ -342,20 +331,26 @@ struct FuelTypeSegmentedPicker: View {
|
||||
var fuels: [FuelType] = FuelType.allCases
|
||||
var onSelect: (FuelType) -> Void = { _ in }
|
||||
|
||||
@Namespace private var selectionSlider
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(fuels) { fuel in
|
||||
let isSelected = fuel == selection
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
|
||||
selection = fuel
|
||||
}
|
||||
onSelect(fuel)
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "fuelpump.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(fuel.tintColor)
|
||||
.scaleEffect(isSelected ? 1.08 : 0.94)
|
||||
Text(fuel.shortName)
|
||||
.font(.subheadline.weight(isSelected ? .semibold : .regular))
|
||||
.contentTransition(.opacity)
|
||||
}
|
||||
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -364,14 +359,29 @@ struct FuelTypeSegmentedPicker: View {
|
||||
if isSelected {
|
||||
Capsule()
|
||||
.fill(Color(UIColor.systemBackground))
|
||||
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
|
||||
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
|
||||
}
|
||||
}
|
||||
.scaleEffect(isSelected ? 1.02 : 0.98)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(Capsule().fill(Color(.secondarySystemBackground)))
|
||||
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Fuel Picker") {
|
||||
@Previewable @State var selectedFuel: FuelType = .e10
|
||||
FuelTypeSegmentedPicker(selection: $selectedFuel)
|
||||
.padding()
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ struct TrendsView: View {
|
||||
var onHistoryRecovered: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@State private var fuel: FuelType = .e10
|
||||
@State private var rangeDays: Int = 30
|
||||
@@ -34,6 +35,13 @@ struct TrendsView: View {
|
||||
@State private var loadFailed = false
|
||||
@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
|
||||
/// opens where the user was — same pattern as FavouritesView.
|
||||
init(favourites: [FavouriteEntry],
|
||||
@@ -157,7 +165,13 @@ struct TrendsView: View {
|
||||
// pointer probe already done above.
|
||||
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
|
||||
revealSeries(fetched)
|
||||
// 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
|
||||
// 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 {
|
||||
switch mode {
|
||||
case .price:
|
||||
@@ -330,6 +370,9 @@ struct TrendsView: View {
|
||||
.font(.system(size: 30, weight: .bold, design: .default))
|
||||
.monospacedDigit()
|
||||
.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 {
|
||||
Label(
|
||||
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
||||
@@ -389,13 +432,13 @@ struct TrendsView: View {
|
||||
// type-checker's budget.)
|
||||
Chart {
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||
areaMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
}
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||
lineMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
@@ -424,6 +467,9 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
.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.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
extension Notification.Name {
|
||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../../Shared/DataRefreshMode.swift
|
||||
@@ -233,11 +233,25 @@ final class BrandTests: XCTestCase {
|
||||
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
|
||||
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
|
||||
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen")
|
||||
XCTAssertEqual(station(brand: "VALERO").brandImageName, "brand_valero")
|
||||
XCTAssertEqual(station(brand: "WELCOME BREAK").brandImageName, "brand_welcome_break")
|
||||
XCTAssertEqual(station(brand: "THE CO-OPERATIVE").brandImageName, "brand_the_co_operative")
|
||||
XCTAssertEqual(station(brand: "CO-OP").brandImageName, "brand_the_co_operative")
|
||||
XCTAssertEqual(station(brand: "MURCO").brandImageName, "brand_murco")
|
||||
XCTAssertEqual(station(brand: "GLEANER").brandImageName, "brand_gleaner")
|
||||
XCTAssertEqual(station(brand: "HIGHLAND FUELS").brandImageName, "brand_highland_fuels")
|
||||
XCTAssertEqual(station(brand: "CIRCLE K").brandImageName, "brand_circle_k")
|
||||
XCTAssertEqual(station(brand: "MAXOL").brandImageName, "brand_maxol")
|
||||
XCTAssertEqual(station(brand: "SPAR").brandImageName, "brand_spar")
|
||||
XCTAssertEqual(station(brand: "ESSAR").brandImageName, "brand_essar")
|
||||
XCTAssertEqual(station(brand: "EG ON THE MOVE").brandImageName, "brand_esso")
|
||||
XCTAssertEqual(station(brand: "GO FORECOURT").brandImageName, "brand_gulf")
|
||||
}
|
||||
|
||||
func testUnknownBrand() {
|
||||
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName)
|
||||
XCTAssertNil(station(brand: "VALERO").brandImageName)
|
||||
func testUnknownBrandUsesGenericFuelPumpFallbackInUI() {
|
||||
XCTAssertNil(station(brand: "TOTAL HARVEST ENERGY").brandImageName)
|
||||
XCTAssertNil(station(brand: "SOLO").brandImageName)
|
||||
XCTAssertNil(station(brand: "PACE").brandImageName)
|
||||
XCTAssertNil(station(brand: "").brandImageName)
|
||||
}
|
||||
}
|
||||
@@ -606,3 +620,82 @@ final class OfflineDataLabelTests: XCTestCase {
|
||||
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 |