Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9102217b9 | ||
|
|
457816ec61 |
+13
-6
@@ -113,12 +113,19 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
action in the Alerts tab that forces a fetch and immediately re-evaluates the
|
action in the Alerts tab that forces a fetch and immediately re-evaluates the
|
||||||
cheapest-favourite alert. Copy should set expectation honestly: standard
|
cheapest-favourite alert. Copy should set expectation honestly: standard
|
||||||
mode checks when FuelBoard refreshes prices (up to twice daily).
|
mode checks when FuelBoard refreshes prices (up to twice daily).
|
||||||
- [ ] **Price-drop alerts: optional frequent-check mode** — if the app ever
|
- [ ] **Data checking mode: Standard vs Smart** — keep the current cache-first
|
||||||
relaxes the current "twice daily max" data policy, expose this as a clearly
|
/ 12 h policy as `Standard`, and add an optional `Smart` mode in Settings.
|
||||||
separate mode rather than a freeform frequency slider. Example framing:
|
`Smart` should try lightweight background checks of the mirror pointer
|
||||||
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
|
(`latest.json` / `data_updated`) more often, and only download the full price
|
||||||
with conservative caps (e.g. every 4–6 h, not hourly) and copy that does not
|
dataset when a newer snapshot is available. Design goal: better freshness
|
||||||
promise exact timing under iOS background scheduling.
|
without pretending to do real-time refreshes or re-downloading the full dump
|
||||||
|
unnecessarily. User-facing copy must be explicit and honest:
|
||||||
|
- `Standard` — Saves battery. Refreshes price data up to twice daily.
|
||||||
|
- `Smart` — Checks for newer data more often in the background and refreshes
|
||||||
|
full prices only when an update is available.
|
||||||
|
Add a footnote/subcopy that background checks are best-effort and happen only
|
||||||
|
when iOS allows, so timing is not exact. Prefer this 2-mode setting over a
|
||||||
|
freeform frequency slider.
|
||||||
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
|
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
|
||||||
(currently `refreshable` fires but no visible progress in the row list).
|
(currently `refreshable` fires but no visible progress in the row list).
|
||||||
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
|
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
|
||||||
|
|||||||
@@ -41,8 +41,13 @@
|
|||||||
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.apt.fuelboard.smart-refresh</string>
|
||||||
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
<string>location</string>
|
<string>location</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?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,4 +1,7 @@
|
|||||||
// !$*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 = {
|
||||||
@@ -7,26 +10,10 @@
|
|||||||
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 */;
|
||||||
@@ -37,28 +24,6 @@
|
|||||||
/* 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;
|
||||||
@@ -73,23 +38,11 @@
|
|||||||
/* 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;
|
||||||
@@ -108,20 +61,6 @@
|
|||||||
/* 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;
|
||||||
@@ -145,8 +84,6 @@
|
|||||||
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>";
|
||||||
@@ -156,8 +93,6 @@
|
|||||||
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>";
|
||||||
@@ -165,50 +100,30 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
BB000000000000000000000F /* FuelBoard */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
ADCE5B4B3033A329008C131C /* Sources */,
|
BB0000000000000000000011 /* Sources */,
|
||||||
ADCE5B4C3033A329008C131C /* Frameworks */,
|
BB000000000000000000000B /* Frameworks */,
|
||||||
ADCE5B4D3033A329008C131C /* Resources */,
|
BB0000000000000000000012 /* Resources */,
|
||||||
|
BB0000000000000000000006 /* Embed App Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
BB0000000000000000000013 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
BB0000000000000000000008 /* FuelBoard */,
|
||||||
|
BB000000000000000000000A /* Shared */,
|
||||||
);
|
);
|
||||||
name = "FuelBoardWatch Watch App Extension";
|
name = FuelBoard;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = "FuelBoardWatch Watch App Extension";
|
productName = FuelBoard;
|
||||||
productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */;
|
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
||||||
productType = "com.apple.product-type.watchkit2-extension";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
|
||||||
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;
|
||||||
@@ -233,33 +148,6 @@
|
|||||||
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 */
|
||||||
@@ -267,16 +155,13 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2630;
|
LastSwiftUpdateCheck = 1600;
|
||||||
LastUpgradeCheck = 1600;
|
LastUpgradeCheck = 1600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
ADCE5B4E3033A329008C131C = {
|
BB000000000000000000000F = {
|
||||||
CreatedOnToolsVersion = 26.3;
|
|
||||||
};
|
|
||||||
BB0000000000000000000005 = {
|
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
};
|
};
|
||||||
BB000000000000000000000F = {
|
BB0000000000000000000005 = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -297,27 +182,11 @@
|
|||||||
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;
|
||||||
@@ -335,13 +204,6 @@
|
|||||||
/* 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;
|
||||||
@@ -359,16 +221,6 @@
|
|||||||
/* 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 */;
|
||||||
@@ -377,262 +229,6 @@
|
|||||||
/* 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 = {
|
||||||
@@ -698,7 +294,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = Config/App-Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -721,7 +317,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = Config/App-Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -744,7 +340,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -769,7 +365,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -788,20 +384,11 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
ADCE5B573033A32B008C131C /* Debug */,
|
BB0000000000000000000018 /* Debug */,
|
||||||
ADCE5B583033A32B008C131C /* Release */,
|
BB0000000000000000000019 /* 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;
|
||||||
@@ -824,15 +411,6 @@
|
|||||||
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 */;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@State private var stations: [FuelStation] = FuelStore.loadStations()
|
@State private var stations: [FuelStation] = FuelStore.loadStations()
|
||||||
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||||
|
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
|
||||||
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
@State private var sortMode: SortMode = FuelStore.loadSortMode()
|
||||||
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
@State private var stationLimit: Int = FuelStore.loadStationLimit()
|
||||||
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
|
||||||
@@ -365,6 +366,7 @@ struct ContentView: View {
|
|||||||
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
installLocationUpdateHook()
|
installLocationUpdateHook()
|
||||||
|
SmartDataRefreshScheduler.scheduleNextIfNeeded()
|
||||||
ensureBackgroundMotionTrackingIfNeeded()
|
ensureBackgroundMotionTrackingIfNeeded()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
@@ -380,14 +382,12 @@ struct ContentView: View {
|
|||||||
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
|
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
}
|
}
|
||||||
|
SmartDataRefreshScheduler.scheduleNextIfNeeded()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
refreshRoadDistancesIfNeeded()
|
refreshRoadDistancesIfNeeded()
|
||||||
if FuelStore.hasPendingWatchRefreshRequest {
|
// No network fetch on foreground — pull-to-refresh is the override.
|
||||||
Task { await handlePendingWatchRefreshRequest() }
|
|
||||||
}
|
|
||||||
// No network fetch on foreground unless the watch explicitly requests one.
|
|
||||||
} else {
|
} else {
|
||||||
locationManager.stopForegroundTracking()
|
locationManager.stopForegroundTracking()
|
||||||
}
|
}
|
||||||
@@ -790,6 +790,7 @@ struct ContentView: View {
|
|||||||
tipStore: tipStore,
|
tipStore: tipStore,
|
||||||
distanceUnit: $distanceUnit,
|
distanceUnit: $distanceUnit,
|
||||||
priceDisplayStyle: $priceDisplayStyle,
|
priceDisplayStyle: $priceDisplayStyle,
|
||||||
|
dataRefreshMode: $dataRefreshMode,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
alertsRadiusKM: alertsRadius,
|
alertsRadiusKM: alertsRadius,
|
||||||
testAlertResult: monitor.lastTestResult,
|
testAlertResult: monitor.lastTestResult,
|
||||||
@@ -834,11 +835,6 @@ 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 {
|
||||||
@@ -857,9 +853,7 @@ struct ContentView: View {
|
|||||||
stations = fetched
|
stations = fetched
|
||||||
FuelStore.saveStations(fetched)
|
FuelStore.saveStations(fetched)
|
||||||
FuelStore.saveLastRefresh()
|
FuelStore.saveLastRefresh()
|
||||||
if force, FuelStore.hasPendingWatchRefreshRequest {
|
FuelStore.saveLastSmartProbe()
|
||||||
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.
|
||||||
@@ -882,9 +876,6 @@ 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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import ActivityKit
|
import ActivityKit
|
||||||
|
import BackgroundTasks
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -22,6 +23,9 @@ struct FuelBoardApp: App {
|
|||||||
ContentView()
|
ContentView()
|
||||||
.onOpenURL(perform: handleOpenURL)
|
.onOpenURL(perform: handleOpenURL)
|
||||||
}
|
}
|
||||||
|
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
|
||||||
|
await SmartDataRefreshCoordinator.runBackgroundProbe()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ enum LiveActivityManager {
|
|||||||
currentStartDate = nil
|
currentStartDate = nil
|
||||||
start(attributes: attributes, state: state)
|
start(attributes: attributes, state: state)
|
||||||
} else {
|
} else {
|
||||||
Task { await current.update(using: state) }
|
Task {
|
||||||
|
await current.update(.init(state: state, staleDate: nil))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
start(attributes: attributes, state: state)
|
start(attributes: attributes, state: state)
|
||||||
|
|||||||
@@ -73,8 +73,12 @@ enum RoadDistanceService {
|
|||||||
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||||
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||||
let request = MKDirections.Request()
|
let request = MKDirections.Request()
|
||||||
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
request.source = MKMapItem(location: CLLocation(latitude: from.latitude,
|
||||||
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
longitude: from.longitude),
|
||||||
|
address: nil)
|
||||||
|
request.destination = MKMapItem(location: CLLocation(latitude: to.latitude,
|
||||||
|
longitude: to.longitude),
|
||||||
|
address: nil)
|
||||||
request.transportType = .automobile
|
request.transportType = .automobile
|
||||||
request.requestsAlternateRoutes = false
|
request.requestsAlternateRoutes = false
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct SettingsView: View {
|
|||||||
@ObservedObject var tipStore: TipStore
|
@ObservedObject var tipStore: TipStore
|
||||||
@Binding var distanceUnit: DistanceUnit
|
@Binding var distanceUnit: DistanceUnit
|
||||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||||
|
@Binding var dataRefreshMode: DataRefreshMode
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
/// tab) so the test notification matches what real alerts will say.
|
/// tab) so the test notification matches what real alerts will say.
|
||||||
var alertsFuel: FuelType = .e10
|
var alertsFuel: FuelType = .e10
|
||||||
@@ -120,6 +121,29 @@ struct SettingsView: View {
|
|||||||
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Data checking", selection: $dataRefreshMode) {
|
||||||
|
ForEach(DataRefreshMode.allCases) { mode in
|
||||||
|
Text(mode.displayName).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: dataRefreshMode) { _, newValue in
|
||||||
|
FuelStore.saveDataRefreshMode(newValue)
|
||||||
|
SmartDataRefreshScheduler.scheduleNextIfNeeded()
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(dataRefreshMode.summary)
|
||||||
|
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
} header: {
|
||||||
|
Text("Data checking")
|
||||||
|
} footer: {
|
||||||
|
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
onShowOnboarding()
|
onShowOnboarding()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Foundation
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum SmartDataRefreshScheduler {
|
||||||
|
static let taskIdentifier = "com.apt.fuelboard.smart-refresh"
|
||||||
|
|
||||||
|
static func register() {
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
|
||||||
|
guard let task = task as? BGAppRefreshTask else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handle(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func scheduleNextIfNeeded() {
|
||||||
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier)
|
||||||
|
guard FuelStore.loadDataRefreshMode() == .smart else { return }
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval)
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("SMART-REFRESH schedule failed: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handle(_ task: BGAppRefreshTask) {
|
||||||
|
scheduleNextIfNeeded()
|
||||||
|
let refreshTask = Task {
|
||||||
|
let success = await SmartDataRefreshCoordinator.runBackgroundProbe()
|
||||||
|
task.setTaskCompleted(success: success)
|
||||||
|
}
|
||||||
|
task.expirationHandler = {
|
||||||
|
refreshTask.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SmartDataRefreshCoordinator {
|
||||||
|
@discardableResult
|
||||||
|
static func runBackgroundProbe(now: Date = Date()) async -> Bool {
|
||||||
|
guard FuelStore.loadDataRefreshMode() == .smart else { return true }
|
||||||
|
guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
FuelStore.saveLastSmartProbe(now)
|
||||||
|
guard !Task.isCancelled else { return false }
|
||||||
|
|
||||||
|
guard let latest = await FuelHistoryStore.fetchLatest() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let latestDay = latest.availableTo ?? latest.date
|
||||||
|
let latestUpdated = latest.dataUpdated
|
||||||
|
let cachedDay = MirrorFuelProvider.loadDumpCache()?.day
|
||||||
|
let savedUpdated = FuelStore.loadDataUpdated()
|
||||||
|
let shouldFetchFullDump = FuelStore.loadStations().isEmpty
|
||||||
|
|| !FuelStore.isCacheFresh(now: now)
|
||||||
|
|| (latestDay != nil && latestDay != cachedDay)
|
||||||
|
|| (latestUpdated != nil && latestUpdated != savedUpdated)
|
||||||
|
|
||||||
|
guard shouldFetchFullDump, !Task.isCancelled else { return true }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fetched = try await FuelPriceProvider.active.fetchStations(
|
||||||
|
near: nil,
|
||||||
|
lng: nil,
|
||||||
|
fuel: FuelStore.loadSelectedFuel(),
|
||||||
|
radiusKM: nil
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled else { return false }
|
||||||
|
FuelStore.saveStations(fetched)
|
||||||
|
FuelStore.saveLastRefresh(now)
|
||||||
|
if let meta = LiveChainProvider.latestMeta {
|
||||||
|
FuelStore.saveRelayMeta(meta)
|
||||||
|
}
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DataRefreshMode: String, CaseIterable, Identifiable {
|
||||||
|
case standard
|
||||||
|
case smart
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .standard: return "Standard"
|
||||||
|
case .smart: return "Smart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
switch self {
|
||||||
|
case .standard:
|
||||||
|
return "Saves battery. Refreshes price data up to twice daily."
|
||||||
|
case .smart:
|
||||||
|
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "watchos",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct FuelBoardWatchApp: App {
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
ContentView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "watchos",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DataRefreshMode: String, CaseIterable, Identifiable {
|
||||||
|
case standard
|
||||||
|
case smart
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .standard: return "Standard"
|
||||||
|
case .smart: return "Smart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
switch self {
|
||||||
|
case .standard:
|
||||||
|
return "Saves battery. Refreshes price data up to twice daily."
|
||||||
|
case .smart:
|
||||||
|
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-26
@@ -370,8 +370,8 @@ struct FuelStore {
|
|||||||
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
||||||
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
|
||||||
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
|
||||||
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970)
|
static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
|
||||||
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
|
static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // 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
|
||||||
@@ -863,10 +863,24 @@ struct FuelStore {
|
|||||||
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
|
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Refresh policy — data is cached; the app only auto-refreshes
|
// MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps
|
||||||
// twice a day (pull-to-refresh is the manual override).
|
// the same full-dump freshness cap but may probe the tiny mirror pointer
|
||||||
|
// more often in the background and only refresh the full dump if it changed.
|
||||||
|
|
||||||
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
static let refreshInterval: TimeInterval = 12 * 60 * 60
|
||||||
|
static let smartProbeInterval: TimeInterval = 4 * 60 * 60
|
||||||
|
|
||||||
|
static func loadDataRefreshMode() -> DataRefreshMode {
|
||||||
|
if let raw = loadString(service: dataRefreshModeKey),
|
||||||
|
let mode = DataRefreshMode(rawValue: raw) {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
return .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDataRefreshMode(_ mode: DataRefreshMode) {
|
||||||
|
saveString(mode.rawValue, service: dataRefreshModeKey)
|
||||||
|
}
|
||||||
|
|
||||||
static func loadLastRefresh() -> Date? {
|
static func loadLastRefresh() -> Date? {
|
||||||
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
|
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
|
||||||
@@ -879,32 +893,15 @@ struct FuelStore {
|
|||||||
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func requestWatchRefresh(_ date: Date = Date()) {
|
static func loadLastSmartProbe() -> Date? {
|
||||||
saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey)
|
if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) {
|
||||||
}
|
|
||||||
|
|
||||||
static func loadWatchRefreshRequest() -> Date? {
|
|
||||||
if let raw = loadString(service: watchRefreshRequestKey), let ts = TimeInterval(raw) {
|
|
||||||
return Date(timeIntervalSince1970: ts)
|
return Date(timeIntervalSince1970: ts)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func markWatchRefreshHandled(_ date: Date = Date()) {
|
static func saveLastSmartProbe(_ date: Date = Date()) {
|
||||||
saveString(String(date.timeIntervalSince1970), service: watchRefreshHandledKey)
|
saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
@@ -959,8 +956,17 @@ struct FuelStore {
|
|||||||
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
||||||
/// should be skipped (twice-a-day policy).
|
/// should be skipped (twice-a-day policy).
|
||||||
static var isCacheFresh: Bool {
|
static var isCacheFresh: Bool {
|
||||||
|
isCacheFresh(now: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isCacheFresh(now: Date) -> Bool {
|
||||||
guard let last = loadLastRefresh() else { return false }
|
guard let last = loadLastRefresh() else { return false }
|
||||||
return Date().timeIntervalSince(last) < refreshInterval
|
return now.timeIntervalSince(last) < refreshInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isSmartProbeDue(now: Date = Date()) -> Bool {
|
||||||
|
guard let last = loadLastSmartProbe() else { return true }
|
||||||
|
return now.timeIntervalSince(last) >= smartProbeInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Onboarding — the app shows the intro screen on first launch only
|
// MARK: Onboarding — the app shows the intro screen on first launch only
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
PROJECT="$PWD/FuelBoard.xcodeproj"
|
|
||||||
SCHEME="FuelBoard"
|
|
||||||
DERIVED_DATA="/tmp/fuelboard-build-ipas"
|
|
||||||
SERVE_DIR="/tmp/ipa-serve"
|
|
||||||
FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa"
|
|
||||||
SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa"
|
|
||||||
HOST="192.168.1.131"
|
|
||||||
PORT="8765"
|
|
||||||
|
|
||||||
APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
|
||||||
STAGE_FULL="/tmp/fuelboard-ipa-stage-full"
|
|
||||||
STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload"
|
|
||||||
|
|
||||||
echo "==> Refresh bundled dump"
|
|
||||||
"$PWD/scripts/refresh_bundled_dump.sh"
|
|
||||||
|
|
||||||
echo "==> Clean build dirs"
|
|
||||||
rm -rf "$DERIVED_DATA" "$STAGE_FULL" "$STAGE_SIDELOAD"
|
|
||||||
mkdir -p "$SERVE_DIR"
|
|
||||||
|
|
||||||
echo "==> Build Release app (generic iOS destination)"
|
|
||||||
xcodebuild \
|
|
||||||
-project "$PROJECT" \
|
|
||||||
-scheme "$SCHEME" \
|
|
||||||
-configuration Release \
|
|
||||||
-destination 'generic/platform=iOS' \
|
|
||||||
-derivedDataPath "$DERIVED_DATA" \
|
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
|
||||||
CODE_SIGN_IDENTITY='' \
|
|
||||||
build >/tmp/fuelboard-build-ipas-xcodebuild.log
|
|
||||||
|
|
||||||
if [ ! -d "$APP_PATH" ]; then
|
|
||||||
echo "missing built app: $APP_PATH" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Package full watch-capable IPA"
|
|
||||||
python3 - <<'PY'
|
|
||||||
import os, shutil, zipfile
|
|
||||||
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
|
||||||
stage = '/tmp/fuelboard-ipa-stage-full'
|
|
||||||
payload = os.path.join(stage, 'Payload')
|
|
||||||
out = '/tmp/ipa-serve/FuelBoard-watch.ipa'
|
|
||||||
if os.path.exists(stage):
|
|
||||||
shutil.rmtree(stage)
|
|
||||||
os.makedirs(payload, exist_ok=True)
|
|
||||||
shutil.copytree(app, os.path.join(payload, 'FuelBoard.app'))
|
|
||||||
tmp = out + '.tmp'
|
|
||||||
if os.path.exists(tmp):
|
|
||||||
os.remove(tmp)
|
|
||||||
with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
|
||||||
for root, dirs, files in os.walk(stage):
|
|
||||||
dirs.sort(); files.sort()
|
|
||||||
for name in dirs:
|
|
||||||
path = os.path.join(root, name)
|
|
||||||
zf.write(path, os.path.relpath(path, stage))
|
|
||||||
for name in files:
|
|
||||||
path = os.path.join(root, name)
|
|
||||||
zf.write(path, os.path.relpath(path, stage))
|
|
||||||
os.replace(tmp, out)
|
|
||||||
print(out)
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "==> Package sideload-safe IPA (strip Watch/)"
|
|
||||||
python3 - <<'PY'
|
|
||||||
import os, shutil, zipfile
|
|
||||||
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
|
||||||
stage = '/tmp/fuelboard-ipa-stage-sideload'
|
|
||||||
payload = os.path.join(stage, 'Payload')
|
|
||||||
out = '/tmp/ipa-serve/FuelBoard-sideload.ipa'
|
|
||||||
if os.path.exists(stage):
|
|
||||||
shutil.rmtree(stage)
|
|
||||||
os.makedirs(payload, exist_ok=True)
|
|
||||||
dst = os.path.join(payload, 'FuelBoard.app')
|
|
||||||
shutil.copytree(app, dst)
|
|
||||||
watch_dir = os.path.join(dst, 'Watch')
|
|
||||||
if os.path.exists(watch_dir):
|
|
||||||
shutil.rmtree(watch_dir)
|
|
||||||
tmp = out + '.tmp'
|
|
||||||
if os.path.exists(tmp):
|
|
||||||
os.remove(tmp)
|
|
||||||
with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
|
||||||
for root, dirs, files in os.walk(stage):
|
|
||||||
dirs.sort(); files.sort()
|
|
||||||
for name in dirs:
|
|
||||||
path = os.path.join(root, name)
|
|
||||||
zf.write(path, os.path.relpath(path, stage))
|
|
||||||
for name in files:
|
|
||||||
path = os.path.join(root, name)
|
|
||||||
zf.write(path, os.path.relpath(path, stage))
|
|
||||||
os.replace(tmp, out)
|
|
||||||
print(out)
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "==> Verify artifacts"
|
|
||||||
ls -lh "$FULL_IPA" "$SIDELOAD_IPA"
|
|
||||||
python3 - <<'PY'
|
|
||||||
import os, plistlib, json
|
|
||||||
base = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
|
||||||
paths = {
|
|
||||||
'host': os.path.join(base, 'Info.plist'),
|
|
||||||
'watch': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'Info.plist'),
|
|
||||||
'watch_ext': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'PlugIns', 'FuelBoardWatch Watch App Extension.appex', 'Info.plist'),
|
|
||||||
}
|
|
||||||
for name, path in paths.items():
|
|
||||||
print(f'## {name} {os.path.exists(path)}')
|
|
||||||
if os.path.exists(path):
|
|
||||||
with open(path, 'rb') as f:
|
|
||||||
pl = plistlib.load(f)
|
|
||||||
print(json.dumps({k: pl.get(k) for k in ['CFBundleIdentifier','CFBundleShortVersionString','CFBundleVersion']}, indent=2))
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "==> URLs"
|
|
||||||
echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa"
|
|
||||||
echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa"
|
|
||||||
echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"
|
|
||||||
Reference in New Issue
Block a user