Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e004f25a31 |
@@ -144,25 +144,6 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
separate phase. Backlog ancestry: this was the original "relay could keep a
|
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
|
rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
|
||||||
visible half.
|
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
|
- [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
|
||||||
trend, after the Trends UI lands (one-kind rule preserved).
|
trend, after the Trends UI lands (one-kind rule preserved).
|
||||||
- [ ] **Price alerts for a specific station** — notify when a starred station's
|
- [ ] **Price alerts for a specific station** — notify when a starred station's
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.apt.fuelboard</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>FuelBoardWatch</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>WKAppBundleIdentifier</key>
|
||||||
|
<string>com.apt.fuelboard.watchapp</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.watchkit</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>FuelBoardWatch</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
</array>
|
||||||
|
<key>WKCompanionAppBundleIdentifier</key>
|
||||||
|
<string>com.apt.fuelboard</string>
|
||||||
|
<key>WKWatchKitApp</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
// !$*UTF8*$!
|
// !$*UTF8*$!
|
||||||
// FuelBoard — hand-crafted project (objectVersion 77, synchronized folders).
|
|
||||||
// Targets: FuelBoard (app) + FuelBoardWidgets (widget extension, embedded).
|
|
||||||
// Shared/ is a synchronized root group included in BOTH targets.
|
|
||||||
{
|
{
|
||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
@@ -10,10 +7,26 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
ADCE5B643033A32D008C131C /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = ADCE5B4E3033A329008C131C;
|
||||||
|
remoteInfo = "FuelBoardWatch Watch App Extension";
|
||||||
|
};
|
||||||
|
ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = ADCE5B673033A32D008C131C;
|
||||||
|
remoteInfo = "FuelBoardWatch Watch App";
|
||||||
|
};
|
||||||
BB0000000000000000000003 /* PBXContainerItemProxy */ = {
|
BB0000000000000000000003 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = BB0000000000000000000004 /* Project object */;
|
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||||
@@ -24,6 +37,28 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed App Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||||
|
dstSubfolderSpec = 16;
|
||||||
|
files = (
|
||||||
|
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */,
|
||||||
|
);
|
||||||
|
name = "Embed Watch Content";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB0000000000000000000006 /* Embed App Extensions */ = {
|
BB0000000000000000000006 /* Embed App Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -38,11 +73,23 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = "FuelBoardWatch Watch App";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = "FuelBoardWatch Wrapper";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
BB0000000000000000000008 /* FuelBoard */ = {
|
BB0000000000000000000008 /* FuelBoard */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = FuelBoard;
|
path = FuelBoard;
|
||||||
@@ -61,6 +108,20 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
ADCE5B4C3033A329008C131C /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
ADCE5B6D3033A32E008C131C /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB000000000000000000000B /* Frameworks */ = {
|
BB000000000000000000000B /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -84,6 +145,8 @@
|
|||||||
BB0000000000000000000008 /* FuelBoard */,
|
BB0000000000000000000008 /* FuelBoard */,
|
||||||
BB0000000000000000000009 /* FuelBoardWidgets */,
|
BB0000000000000000000009 /* FuelBoardWidgets */,
|
||||||
BB000000000000000000000A /* Shared */,
|
BB000000000000000000000A /* Shared */,
|
||||||
|
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||||
|
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||||
BB000000000000000000000E /* Products */,
|
BB000000000000000000000E /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -93,6 +156,8 @@
|
|||||||
children = (
|
children = (
|
||||||
BB0000000000000000000007 /* FuelBoard.app */,
|
BB0000000000000000000007 /* FuelBoard.app */,
|
||||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
||||||
|
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
|
||||||
|
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -100,30 +165,50 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
BB000000000000000000000F /* FuelBoard */ = {
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
|
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
BB0000000000000000000011 /* Sources */,
|
ADCE5B4B3033A329008C131C /* Sources */,
|
||||||
BB000000000000000000000B /* Frameworks */,
|
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||||
BB0000000000000000000012 /* Resources */,
|
ADCE5B4D3033A329008C131C /* Resources */,
|
||||||
BB0000000000000000000006 /* Embed App Extensions */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
BB0000000000000000000013 /* PBXTargetDependency */,
|
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
BB0000000000000000000008 /* FuelBoard */,
|
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||||
BB000000000000000000000A /* Shared */,
|
|
||||||
);
|
);
|
||||||
name = FuelBoard;
|
name = "FuelBoardWatch Watch App Extension";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = FuelBoard;
|
productName = "FuelBoardWatch Watch App Extension";
|
||||||
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.watchkit2-extension";
|
||||||
|
};
|
||||||
|
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
||||||
|
buildPhases = (
|
||||||
|
ADCE5B6E3033A32E008C131C /* Resources */,
|
||||||
|
ADCE5B653033A32D008C131C /* Embed App Extensions */,
|
||||||
|
ADCE5B6D3033A32E008C131C /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
ADCE5B683033A32D008C131C /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||||
|
);
|
||||||
|
name = "FuelBoardWatch Watch App";
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = "FuelBoardWatch Watch App";
|
||||||
|
productReference = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */;
|
||||||
|
productType = "com.apple.product-type.application.watchapp2";
|
||||||
};
|
};
|
||||||
BB0000000000000000000005 /* FuelBoardWidgets */ = {
|
BB0000000000000000000005 /* FuelBoardWidgets */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
@@ -148,6 +233,33 @@
|
|||||||
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
|
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
|
BB000000000000000000000F /* FuelBoard */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
|
||||||
|
buildPhases = (
|
||||||
|
BB0000000000000000000011 /* Sources */,
|
||||||
|
BB000000000000000000000B /* Frameworks */,
|
||||||
|
BB0000000000000000000012 /* Resources */,
|
||||||
|
ADCE5B5B3033A32C008C131C /* Embed Watch Content */,
|
||||||
|
BB0000000000000000000006 /* Embed App Extensions */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
ADCE5B703033A32F008C131C /* PBXTargetDependency */,
|
||||||
|
BB0000000000000000000013 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
BB0000000000000000000008 /* FuelBoard */,
|
||||||
|
BB000000000000000000000A /* Shared */,
|
||||||
|
);
|
||||||
|
name = FuelBoard;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = FuelBoard;
|
||||||
|
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -155,15 +267,18 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 1600;
|
LastSwiftUpdateCheck = 2630;
|
||||||
LastUpgradeCheck = 1600;
|
LastUpgradeCheck = 1600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
BB000000000000000000000F = {
|
ADCE5B4E3033A329008C131C = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 26.3;
|
||||||
};
|
};
|
||||||
BB0000000000000000000005 = {
|
BB0000000000000000000005 = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
};
|
};
|
||||||
|
BB000000000000000000000F = {
|
||||||
|
CreatedOnToolsVersion = 16.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
|
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
|
||||||
@@ -182,11 +297,27 @@
|
|||||||
targets = (
|
targets = (
|
||||||
BB000000000000000000000F /* FuelBoard */,
|
BB000000000000000000000F /* FuelBoard */,
|
||||||
BB0000000000000000000005 /* FuelBoardWidgets */,
|
BB0000000000000000000005 /* FuelBoardWidgets */,
|
||||||
|
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
||||||
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
ADCE5B4D3033A329008C131C /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
ADCE5B6E3033A32E008C131C /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB0000000000000000000012 /* Resources */ = {
|
BB0000000000000000000012 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -204,6 +335,13 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
ADCE5B4B3033A329008C131C /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB0000000000000000000011 /* Sources */ = {
|
BB0000000000000000000011 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -221,6 +359,16 @@
|
|||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
ADCE5B683033A32D008C131C /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */;
|
||||||
|
targetProxy = ADCE5B643033A32D008C131C /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
ADCE5B703033A32F008C131C /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */;
|
||||||
|
targetProxy = ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
BB0000000000000000000013 /* PBXTargetDependency */ = {
|
BB0000000000000000000013 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = BB0000000000000000000005 /* FuelBoardWidgets */;
|
target = BB0000000000000000000005 /* FuelBoardWidgets */;
|
||||||
@@ -229,6 +377,262 @@
|
|||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
ADCE5B573033A32B008C131C /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
|
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
ADCE5B583033A32B008C131C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
|
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
ADCE5B6B3033A32D008C131C /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
ADCE5B6C3033A32D008C131C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0.1;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
BB0000000000000000000018 /* Debug */ = {
|
BB0000000000000000000018 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -293,7 +697,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/App-Info.plist;
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -315,7 +719,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/App-Info.plist;
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -337,7 +741,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -361,7 +765,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -380,11 +784,20 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
BB0000000000000000000018 /* Debug */,
|
ADCE5B573033A32B008C131C /* Debug */,
|
||||||
BB0000000000000000000019 /* Release */,
|
ADCE5B583033A32B008C131C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
ADCE5B6B3033A32D008C131C /* Debug */,
|
||||||
|
ADCE5B6C3033A32D008C131C /* Release */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
@@ -407,6 +820,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BB0000000000000000000018 /* Debug */,
|
||||||
|
BB0000000000000000000019 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = BB0000000000000000000004 /* Project object */;
|
rootObject = BB0000000000000000000004 /* Project object */;
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"filename": "brand_essar_2x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "brand_essar_3x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"filename": "brand_gleaner_2x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "brand_gleaner_3x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 636 B |
|
Before Width: | Height: | Size: 970 B |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"filename": "brand_maxol_2x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "brand_maxol_3x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"filename": "brand_murco_2x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "brand_murco_3x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"filename": "brand_spar_2x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "brand_spar_3x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"filename": "brand_valero_2x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "brand_valero_3x.png",
|
|
||||||
"idiom": "universal",
|
|
||||||
"scale": "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -2,15 +2,6 @@ import SwiftUI
|
|||||||
import CoreLocation
|
import CoreLocation
|
||||||
import WidgetKit
|
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: Equatable {
|
|
||||||
case offlineDump(date: String)
|
|
||||||
case connectionProblem
|
|
||||||
case tip(TipStore.TipOutcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -41,24 +32,6 @@ struct ContentView: View {
|
|||||||
private var effectiveAlertsRadiusKM: Double {
|
private var effectiveAlertsRadiusKM: Double {
|
||||||
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensureBackgroundMotionTrackingIfNeeded() {
|
|
||||||
if alertsEnabled || liveActivityEnabled {
|
|
||||||
locationManager.startBackgroundTracking()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
/// What data is on screen, driving which (if any) status banner shows
|
/// What data is on screen, driving which (if any) status banner shows
|
||||||
@@ -79,10 +52,6 @@ struct ContentView: View {
|
|||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@State private var locationManager = LocationManager()
|
@State private var locationManager = LocationManager()
|
||||||
@StateObject private var monitor = ProximityMonitor()
|
@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()
|
|
||||||
|
|
||||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
/// 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
|
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||||
@@ -297,12 +266,20 @@ struct ContentView: View {
|
|||||||
if FuelStore.loadHasCompletedOnboarding()
|
if FuelStore.loadHasCompletedOnboarding()
|
||||||
|| shouldSkipOnboarding {
|
|| shouldSkipOnboarding {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
// Geofences and the Live Activity must follow the user even in
|
// Geofences must follow the user even in the background:
|
||||||
// the background: wire the delegate hook (fires on every fix
|
// wire the delegate hook (fires on every fix incl. background
|
||||||
// incl. background significant-change wake-ups) so SwiftUI's
|
// significant-change wake-ups) to re-register the region
|
||||||
// foreground-only onChange isn't the only update path.
|
// window around the new position. SwiftUI onChange alone
|
||||||
installLocationUpdateHook()
|
// never runs while suspended, so alerts would otherwise stay
|
||||||
ensureBackgroundMotionTrackingIfNeeded()
|
// 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()
|
||||||
|
}
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
@@ -333,8 +310,6 @@ struct ContentView: View {
|
|||||||
// empty on a brand-new install.
|
// empty on a brand-new install.
|
||||||
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
installLocationUpdateHook()
|
|
||||||
ensureBackgroundMotionTrackingIfNeeded()
|
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
@@ -352,7 +327,10 @@ struct ContentView: View {
|
|||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
// No network fetch on foreground — pull-to-refresh is the override.
|
if FuelStore.hasPendingWatchRefreshRequest {
|
||||||
|
Task { await handlePendingWatchRefreshRequest() }
|
||||||
|
}
|
||||||
|
// No network fetch on foreground unless the watch explicitly requests one.
|
||||||
} else {
|
} else {
|
||||||
locationManager.stopForegroundTracking()
|
locationManager.stopForegroundTracking()
|
||||||
}
|
}
|
||||||
@@ -398,7 +376,9 @@ struct ContentView: View {
|
|||||||
monitor.setEnabled(newValue)
|
monitor.setEnabled(newValue)
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
ensureBackgroundMotionTrackingIfNeeded()
|
if newValue {
|
||||||
|
locationManager.startBackgroundTracking()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: alertsRadius) { _, newValue in
|
.onChange(of: alertsRadius) { _, newValue in
|
||||||
FuelStore.saveAlertsRadius(newValue)
|
FuelStore.saveAlertsRadius(newValue)
|
||||||
@@ -429,7 +409,6 @@ struct ContentView: View {
|
|||||||
// Toggling the Live Activity on starts it with the current best
|
// Toggling the Live Activity on starts it with the current best
|
||||||
// station; toggling off ends any running activity.
|
// station; toggling off ends any running activity.
|
||||||
FuelStore.saveLiveActivityEnabled(newValue)
|
FuelStore.saveLiveActivityEnabled(newValue)
|
||||||
ensureBackgroundMotionTrackingIfNeeded()
|
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
}
|
}
|
||||||
.onChange(of: liveActivityFuel) { _, newValue in
|
.onChange(of: liveActivityFuel) { _, newValue in
|
||||||
@@ -468,40 +447,40 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one banner to show right now. Network/offline and tip outcomes all
|
private var mainContent: some View {
|
||||||
/// funnel here and render through the SAME floating chrome. A transient
|
VStack(spacing: 0) {
|
||||||
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
|
statusBannerView
|
||||||
/// network state for its ~3.5 s.
|
rootTabView
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainContent: some View {
|
@ViewBuilder
|
||||||
GeometryReader { geo in
|
private var statusBannerView: some View {
|
||||||
ZStack(alignment: .top) {
|
switch dataStatus {
|
||||||
rootTabView
|
case .offlineDump(let date):
|
||||||
if let banner = activeBanner {
|
let title = offlineTitle(date: date)
|
||||||
// Floating near the top of the screen, over the nav area —
|
statusBanner(
|
||||||
// overlays content (never pushes it) and sits above the
|
icon: "wifi.slash",
|
||||||
// main content so it doesn't cover or block the list/pill
|
tint: .orange,
|
||||||
// beneath it.
|
title: title,
|
||||||
floatingBanner(banner)
|
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||||
.padding(.top, geo.safeAreaInsets.top + 10)
|
accessibilityLabel: date.isEmpty
|
||||||
.transition(.asymmetric(
|
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||||
insertion: .move(edge: .top).combined(with: .opacity),
|
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||||
// Slide up + fade on dismiss/timeout (one style for all).
|
)
|
||||||
removal: .move(edge: .top).combined(with: .opacity)
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: activeBanner)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var rootTabView: some View {
|
private var rootTabView: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -530,51 +509,13 @@ struct ContentView: View {
|
|||||||
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ONE floating banner style for all notifications. Network/offline and
|
/// Shared status-strip chrome: a tappable card pinned above the tabs.
|
||||||
/// tip outcomes share the same chrome; only the tap action differs —
|
/// Tapping retries the live fetch from ANY screen — no pull gesture
|
||||||
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
|
/// needed, so the offline banner isn't trapped on the Stations tab.
|
||||||
@ViewBuilder
|
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
|
||||||
private func floatingBanner(_ banner: AppBanner) -> some View {
|
Button {
|
||||||
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) }
|
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) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
@@ -584,14 +525,12 @@ struct ContentView: View {
|
|||||||
Text(title)
|
Text(title)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
if let subtitle {
|
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: trailingIcon)
|
Image(systemName: "arrow.clockwise")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
}
|
}
|
||||||
@@ -604,13 +543,13 @@ struct ContentView: View {
|
|||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.stroke(tint.opacity(0.35), lineWidth: 1)
|
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||||
)
|
)
|
||||||
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
|
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 6)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.accessibilityLabel(accessibilityLabel)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the current best-in-radius station into the Live Activity.
|
/// Pushes the current best-in-radius station into the Live Activity.
|
||||||
@@ -698,7 +637,6 @@ struct ContentView: View {
|
|||||||
/// within the compiler's type-check budget.
|
/// within the compiler's type-check budget.
|
||||||
private var settingsTab: some View {
|
private var settingsTab: some View {
|
||||||
SettingsView(
|
SettingsView(
|
||||||
tipStore: tipStore,
|
|
||||||
distanceUnit: $distanceUnit,
|
distanceUnit: $distanceUnit,
|
||||||
priceDisplayStyle: $priceDisplayStyle,
|
priceDisplayStyle: $priceDisplayStyle,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
@@ -745,6 +683,11 @@ struct ContentView: View {
|
|||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handlePendingWatchRefreshRequest() async {
|
||||||
|
guard FuelStore.hasPendingWatchRefreshRequest else { return }
|
||||||
|
await refresh(force: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches fresh prices, but only when the cache is stale — unless
|
/// Fetches fresh prices, but only when the cache is stale — unless
|
||||||
/// `force` is true (pull-to-refresh is the manual override).
|
/// `force` is true (pull-to-refresh is the manual override).
|
||||||
private func refresh(force: Bool = false) async {
|
private func refresh(force: Bool = false) async {
|
||||||
@@ -763,6 +706,9 @@ struct ContentView: View {
|
|||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
FuelStore.saveLastRefresh()
|
||||||
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||||
|
FuelStore.markWatchRefreshHandled()
|
||||||
|
}
|
||||||
// Persist envelope metadata (source, station count, GOV.UK
|
// Persist envelope metadata (source, station count, GOV.UK
|
||||||
// dataset update time) for the Settings → About section — the
|
// dataset update time) for the Settings → About section — the
|
||||||
// live chain records whichever leg served the fetch.
|
// live chain records whichever leg served the fetch.
|
||||||
@@ -785,6 +731,9 @@ struct ContentView: View {
|
|||||||
// Live data restored — any status banner no longer applies.
|
// Live data restored — any status banner no longer applies.
|
||||||
dataStatus = .live
|
dataStatus = .live
|
||||||
} catch {
|
} catch {
|
||||||
|
if force, FuelStore.hasPendingWatchRefreshRequest {
|
||||||
|
FuelStore.markWatchRefreshHandled()
|
||||||
|
}
|
||||||
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||||||
if FuelStore.loadStations().isEmpty {
|
if FuelStore.loadStations().isEmpty {
|
||||||
// No cached prices — last resort is the bundled REAL dump
|
// No cached prices — last resort is the bundled REAL dump
|
||||||
@@ -854,14 +803,14 @@ struct StationRow: View {
|
|||||||
.padding(5)
|
.padding(5)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22))
|
||||||
.foregroundStyle(Color.black.opacity(0.82))
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 46, height: 46)
|
.frame(width: 46, height: 46)
|
||||||
.background(Circle().fill(Color.white))
|
.background(Circle().fill(.white))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
|
.overlay(Circle().stroke(Color.primary.opacity(0.08), lineWidth: 1))
|
||||||
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
|
||||||
|
|
||||||
// MIDDLE — name, full address, distance
|
// MIDDLE — name, full address, distance
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ struct OnboardingView: View {
|
|||||||
private var welcomePage: some View {
|
private var welcomePage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
AnimatedOnboardingIcon(tint: .accentColor) {
|
ZStack {
|
||||||
// The app icon itself, with the standard iOS rounded-corner
|
// The app icon itself, with the standard iOS rounded-corner
|
||||||
// treatment (~22% of the size), so onboarding matches the
|
// treatment (~22% of the size), so onboarding matches the
|
||||||
// Home Screen.
|
// Home Screen.
|
||||||
@@ -122,7 +122,8 @@ struct OnboardingView: View {
|
|||||||
private var locationPage: some View {
|
private var locationPage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
|
ZStack {
|
||||||
|
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
|
||||||
Image(systemName: "location.fill")
|
Image(systemName: "location.fill")
|
||||||
.font(.system(size: 40, weight: .semibold))
|
.font(.system(size: 40, weight: .semibold))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
@@ -153,7 +154,8 @@ struct OnboardingView: View {
|
|||||||
private var notificationsPage: some View {
|
private var notificationsPage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
|
ZStack {
|
||||||
|
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
|
||||||
Image(systemName: "bell.badge.fill")
|
Image(systemName: "bell.badge.fill")
|
||||||
.font(.system(size: 40, weight: .semibold))
|
.font(.system(size: 40, weight: .semibold))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
@@ -184,7 +186,8 @@ struct OnboardingView: View {
|
|||||||
private var dataPage: some View {
|
private var dataPage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
|
ZStack {
|
||||||
|
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
|
||||||
Image(systemName: "arrow.down.circle.fill")
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.font(.system(size: 40, weight: .semibold))
|
.font(.system(size: 40, weight: .semibold))
|
||||||
.foregroundStyle(.teal)
|
.foregroundStyle(.teal)
|
||||||
@@ -245,27 +248,19 @@ struct OnboardingView: View {
|
|||||||
) {
|
) {
|
||||||
if prompter.locationDenied {
|
if prompter.locationDenied {
|
||||||
openSettings()
|
openSettings()
|
||||||
} else if prompter.locationGranted {
|
|
||||||
page = 2 // already decided — just move on
|
|
||||||
} else {
|
} else {
|
||||||
// Fire the system prompt NOW, while this page is still on
|
// The prompt fires on LEAVING this page (onChange(of:
|
||||||
// screen (after it's been read) and BEFORE advancing — so
|
// page), forward advance only) — the description is on
|
||||||
// the prompt never covers the next page's animation. Auto-
|
// screen until the user moves on.
|
||||||
// advance on grant moves us on once the user responds;
|
page = 2
|
||||||
// denying leaves the page's "Open Settings" path.
|
|
||||||
prompter.requestLocation()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
primaryButton(
|
primaryButton(
|
||||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
||||||
) {
|
) {
|
||||||
if prompter.notificationsDenied || prompter.notificationsGranted {
|
// Prompt fires on leaving this page (same rule as Location).
|
||||||
page = 3 // decided either way — move on
|
page = 3
|
||||||
} else {
|
|
||||||
// Same rule as Location: prompt now, before advancing.
|
|
||||||
prompter.requestNotifications()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case 3:
|
case 3:
|
||||||
// No permission on this page — prices need none. Straight on.
|
// No permission on this page — prices need none. Straight on.
|
||||||
@@ -331,99 +326,6 @@ 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
|
/// Owns the system permission requests during onboarding and publishes
|
||||||
/// their outcomes so the pages can reflect them live.
|
/// their outcomes so the pages can reflect them live.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ import WidgetKit
|
|||||||
/// freshness) is debug-only too — moved here so release builds don't expose
|
/// freshness) is debug-only too — moved here so release builds don't expose
|
||||||
/// relay plumbing.
|
/// relay plumbing.
|
||||||
struct SettingsView: View {
|
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 distanceUnit: DistanceUnit
|
||||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
@@ -56,6 +53,9 @@ struct SettingsView: View {
|
|||||||
/// Clears the armed fence (monitor.clearDebugFence).
|
/// Clears the armed fence (monitor.clearDebugFence).
|
||||||
var onClearDebugFence: () -> Void = {}
|
var onClearDebugFence: () -> Void = {}
|
||||||
|
|
||||||
|
@StateObject private var tipStore = TipStore()
|
||||||
|
@State private var showTipAlert = false
|
||||||
|
@State private var tipAlertMessage = ""
|
||||||
@State private var testAlertMessage: String?
|
@State private var testAlertMessage: String?
|
||||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||||
@@ -359,6 +359,13 @@ struct SettingsView: View {
|
|||||||
Text("About")
|
Text("About")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let message = tipStore.message {
|
||||||
|
Section {
|
||||||
|
Text(message)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -603,19 +610,9 @@ final class TipStore: ObservableObject {
|
|||||||
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
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: Equatable {
|
|
||||||
let message: String
|
|
||||||
let icon: String
|
|
||||||
let tint: Color
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published private(set) var products: [String: Product] = [:]
|
@Published private(set) var products: [String: Product] = [:]
|
||||||
@Published private(set) var purchaseInProgress = false
|
@Published private(set) var purchaseInProgress = false
|
||||||
@Published private(set) var outcome: TipOutcome?
|
@Published private(set) var message: String?
|
||||||
|
|
||||||
/// 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()
|
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
||||||
/// call — Ask to Buy approvals, payments finished on another device
|
/// call — Ask to Buy approvals, payments finished on another device
|
||||||
@@ -633,7 +630,6 @@ final class TipStore: ObservableObject {
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
dismissTask?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||||
@@ -642,30 +638,7 @@ final class TipStore: ObservableObject {
|
|||||||
// Only acknowledge our own consumables (future products get their own).
|
// Only acknowledge our own consumables (future products get their own).
|
||||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
showOutcome(
|
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
||||||
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 {
|
func displayPrice(for tier: TipTier) -> String {
|
||||||
@@ -694,11 +667,7 @@ final class TipStore: ObservableObject {
|
|||||||
// Connect yet), still allow the attempt so the user sees a clear
|
// Connect yet), still allow the attempt so the user sees a clear
|
||||||
// outcome rather than a dead button.
|
// outcome rather than a dead button.
|
||||||
guard let product = products[tier.id] else {
|
guard let product = products[tier.id] else {
|
||||||
showOutcome(
|
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,35 +680,19 @@ final class TipStore: ObservableObject {
|
|||||||
// Consume the consumable — otherwise StoreKit re-delivers
|
// Consume the consumable — otherwise StoreKit re-delivers
|
||||||
// it through Transaction.updates on every launch.
|
// it through Transaction.updates on every launch.
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
showOutcome(
|
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
||||||
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:
|
case .unverified:
|
||||||
showOutcome(
|
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
|
||||||
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
|
||||||
icon: "exclamationmark.triangle.fill",
|
|
||||||
tint: .orange
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
dismissOutcome() // silent — the user just closed the sheet
|
message = nil // silent — the user just closed the sheet
|
||||||
case .pending:
|
case .pending:
|
||||||
showOutcome(
|
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
|
||||||
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:
|
@unknown default:
|
||||||
dismissOutcome()
|
message = nil
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showOutcome(
|
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
|
||||||
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
|
||||||
icon: "xmark.circle.fill",
|
|
||||||
tint: .red
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,26 +342,20 @@ struct FuelTypeSegmentedPicker: View {
|
|||||||
var fuels: [FuelType] = FuelType.allCases
|
var fuels: [FuelType] = FuelType.allCases
|
||||||
var onSelect: (FuelType) -> Void = { _ in }
|
var onSelect: (FuelType) -> Void = { _ in }
|
||||||
|
|
||||||
@Namespace private var selectionSlider
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
ForEach(fuels) { fuel in
|
ForEach(fuels) { fuel in
|
||||||
let isSelected = fuel == selection
|
let isSelected = fuel == selection
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
|
|
||||||
selection = fuel
|
selection = fuel
|
||||||
}
|
|
||||||
onSelect(fuel)
|
onSelect(fuel)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(fuel.tintColor)
|
.foregroundStyle(fuel.tintColor)
|
||||||
.scaleEffect(isSelected ? 1.08 : 0.94)
|
|
||||||
Text(fuel.shortName)
|
Text(fuel.shortName)
|
||||||
.font(.subheadline.weight(isSelected ? .semibold : .regular))
|
.font(.subheadline.weight(isSelected ? .semibold : .regular))
|
||||||
.contentTransition(.opacity)
|
|
||||||
}
|
}
|
||||||
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
|
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -370,29 +364,14 @@ struct FuelTypeSegmentedPicker: View {
|
|||||||
if isSelected {
|
if isSelected {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color(UIColor.systemBackground))
|
.fill(Color(UIColor.systemBackground))
|
||||||
.overlay {
|
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5)
|
||||||
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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(3)
|
.padding(3)
|
||||||
.background(Capsule().fill(Color(.secondarySystemBackground)))
|
.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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -233,25 +233,11 @@ final class BrandTests: XCTestCase {
|
|||||||
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
|
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
|
||||||
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
|
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
|
||||||
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen")
|
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 testUnknownBrandUsesGenericFuelPumpFallbackInUI() {
|
func testUnknownBrand() {
|
||||||
XCTAssertNil(station(brand: "TOTAL HARVEST ENERGY").brandImageName)
|
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName)
|
||||||
XCTAssertNil(station(brand: "SOLO").brandImageName)
|
XCTAssertNil(station(brand: "VALERO").brandImageName)
|
||||||
XCTAssertNil(station(brand: "PACE").brandImageName)
|
|
||||||
XCTAssertNil(station(brand: "").brandImageName)
|
XCTAssertNil(station(brand: "").brandImageName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
private enum WatchFuelType: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case e10
|
||||||
|
case e5
|
||||||
|
case diesel
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .e10: return "Unleaded"
|
||||||
|
case .e5: return "Premium"
|
||||||
|
case .diesel: return "Diesel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortName: String {
|
||||||
|
switch self {
|
||||||
|
case .e10: return "E10"
|
||||||
|
case .e5: return "E5"
|
||||||
|
case .diesel: return "B7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .e10: return .green
|
||||||
|
case .e5: return .yellow
|
||||||
|
case .diesel: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .e10: return "fuelpump.fill"
|
||||||
|
case .e5: return "fuelpump.circle.fill"
|
||||||
|
case .diesel: return "drop.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortRank: Int {
|
||||||
|
switch self {
|
||||||
|
case .e10: return 0
|
||||||
|
case .e5: return 1
|
||||||
|
case .diesel: return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchDistanceUnit: String, Codable {
|
||||||
|
case miles
|
||||||
|
case kilometers
|
||||||
|
|
||||||
|
func format(km: Double) -> String {
|
||||||
|
switch self {
|
||||||
|
case .miles:
|
||||||
|
return String(format: "%.1f mi", km * 0.621371)
|
||||||
|
case .kilometers:
|
||||||
|
return String(format: "%.1f km", km)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchPriceStyle: String, Codable {
|
||||||
|
case stationSign
|
||||||
|
case poundsPence
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchCoordinate {
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchStation: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let brand: String
|
||||||
|
let address: String
|
||||||
|
let postcode: String
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
let prices: [WatchFuelType: Double]
|
||||||
|
let priceUpdated: TimeInterval?
|
||||||
|
|
||||||
|
func distanceKM(to location: WatchCoordinate) -> Double {
|
||||||
|
let r = 6371.0
|
||||||
|
let dLat = (location.lat - lat) * .pi / 180
|
||||||
|
let dLng = (location.lng - lng) * .pi / 180
|
||||||
|
let a = sin(dLat / 2) * sin(dLat / 2)
|
||||||
|
+ cos(lat * .pi / 180) * cos(location.lat * .pi / 180)
|
||||||
|
* sin(dLng / 2) * sin(dLng / 2)
|
||||||
|
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchFavouriteEntry: Codable, Identifiable {
|
||||||
|
let station: WatchStation
|
||||||
|
let fuel: WatchFuelType
|
||||||
|
|
||||||
|
var id: String { "\(fuel.rawValue)|\(station.id)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchFavouriteRow: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let priceText: String
|
||||||
|
let distanceText: String?
|
||||||
|
let isCheapest: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchRefreshState {
|
||||||
|
case idle(lastUpdated: String)
|
||||||
|
case stale(lastUpdated: String)
|
||||||
|
case checking
|
||||||
|
case requested(lastUpdated: String)
|
||||||
|
case needsPhone
|
||||||
|
case noData
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .idle(let lastUpdated): return "Updated \(lastUpdated)"
|
||||||
|
case .stale(let lastUpdated): return "Cache from \(lastUpdated)"
|
||||||
|
case .checking: return "Checking…"
|
||||||
|
case .requested(let lastUpdated): return "Requested · \(lastUpdated)"
|
||||||
|
case .needsPhone: return "Refresh on iPhone"
|
||||||
|
case .noData: return "No cached prices yet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .idle: return .secondary
|
||||||
|
case .stale: return .yellow
|
||||||
|
case .checking: return .yellow
|
||||||
|
case .requested: return .green
|
||||||
|
case .needsPhone: return .orange
|
||||||
|
case .noData: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .idle: return "clock"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .checking: return "arrow.triangle.2.circlepath"
|
||||||
|
case .requested: return "checkmark.circle"
|
||||||
|
case .needsPhone: return "iphone"
|
||||||
|
case .noData: return "tray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WatchFuelCache {
|
||||||
|
static let appGroupSuite = "group.com.apt.fuelboard"
|
||||||
|
static let favouritesKey = "fuelboard.favourites"
|
||||||
|
static let stationsKey = "fuelboard.stations"
|
||||||
|
static let fuelKey = "fuelboard.selectedFuel"
|
||||||
|
static let distanceUnitKey = "fuelboard.distanceUnit"
|
||||||
|
static let priceStyleKey = "fuelboard.priceDisplayStyle"
|
||||||
|
static let locationKey = "fuelboard.lastLocation"
|
||||||
|
static let lastRefreshKey = "fuelboard.lastRefresh"
|
||||||
|
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest"
|
||||||
|
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled"
|
||||||
|
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
||||||
|
|
||||||
|
static func defaults() -> UserDefaults? {
|
||||||
|
UserDefaults(suiteName: appGroupSuite)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadSelectedFuel() -> WatchFuelType {
|
||||||
|
guard let raw = defaults()?.string(forKey: fuelKey), let fuel = WatchFuelType(rawValue: raw) else {
|
||||||
|
return .e10
|
||||||
|
}
|
||||||
|
return fuel
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadDistanceUnit() -> WatchDistanceUnit {
|
||||||
|
guard let raw = defaults()?.string(forKey: distanceUnitKey), let unit = WatchDistanceUnit(rawValue: raw) else {
|
||||||
|
return .miles
|
||||||
|
}
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadPriceStyle() -> WatchPriceStyle {
|
||||||
|
guard let raw = defaults()?.string(forKey: priceStyleKey), let style = WatchPriceStyle(rawValue: raw) else {
|
||||||
|
return .stationSign
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadLocation() -> WatchCoordinate? {
|
||||||
|
guard let raw = defaults()?.string(forKey: locationKey) else { return nil }
|
||||||
|
let parts = raw.split(separator: ",").compactMap { Double($0) }
|
||||||
|
guard parts.count >= 2 else { return nil }
|
||||||
|
return WatchCoordinate(lat: parts[0], lng: parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadLastRefresh() -> Date? {
|
||||||
|
guard let raw = defaults()?.string(forKey: lastRefreshKey), let ts = TimeInterval(raw) else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isCacheFresh(lastRefresh: Date?) -> Bool {
|
||||||
|
guard let lastRefresh else { return false }
|
||||||
|
return Date().timeIntervalSince(lastRefresh) < refreshInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestRefresh(_ date: Date = Date()) {
|
||||||
|
defaults()?.set(String(date.timeIntervalSince1970), forKey: watchRefreshRequestKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRefreshRequest() -> Date? {
|
||||||
|
guard let raw = defaults()?.string(forKey: watchRefreshRequestKey), let ts = TimeInterval(raw) else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRefreshHandled() -> Date? {
|
||||||
|
guard let raw = defaults()?.string(forKey: watchRefreshHandledKey), let ts = TimeInterval(raw) else { return nil }
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func wasRefreshHandled(since requestDate: Date) -> Bool {
|
||||||
|
guard let handled = loadRefreshHandled() else { return false }
|
||||||
|
return handled >= requestDate
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadStations() -> [WatchStation] {
|
||||||
|
guard let data = defaults()?.data(forKey: stationsKey),
|
||||||
|
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return stations
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadFavourites() -> [WatchFavouriteEntry] {
|
||||||
|
guard let data = defaults()?.data(forKey: favouritesKey),
|
||||||
|
let favourites = try? JSONDecoder().decode([WatchFavouriteEntry].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return favourites
|
||||||
|
}
|
||||||
|
|
||||||
|
static func refreshedFavourites(_ favourites: [WatchFavouriteEntry], from stations: [WatchStation]) -> [WatchFavouriteEntry] {
|
||||||
|
favourites.map { favourite in
|
||||||
|
guard let fresh = stations.first(where: { $0.id == favourite.station.id }) else { return favourite }
|
||||||
|
return WatchFavouriteEntry(station: fresh, fuel: favourite.fuel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func cheapestFavourite(in favourites: [WatchFavouriteEntry], fuel: WatchFuelType) -> WatchFavouriteEntry? {
|
||||||
|
var best: WatchFavouriteEntry?
|
||||||
|
for favourite in favourites where favourite.fuel == fuel {
|
||||||
|
guard let price = favourite.station.prices[fuel] else { continue }
|
||||||
|
guard let current = best, let currentPrice = current.station.prices[fuel] else {
|
||||||
|
best = favourite
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if price < currentPrice {
|
||||||
|
best = favourite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
static func priceText(_ pence: Double, style: WatchPriceStyle) -> String {
|
||||||
|
switch style {
|
||||||
|
case .stationSign:
|
||||||
|
return String(format: "%.1f", pence)
|
||||||
|
case .poundsPence:
|
||||||
|
let tenths = Int((pence * 10).rounded())
|
||||||
|
let whole = tenths / 1000
|
||||||
|
let major = (tenths % 1000) / 10
|
||||||
|
let minor = tenths % 10
|
||||||
|
let superscripts: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
|
return String(format: "£%d.%02d", whole, major) + String(superscripts[minor]) + "/L"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel()
|
||||||
|
@State private var refreshState: WatchRefreshState = .noData
|
||||||
|
@State private var favourites: [WatchFavouriteEntry] = []
|
||||||
|
@State private var stations: [WatchStation] = []
|
||||||
|
@State private var lastLocation: WatchCoordinate?
|
||||||
|
@State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit()
|
||||||
|
@State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $fuel) {
|
||||||
|
ForEach(WatchFuelType.allCases) { fuelCase in
|
||||||
|
fuelPage(for: fuelCase)
|
||||||
|
.tag(fuelCase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.verticalPage)
|
||||||
|
.onAppear(perform: reloadFromCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func fuelPage(for fuelCase: WatchFuelType) -> some View {
|
||||||
|
let fuelFavourites = favouritesForFuel(fuelCase)
|
||||||
|
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
|
||||||
|
let rows = rows(for: fuelCase)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
header(for: fuelCase)
|
||||||
|
|
||||||
|
if let cheapest {
|
||||||
|
cheapestCard(for: fuelCase, favourite: cheapest)
|
||||||
|
} else {
|
||||||
|
emptyCheapestCard(for: fuelCase)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusRow
|
||||||
|
|
||||||
|
Button {
|
||||||
|
runCheckNow()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: refreshState.icon)
|
||||||
|
Text("Check now")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(fuelCase.color)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Favourites")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if rows.isEmpty {
|
||||||
|
Text("No favourites for \(fuelCase.displayName.lowercased()) yet.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(rows) { row in
|
||||||
|
favouriteRow(row, fuel: fuelCase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func favouritesForFuel(_ fuelCase: WatchFuelType) -> [WatchFavouriteEntry] {
|
||||||
|
favourites.filter { $0.fuel == fuelCase }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rows(for fuelCase: WatchFuelType) -> [WatchFavouriteRow] {
|
||||||
|
let fuelFavourites = favouritesForFuel(fuelCase)
|
||||||
|
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
|
||||||
|
|
||||||
|
return fuelFavourites.map { favourite in
|
||||||
|
let distanceText = lastLocation.map { distanceUnit.format(km: favourite.station.distanceKM(to: $0)) }
|
||||||
|
let priceText = favourite.station.prices[fuelCase].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—"
|
||||||
|
return WatchFavouriteRow(
|
||||||
|
id: favourite.id,
|
||||||
|
name: favourite.station.name,
|
||||||
|
priceText: priceText,
|
||||||
|
distanceText: distanceText,
|
||||||
|
isCheapest: favourite.id == cheapest?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(for fuel: WatchFuelType) -> some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Image(systemName: fuel.symbol)
|
||||||
|
.foregroundStyle(fuel.color)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("FuelBoard")
|
||||||
|
.font(.headline)
|
||||||
|
Text(fuel.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(fuel.shortName)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(fuel.color.opacity(0.18), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Cheapest favourite")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(favourite.station.name)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(favourite.station.prices[fuel].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
Spacer()
|
||||||
|
if let location = lastLocation {
|
||||||
|
Text(distanceUnit.format(km: favourite.station.distanceKM(to: location)))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emptyCheapestCard(for fuel: WatchFuelType) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Cheapest favourite")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No favourite saved")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Add favourites on iPhone to see them here.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusRow: some View {
|
||||||
|
Label(refreshState.label, systemImage: refreshState.icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(refreshState.tint)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(favourite.name)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(favourite.isCheapest ? .semibold : .regular)
|
||||||
|
.lineLimit(1)
|
||||||
|
if favourite.isCheapest {
|
||||||
|
Text("TOP")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(fuel.color.opacity(0.18), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let distanceText = favourite.distanceText {
|
||||||
|
Text(distanceText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(favourite.priceText)
|
||||||
|
.font(.callout.monospacedDigit())
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadFromCache() {
|
||||||
|
stations = WatchFuelCache.loadStations()
|
||||||
|
favourites = WatchFuelCache.refreshedFavourites(WatchFuelCache.loadFavourites(), from: stations)
|
||||||
|
distanceUnit = WatchFuelCache.loadDistanceUnit()
|
||||||
|
priceStyle = WatchFuelCache.loadPriceStyle()
|
||||||
|
lastLocation = WatchFuelCache.loadLocation()
|
||||||
|
refreshState = statusFromCache()
|
||||||
|
|
||||||
|
let availableFuels = Set(favourites.map(\.fuel))
|
||||||
|
if !availableFuels.isEmpty, !availableFuels.contains(fuel) {
|
||||||
|
fuel = availableFuels.sorted { $0.sortRank < $1.sortRank }.first ?? .e10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusFromCache() -> WatchRefreshState {
|
||||||
|
guard !stations.isEmpty else { return .noData }
|
||||||
|
guard let lastRefresh = WatchFuelCache.loadLastRefresh() else { return .stale(lastUpdated: "earlier") }
|
||||||
|
let formatted = relativeRefreshText(for: lastRefresh)
|
||||||
|
return WatchFuelCache.isCacheFresh(lastRefresh: lastRefresh)
|
||||||
|
? .idle(lastUpdated: formatted)
|
||||||
|
: .stale(lastUpdated: formatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runCheckNow() {
|
||||||
|
refreshState = .checking
|
||||||
|
let requestDate = Date()
|
||||||
|
WatchFuelCache.requestRefresh(requestDate)
|
||||||
|
Task {
|
||||||
|
for _ in 0..<12 {
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
if WatchFuelCache.wasRefreshHandled(since: requestDate) {
|
||||||
|
await MainActor.run {
|
||||||
|
reloadFromCache()
|
||||||
|
let label = WatchFuelCache.loadLastRefresh().map(relativeRefreshText(for:)) ?? "just now"
|
||||||
|
refreshState = .requested(lastUpdated: label)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
reloadFromCache()
|
||||||
|
refreshState = .needsPhone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relativeRefreshText(for date: Date) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .short
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct FuelBoardWatchApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,7 +123,7 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
size: 15, weight: .bold)
|
size: 15, weight: .bold)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
Text(context.state.stationName)
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 9))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|||||||
@@ -216,18 +216,10 @@ enum FuelHistoryStore {
|
|||||||
// MARK: Network
|
// MARK: Network
|
||||||
|
|
||||||
/// The mirror pointer — used for the empty-state hint ("first snapshot
|
/// The mirror pointer — used for the empty-state hint ("first snapshot
|
||||||
/// landed …"). Non-fatal: nil just means no hint. Deliberately bypasses
|
/// landed …"). Non-fatal: nil just means no hint.
|
||||||
/// the HTTP cache (reloadIgnoringLocalCacheData) so an OFFLINE fetch
|
static func fetchLatest(base: URL = mirrorBase,
|
||||||
/// really fails instead of silently re-serving a cached pointer as a
|
|
||||||
/// success — otherwise "no network" would look like fresh data and the
|
|
||||||
/// offline banner would never fire. A short timeout surfaces the failure
|
|
||||||
/// (and the banner) quickly on a connected-but-dead network.
|
|
||||||
static func fetchLatest(base: URL = mirrorBase, timeout: TimeInterval = 10,
|
|
||||||
session: URLSession = .shared) async -> MirrorLatest? {
|
session: URLSession = .shared) async -> MirrorLatest? {
|
||||||
var req = URLRequest(url: latestFileURL(base: base))
|
guard let (data, response) = try? await session.data(from: latestFileURL(base: base)),
|
||||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
|
||||||
req.timeoutInterval = timeout
|
|
||||||
guard let (data, response) = try? await session.data(for: req),
|
|
||||||
(response as? HTTPURLResponse)?.statusCode == 200,
|
(response as? HTTPURLResponse)?.statusCode == 200,
|
||||||
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
|
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -242,37 +242,21 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
var brandImageName: String? {
|
var brandImageName: String? {
|
||||||
let raw = brand.uppercased()
|
let raw = brand.uppercased()
|
||||||
let known: [(String, String)] = [
|
let known: [(String, String)] = [
|
||||||
("CIRCLE K", "brand_circle_k"),
|
|
||||||
("MAXOL", "brand_maxol"),
|
|
||||||
("SPAR", "brand_spar"),
|
|
||||||
("SHELL", "brand_shell"),
|
("SHELL", "brand_shell"),
|
||||||
("SAINSBURY", "brand_sainsburys"),
|
("SAINSBURY", "brand_sainsburys"),
|
||||||
("MORRISONS", "brand_morrisons"),
|
("MORRISONS", "brand_morrisons"),
|
||||||
("APPLEGREEN", "brand_applegreen"),
|
("APPLEGREEN", "brand_applegreen"),
|
||||||
("TESCO", "brand_tesco"),
|
("TESCO", "brand_tesco"),
|
||||||
("TEXACO", "brand_texaco"),
|
("TEXACO", "brand_texaco"),
|
||||||
("ESSAR", "brand_essar"),
|
|
||||||
("ESSO", "brand_esso"),
|
("ESSO", "brand_esso"),
|
||||||
("ASDA", "brand_asda"),
|
("ASDA", "brand_asda"),
|
||||||
("GULF", "brand_gulf"),
|
("GULF", "brand_gulf"),
|
||||||
("VALERO", "brand_valero"),
|
|
||||||
("WELCOME BREAK", "brand_welcome_break"),
|
|
||||||
("THE CO-OPERATIVE", "brand_the_co_operative"),
|
|
||||||
("CO-OPERATIVE", "brand_the_co_operative"),
|
|
||||||
("CO OPERATIVE", "brand_the_co_operative"),
|
|
||||||
("CO-OP", "brand_the_co_operative"),
|
|
||||||
("CO OP", "brand_the_co_operative"),
|
|
||||||
("MURCO", "brand_murco"),
|
|
||||||
("GLEANER", "brand_gleaner"),
|
|
||||||
("HIGHLAND FUELS", "brand_highland_fuels"),
|
|
||||||
("BP", "brand_bp"),
|
("BP", "brand_bp"),
|
||||||
("JET", "brand_jet"),
|
("JET", "brand_jet"),
|
||||||
]
|
]
|
||||||
for (needle, asset) in known where raw.contains(needle) {
|
for (needle, asset) in known where raw.contains(needle) {
|
||||||
return asset
|
return asset
|
||||||
}
|
}
|
||||||
if raw.contains("EG ON THE MOVE") { return "brand_esso" }
|
|
||||||
if raw == "GO" || raw.contains("GO FORECOURT") || raw.contains("GO FUEL") { return "brand_gulf" }
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,6 +339,8 @@ struct FuelStore {
|
|||||||
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
||||||
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
||||||
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
||||||
|
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970)
|
||||||
|
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
|
||||||
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
||||||
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
||||||
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
|
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
|
||||||
@@ -862,6 +848,34 @@ struct FuelStore {
|
|||||||
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func requestWatchRefresh(_ date: Date = Date()) {
|
||||||
|
saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadWatchRefreshRequest() -> Date? {
|
||||||
|
if let raw = loadString(service: watchRefreshRequestKey), let ts = TimeInterval(raw) {
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func markWatchRefreshHandled(_ date: Date = Date()) {
|
||||||
|
saveString(String(date.timeIntervalSince1970), service: watchRefreshHandledKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadWatchRefreshHandled() -> Date? {
|
||||||
|
if let raw = loadString(service: watchRefreshHandledKey), let ts = TimeInterval(raw) {
|
||||||
|
return Date(timeIntervalSince1970: ts)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static var hasPendingWatchRefreshRequest: Bool {
|
||||||
|
guard let requested = loadWatchRefreshRequest() else { return false }
|
||||||
|
guard let handled = loadWatchRefreshHandled() else { return true }
|
||||||
|
return requested > handled
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Relay metadata — shown in Settings → About. Written after each
|
// MARK: Relay metadata — shown in Settings → About. Written after each
|
||||||
// successful full fetch so the About section reflects the live source.
|
// successful full fetch so the About section reflects the live source.
|
||||||
|
|
||||||
|
|||||||
@@ -96,13 +96,9 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
|
||||||
data = cached
|
data = cached
|
||||||
} else {
|
} else {
|
||||||
// Bypass the HTTP cache + short timeout: an offline refresh must
|
let (fetched, response) = try await URLSession.shared.data(
|
||||||
// FAIL (→ offline banner), never re-serve a stale cached dump as
|
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
|
||||||
// a "successful" live fetch.
|
)
|
||||||
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
|
|
||||||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
|
||||||
req.timeoutInterval = 10
|
|
||||||
let (fetched, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
throw FuelProviderError.mirrorUnavailable
|
throw FuelProviderError.mirrorUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PROJECT="$PWD/FuelBoard.xcodeproj"
|
||||||
|
SCHEME="FuelBoard"
|
||||||
|
DERIVED_DATA="/tmp/fuelboard-build-ipas"
|
||||||
|
SERVE_DIR="/tmp/ipa-serve"
|
||||||
|
FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa"
|
||||||
|
SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa"
|
||||||
|
HOST="192.168.1.131"
|
||||||
|
PORT="8765"
|
||||||
|
|
||||||
|
APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
||||||
|
STAGE_FULL="/tmp/fuelboard-ipa-stage-full"
|
||||||
|
STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload"
|
||||||
|
|
||||||
|
echo "==> Refresh bundled dump"
|
||||||
|
"$PWD/scripts/refresh_bundled_dump.sh"
|
||||||
|
|
||||||
|
echo "==> Clean build dirs"
|
||||||
|
rm -rf "$DERIVED_DATA" "$STAGE_FULL" "$STAGE_SIDELOAD"
|
||||||
|
mkdir -p "$SERVE_DIR"
|
||||||
|
|
||||||
|
echo "==> Build Release app (generic iOS destination)"
|
||||||
|
xcodebuild \
|
||||||
|
-project "$PROJECT" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-derivedDataPath "$DERIVED_DATA" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY='' \
|
||||||
|
build >/tmp/fuelboard-build-ipas-xcodebuild.log
|
||||||
|
|
||||||
|
if [ ! -d "$APP_PATH" ]; then
|
||||||
|
echo "missing built app: $APP_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Package full watch-capable IPA"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, shutil, zipfile
|
||||||
|
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
|
stage = '/tmp/fuelboard-ipa-stage-full'
|
||||||
|
payload = os.path.join(stage, 'Payload')
|
||||||
|
out = '/tmp/ipa-serve/FuelBoard-watch.ipa'
|
||||||
|
if os.path.exists(stage):
|
||||||
|
shutil.rmtree(stage)
|
||||||
|
os.makedirs(payload, exist_ok=True)
|
||||||
|
shutil.copytree(app, os.path.join(payload, 'FuelBoard.app'))
|
||||||
|
tmp = out + '.tmp'
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
||||||
|
for root, dirs, files in os.walk(stage):
|
||||||
|
dirs.sort(); files.sort()
|
||||||
|
for name in dirs:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
zf.write(path, os.path.relpath(path, stage))
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
zf.write(path, os.path.relpath(path, stage))
|
||||||
|
os.replace(tmp, out)
|
||||||
|
print(out)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "==> Package sideload-safe IPA (strip Watch/)"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, shutil, zipfile
|
||||||
|
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
|
stage = '/tmp/fuelboard-ipa-stage-sideload'
|
||||||
|
payload = os.path.join(stage, 'Payload')
|
||||||
|
out = '/tmp/ipa-serve/FuelBoard-sideload.ipa'
|
||||||
|
if os.path.exists(stage):
|
||||||
|
shutil.rmtree(stage)
|
||||||
|
os.makedirs(payload, exist_ok=True)
|
||||||
|
dst = os.path.join(payload, 'FuelBoard.app')
|
||||||
|
shutil.copytree(app, dst)
|
||||||
|
watch_dir = os.path.join(dst, 'Watch')
|
||||||
|
if os.path.exists(watch_dir):
|
||||||
|
shutil.rmtree(watch_dir)
|
||||||
|
tmp = out + '.tmp'
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
||||||
|
for root, dirs, files in os.walk(stage):
|
||||||
|
dirs.sort(); files.sort()
|
||||||
|
for name in dirs:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
zf.write(path, os.path.relpath(path, stage))
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
zf.write(path, os.path.relpath(path, stage))
|
||||||
|
os.replace(tmp, out)
|
||||||
|
print(out)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "==> Verify artifacts"
|
||||||
|
ls -lh "$FULL_IPA" "$SIDELOAD_IPA"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, plistlib, json
|
||||||
|
base = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
|
paths = {
|
||||||
|
'host': os.path.join(base, 'Info.plist'),
|
||||||
|
'watch': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'Info.plist'),
|
||||||
|
'watch_ext': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'PlugIns', 'FuelBoardWatch Watch App Extension.appex', 'Info.plist'),
|
||||||
|
}
|
||||||
|
for name, path in paths.items():
|
||||||
|
print(f'## {name} {os.path.exists(path)}')
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
pl = plistlib.load(f)
|
||||||
|
print(json.dumps({k: pl.get(k) for k in ['CFBundleIdentifier','CFBundleShortVersionString','CFBundleVersion']}, indent=2))
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "==> URLs"
|
||||||
|
echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa"
|
||||||
|
echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa"
|
||||||
|
echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"
|
||||||