Author SHA1 Message Date
FuelBoard Contributor 1299b4a004 feat: split Tap action into its own Settings section
Moves the Tap station picker out of Units into a dedicated Tap
action section, with the tap descriptor text as its footer. Units
footer reverts to distances/prices only.
2026-09-14 22:11:23 +01:00
FuelBoard Contributor cc948dac3a feat: station tap action toggle (open map vs more info)
Settings → Units gains a Tap station picker: Open map (default,
historic behaviour — tap opens Apple Maps directions) or More info
(tap shows a detail sheet with name, address, distance from current
location, and a Directions button). Applies to Stations and
Favourites tabs; persisted keychain-first as
fuelboard.stationTapAction. Stations footer hint follows the setting.
2026-09-14 22:03:20 +01:00
FuelBoard Contributor ac44cb4ce7 Merge feature/watch-app into main 2026-09-14 21:39:18 +01:00
FuelBoard Contributor abc737eb2d test: symlink DataRefreshMode into test target
FuelStore references DataRefreshMode; without the symlink the
SPM test target fails to compile. 107 tests pass.
2026-09-14 21:39:14 +01:00
FuelBoard Contributor 3d5c999dc2 build: align embedded bundle versions for archive 2026-08-31 11:35:22 +01:00
FuelBoard Contributor d899409b78 build: align all target build numbers to 3 2026-08-31 11:18:05 +01:00
FuelBoard Contributor 8bbcb0b4dc watch: polish refresh status and compact live activity 2026-08-31 10:40:16 +01:00
FuelBoard Contributor 767080a983 watch: use MapKit directions handoff 2026-08-31 08:23:07 +01:00
FuelBoard Contributor 7a84364238 watch: fix snapshot sync and polish favourites UX 2026-08-31 07:48:20 +01:00
FuelBoard Contributor 486b8cff2e Fix watch fuel icons and snapshot sync 2026-08-30 23:35:59 +01:00
FuelBoard Contributor d40bcaeca0 Fix watch icon pixel sizes for archive validation 2026-08-30 23:11:27 +01:00
FuelBoard Contributor f2fb075f2b Fix watch app name and unify watch icon assets 2026-08-30 22:58:05 +01:00
FuelBoard Contributor 09740fca18 Fix watch build, status sync, and icon assets 2026-08-30 22:26:30 +01:00
FuelBoard Contributor ebda21436d Restore smart settings and sync watch favourites 2026-08-30 18:57:57 +01:00
FuelBoard Contributor 0997dcea45 Set watch app team for signing 2026-08-30 13:35:16 +01:00
FuelBoard Contributor a59591d6ca Merge main into feature/watch-app 2026-08-30 13:10:59 +01:00
FuelBoard Contributor e586e5ecbf Merge main into feature/watch-app
Bring watch branch up to date with main (banner fade, onboarding timing,
Live Activity full-width layout). Resolved the bundled fuel dump data-refresh
conflict by taking main's newer snapshot (data_updated 2026-08-17T15:01, 8028
stations).
2026-08-19 18:11:13 +01:00
FuelBoard Contributor e004f25a31 feat: add real watch companion and dual IPA build workflow 2026-08-17 22:41:12 +01:00
86 changed files with 2261 additions and 121 deletions
+6 -13
View File
@@ -113,19 +113,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
action in the Alerts tab that forces a fetch and immediately re-evaluates the
cheapest-favourite alert. Copy should set expectation honestly: standard
mode checks when FuelBoard refreshes prices (up to twice daily).
- [ ] **Data checking mode: Standard vs Smart**keep the current cache-first
/ 12 h policy as `Standard`, and add an optional `Smart` mode in Settings.
`Smart` should try lightweight background checks of the mirror pointer
(`latest.json` / `data_updated`) more often, and only download the full price
dataset when a newer snapshot is available. Design goal: better freshness
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.
- [ ] **Price-drop alerts: optional frequent-check mode**if the app ever
relaxes the current "twice daily max" data policy, expose this as a clearly
separate mode rather than a freeform frequency slider. Example framing:
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
with conservative caps (e.g. every 46 h, not hourly) and copy that does not
promise exact timing under iOS background scheduling.
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
(currently `refreshable` fires but no visible progress in the row list).
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
+1 -1
View File
@@ -30,7 +30,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
+10
View File
@@ -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>
+34
View File
@@ -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>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FuelBoard</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>FuelBoard</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.apt.fuelboard</string>
<key>WKWatchKitApp</key>
<true/>
</dict>
</plist>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
+460 -34
View File
@@ -1,7 +1,4 @@
// !$*UTF8*$!
// FuelBoard — hand-crafted project (objectVersion 77, synchronized folders).
// Targets: FuelBoard (app) + FuelBoardWidgets (widget extension, embedded).
// Shared/ is a synchronized root group included in BOTH targets.
{
archiveVersion = 1;
classes = {
@@ -10,10 +7,26 @@
objects = {
/* Begin PBXBuildFile section */
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
ADCE5B643033A32D008C131C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = BB0000000000000000000004 /* Project object */;
proxyType = 1;
remoteGlobalIDString = ADCE5B4E3033A329008C131C;
remoteInfo = "FuelBoardWatch Watch App Extension";
};
ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = BB0000000000000000000004 /* Project object */;
proxyType = 1;
remoteGlobalIDString = ADCE5B673033A32D008C131C;
remoteInfo = "FuelBoardWatch Watch App";
};
BB0000000000000000000003 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = BB0000000000000000000004 /* Project object */;
@@ -24,6 +37,28 @@
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000006 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -38,11 +73,23 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "FuelBoardWatch Watch App";
sourceTree = "<group>";
};
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "FuelBoardWatch Wrapper";
sourceTree = "<group>";
};
BB0000000000000000000008 /* FuelBoard */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = FuelBoard;
@@ -61,6 +108,20 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
ADCE5B4C3033A329008C131C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B6D3033A32E008C131C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB000000000000000000000B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -84,6 +145,8 @@
BB0000000000000000000008 /* FuelBoard */,
BB0000000000000000000009 /* FuelBoardWidgets */,
BB000000000000000000000A /* Shared */,
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
BB000000000000000000000E /* Products */,
);
sourceTree = "<group>";
@@ -93,6 +156,8 @@
children = (
BB0000000000000000000007 /* FuelBoard.app */,
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -100,30 +165,50 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
BB000000000000000000000F /* FuelBoard */ = {
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
buildPhases = (
BB0000000000000000000011 /* Sources */,
BB000000000000000000000B /* Frameworks */,
BB0000000000000000000012 /* Resources */,
BB0000000000000000000006 /* Embed App Extensions */,
ADCE5B4B3033A329008C131C /* Sources */,
ADCE5B4C3033A329008C131C /* Frameworks */,
ADCE5B4D3033A329008C131C /* Resources */,
);
buildRules = (
);
dependencies = (
BB0000000000000000000013 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
BB0000000000000000000008 /* FuelBoard */,
BB000000000000000000000A /* Shared */,
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
);
name = FuelBoard;
name = "FuelBoardWatch Watch App Extension";
packageProductDependencies = (
);
productName = FuelBoard;
productReference = BB0000000000000000000007 /* FuelBoard.app */;
productType = "com.apple.product-type.application";
productName = "FuelBoardWatch Watch App Extension";
productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */;
productType = "com.apple.product-type.watchkit2-extension";
};
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
buildPhases = (
ADCE5B6E3033A32E008C131C /* Resources */,
ADCE5B653033A32D008C131C /* Embed App Extensions */,
ADCE5B6D3033A32E008C131C /* Frameworks */,
);
buildRules = (
);
dependencies = (
ADCE5B683033A32D008C131C /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
);
name = "FuelBoardWatch Watch App";
packageProductDependencies = (
);
productName = "FuelBoardWatch Watch App";
productReference = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */;
productType = "com.apple.product-type.application.watchapp2";
};
BB0000000000000000000005 /* FuelBoardWidgets */ = {
isa = PBXNativeTarget;
@@ -148,6 +233,33 @@
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
productType = "com.apple.product-type.app-extension";
};
BB000000000000000000000F /* FuelBoard */ = {
isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
buildPhases = (
BB0000000000000000000011 /* Sources */,
BB000000000000000000000B /* Frameworks */,
BB0000000000000000000012 /* Resources */,
ADCE5B5B3033A32C008C131C /* Embed Watch Content */,
BB0000000000000000000006 /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
ADCE5B703033A32F008C131C /* PBXTargetDependency */,
BB0000000000000000000013 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
BB0000000000000000000008 /* FuelBoard */,
BB000000000000000000000A /* Shared */,
);
name = FuelBoard;
packageProductDependencies = (
);
productName = FuelBoard;
productReference = BB0000000000000000000007 /* FuelBoard.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -155,15 +267,18 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 1600;
TargetAttributes = {
BB000000000000000000000F = {
CreatedOnToolsVersion = 16.0;
ADCE5B4E3033A329008C131C = {
CreatedOnToolsVersion = 26.3;
};
BB0000000000000000000005 = {
CreatedOnToolsVersion = 16.0;
};
BB000000000000000000000F = {
CreatedOnToolsVersion = 16.0;
};
};
};
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
@@ -182,11 +297,27 @@
targets = (
BB000000000000000000000F /* FuelBoard */,
BB0000000000000000000005 /* FuelBoardWidgets */,
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
ADCE5B4D3033A329008C131C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B6E3033A32E008C131C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -204,6 +335,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
ADCE5B4B3033A329008C131C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000011 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -221,6 +359,16 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
ADCE5B683033A32D008C131C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */;
targetProxy = ADCE5B643033A32D008C131C /* PBXContainerItemProxy */;
};
ADCE5B703033A32F008C131C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */;
targetProxy = ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */;
};
BB0000000000000000000013 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = BB0000000000000000000005 /* FuelBoardWidgets */;
@@ -229,6 +377,266 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
ADCE5B573033A32B008C131C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.2;
};
name = Debug;
};
ADCE5B583033A32B008C131C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.2;
};
name = Release;
};
ADCE5B6B3033A32D008C131C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.2;
};
name = Debug;
};
ADCE5B6C3033A32D008C131C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.2;
};
name = Release;
};
BB0000000000000000000018 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -291,15 +699,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist;
INFOPLIST_FILE = "Config/App-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -314,15 +722,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist;
INFOPLIST_FILE = "Config/App-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -337,16 +745,16 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist;
INFOPLIST_FILE = "Config/Widget-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -362,16 +770,16 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist;
INFOPLIST_FILE = "Config/Widget-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -384,11 +792,20 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB0000000000000000000018 /* Debug */,
BB0000000000000000000019 /* Release */,
ADCE5B573033A32B008C131C /* Debug */,
ADCE5B583033A32B008C131C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ADCE5B6B3033A32D008C131C /* Debug */,
ADCE5B6C3033A32D008C131C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -411,6 +828,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB0000000000000000000018 /* Debug */,
BB0000000000000000000019 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = BB0000000000000000000004 /* Project object */;
@@ -1,38 +1,170 @@
{
"images" : [
"images": [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances" : [
"appearances": [
{
"appearance" : "luminosity",
"value" : "dark"
"appearance": "luminosity",
"value": "dark"
}
],
"filename" : "AppIcon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon-dark.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances" : [
"appearances": [
{
"appearance" : "luminosity",
"value" : "tinted"
"appearance": "luminosity",
"value": "tinted"
}
],
"filename" : "AppIcon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon-tinted.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"filename": "Watch-NotificationCenter-48.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "24x24",
"subtype": "38mm"
},
{
"filename": "Watch-NotificationCenter-55.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "27.5x27.5",
"subtype": "42mm"
},
{
"filename": "Watch-NotificationCenter-66.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "33x33",
"subtype": "45mm"
},
{
"filename": "Watch-CompanionSettings-58.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "2x",
"size": "29x29"
},
{
"filename": "Watch-CompanionSettings-87.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "3x",
"size": "29x29"
},
{
"filename": "Watch-AppLauncher-80.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "40x40",
"subtype": "38mm"
},
{
"filename": "Watch-AppLauncher-88.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "44x44",
"subtype": "40mm"
},
{
"filename": "Watch-AppLauncher-92.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "46x46",
"subtype": "41mm"
},
{
"filename": "Watch-AppLauncher-100.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
},
{
"filename": "Watch-AppLauncher-102.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "51x51",
"subtype": "45mm"
},
{
"filename": "Watch-AppLauncher-108.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "54x54",
"subtype": "49mm"
},
{
"filename": "Watch-QuickLook-172.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "86x86",
"subtype": "38mm"
},
{
"filename": "Watch-QuickLook-196.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "98x98",
"subtype": "42mm"
},
{
"filename": "Watch-QuickLook-216.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "108x108",
"subtype": "44mm"
},
{
"filename": "Watch-QuickLook-234.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "117x117",
"subtype": "45mm"
},
{
"filename": "Watch-QuickLook-258.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "129x129",
"subtype": "49mm"
},
{
"filename": "Watch-Marketing-1024.png",
"idiom": "watch-marketing",
"scale": "1x",
"size": "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+32 -4
View File
@@ -21,6 +21,7 @@ struct ContentView: View {
@State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@@ -276,6 +277,7 @@ struct ContentView: View {
}
if !favs.isEmpty {
FuelStore.saveFavourites(favs)
WatchSyncManager.shared.pushSnapshot()
}
}
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
@@ -366,7 +368,6 @@ struct ContentView: View {
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
installLocationUpdateHook()
SmartDataRefreshScheduler.scheduleNextIfNeeded()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
@@ -382,12 +383,14 @@ struct ContentView: View {
if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
}
SmartDataRefreshScheduler.scheduleNextIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
refreshRoadDistancesIfNeeded()
// 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 {
locationManager.stopForegroundTracking()
}
@@ -396,6 +399,7 @@ struct ContentView: View {
if let newLocation {
location = newLocation
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
// Geofences follow the user's position, but the station list is
// NOT re-fetched on every movement (cached, twice-a-day policy).
@@ -414,6 +418,7 @@ struct ContentView: View {
.onChange(of: selectedFuel) { _, _ in
// No re-fetch needed one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
.onChange(of: stationLimit) { _, newValue in
// Distance filter is LOCAL math now the cache holds the full-UK
@@ -738,6 +743,7 @@ struct ContentView: View {
topStationID: topStationID,
location: location,
favouriteIDs: favouriteIDs,
stationTapAction: stationTapAction,
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
)
@@ -752,6 +758,7 @@ struct ContentView: View {
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
stationTapAction: stationTapAction,
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
@@ -790,6 +797,7 @@ struct ContentView: View {
tipStore: tipStore,
distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle,
stationTapAction: $stationTapAction,
dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius,
@@ -819,6 +827,7 @@ struct ContentView: View {
favourites.append(key)
}
FuelStore.saveFavourites(favourites)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
@@ -830,11 +839,17 @@ struct ContentView: View {
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
favourites = newOrder
FuelStore.saveFavourites(favourites)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
private func handlePendingWatchRefreshRequest() async {
guard FuelStore.hasPendingWatchRefreshRequest else { return }
await refresh(force: true)
}
/// Fetches fresh prices, but only when the cache is stale unless
/// `force` is true (pull-to-refresh is the manual override).
private func refresh(force: Bool = false) async {
@@ -853,7 +868,10 @@ struct ContentView: View {
stations = fetched
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh()
FuelStore.saveLastSmartProbe()
if force, FuelStore.hasPendingWatchRefreshRequest {
FuelStore.markWatchRefreshHandled()
}
WatchSyncManager.shared.pushSnapshot()
// Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings About section the
// live chain records whichever leg served the fetch.
@@ -876,6 +894,9 @@ struct ContentView: View {
// Live data restored any status banner no longer applies.
dataStatus = .live
} catch {
if force, FuelStore.hasPendingWatchRefreshRequest {
FuelStore.markWatchRefreshHandled()
}
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
if FuelStore.loadStations().isEmpty {
// No cached prices last resort is the bundled REAL dump
@@ -914,7 +935,9 @@ struct StationRow: View {
let baselinePrice: Double?
let isTopResult: Bool
let isFavourite: Bool
var tapAction: StationTapAction = .openMap
var onToggleFavourite: () -> Void = {}
var onShowDetails: (FuelStation) -> Void = { _ in }
private var ragColor: Color {
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
@@ -1013,9 +1036,14 @@ struct StationRow: View {
}
.contentShape(Rectangle())
.onTapGesture {
switch tapAction {
case .openMap:
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
case .showDetails:
onShowDetails(station)
}
}
}
}
+16 -1
View File
@@ -14,6 +14,7 @@ struct FavouritesView: View {
let location: Coordinate?
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let stationTapAction: StationTapAction
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -37,6 +38,9 @@ struct FavouritesView: View {
/// Trends sheet (price history chart) presentation state.
@State private var showTrends = false
/// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation?
private var activeFuel: FuelType {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
}
@@ -65,6 +69,7 @@ struct FavouritesView: View {
location: Coordinate?,
distanceUnit: DistanceUnit,
priceDisplayStyle: PriceDisplayStyle,
stationTapAction: StationTapAction,
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil,
@@ -74,6 +79,7 @@ struct FavouritesView: View {
self.location = location
self.distanceUnit = distanceUnit
self.priceDisplayStyle = priceDisplayStyle
self.stationTapAction = stationTapAction
self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
@@ -129,7 +135,9 @@ struct FavouritesView: View {
baselinePrice: cheapestPrice,
isTopResult: index == 0,
isFavourite: activeFuelFavouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
tapAction: stationTapAction,
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 }
)
}
.onMove(perform: moveFavourite)
@@ -171,6 +179,13 @@ struct FavouritesView: View {
onHistoryRecovered: onHistoryRecovered
)
}
.sheet(item: $detailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit
)
}
.onAppear {
#if DEBUG
// QA hook: launch with `-showTrends` to open the sheet
+4
View File
@@ -5,6 +5,7 @@ import SwiftUI
@main
struct FuelBoardApp: App {
init() {
WatchSyncManager.shared.activate()
#if DEBUG
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
// Live Activity with a long station name so the Lock Screen / island
@@ -22,6 +23,9 @@ struct FuelBoardApp: App {
WindowGroup {
ContentView()
.onOpenURL(perform: handleOpenURL)
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
WatchSyncManager.shared.pushSnapshot()
}
}
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
await SmartDataRefreshCoordinator.runBackgroundProbe()
+1 -3
View File
@@ -73,9 +73,7 @@ enum LiveActivityManager {
currentStartDate = nil
start(attributes: attributes, state: state)
} else {
Task {
await current.update(.init(state: state, staleDate: nil))
}
Task { await current.update(using: state) }
}
} else {
start(attributes: attributes, state: state)
+2 -6
View File
@@ -73,12 +73,8 @@ enum RoadDistanceService {
/// Driving distance (metres) between two coordinates via Apple Maps routing.
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
let request = MKDirections.Request()
request.source = MKMapItem(location: CLLocation(latitude: from.latitude,
longitude: from.longitude),
address: nil)
request.destination = MKMapItem(location: CLLocation(latitude: to.latitude,
longitude: to.longitude),
address: nil)
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
request.transportType = .automobile
request.requestsAlternateRoutes = false
do {
+20
View File
@@ -18,6 +18,7 @@ struct SettingsView: View {
@ObservedObject var tipStore: TipStore
@Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle
@Binding var stationTapAction: StationTapAction
@Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say.
@@ -104,6 +105,7 @@ struct SettingsView: View {
.onChange(of: distanceUnit) { _, newValue in
FuelStore.saveDistanceUnit(newValue)
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
Picker("Price display", selection: $priceDisplayStyle) {
ForEach(PriceDisplayStyle.allCases) { style in
@@ -114,6 +116,7 @@ struct SettingsView: View {
.onChange(of: priceDisplayStyle) { _, newValue in
FuelStore.savePriceDisplayStyle(newValue)
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
} header: {
Text("Units")
@@ -121,6 +124,22 @@ struct SettingsView: View {
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
}
Section {
Picker("Tap station", selection: $stationTapAction) {
ForEach(StationTapAction.allCases) { action in
Text(action.displayName).tag(action)
}
}
.pickerStyle(.segmented)
.onChange(of: stationTapAction) { _, newValue in
FuelStore.saveStationTapAction(newValue)
}
} header: {
Text("Tap action")
} footer: {
Text("Tapping a station either opens directions straight away or shows its details first.")
}
Section {
Picker("Data checking", selection: $dataRefreshMode) {
ForEach(DataRefreshMode.allCases) { mode in
@@ -131,6 +150,7 @@ struct SettingsView: View {
.onChange(of: dataRefreshMode) { _, newValue in
FuelStore.saveDataRefreshMode(newValue)
SmartDataRefreshScheduler.scheduleNextIfNeeded()
WatchSyncManager.shared.pushSnapshot()
}
VStack(alignment: .leading, spacing: 8) {
Text(dataRefreshMode.summary)
+1
View File
@@ -76,6 +76,7 @@ enum SmartDataRefreshCoordinator {
guard !Task.isCancelled else { return false }
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh(now)
await WatchSyncManager.shared.pushSnapshot()
if let meta = LiveChainProvider.latestMeta {
FuelStore.saveRelayMeta(meta)
}
+47
View File
@@ -0,0 +1,47 @@
import SwiftUI
import UIKit
/// Detail sheet for a tapped station (when Settings Tap station = More info).
/// Shows the station name, address, distance from the current location, and a
/// Directions button that opens Apple Maps the same destination a direct tap
/// would have opened.
struct StationDetailView: View {
let station: FuelStation
let location: Coordinate?
let distanceUnit: DistanceUnit
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
LabeledContent("Name", value: station.name)
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
if let location {
let km = FuelStore.displayDistanceKM(
station: station, userLat: location.lat, userLng: location.lng
)
LabeledContent("Distance", value: distanceUnit.format(km))
.monospacedDigit()
}
}
Section {
Button {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
}
}
}
.navigationTitle(station.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
+19 -3
View File
@@ -15,6 +15,7 @@ struct StationsView: View {
let topStationID: String?
let location: Coordinate?
let favouriteIDs: Set<String>
let stationTapAction: StationTapAction
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {}
@@ -29,6 +30,9 @@ struct StationsView: View {
/// info button in the header keeps the list focused on stations.
@State private var showKey = false
/// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation?
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
/// directions context + "N/TOTAL stations updated". The numerator is the
/// current list pool; the denominator is the full UK station total from
@@ -38,6 +42,9 @@ struct StationsView: View {
let unit = distanceUnit.label(for: Double(miles))
let fuel = selectedFuel.displayName
let mode = sortMode == .closest ? "Closest" : "Cheapest"
let tapHint = stationTapAction == .openMap
? "tap a station for directions."
: "tap a station for details."
let ratio: String
if let total = FuelStore.loadStationCount() {
ratio = "\(totalCount)/\(total)"
@@ -48,9 +55,9 @@ struct StationsView: View {
if sortMode == .closest {
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
}
return "\(mode) \(fuel) within \(miles) \(unit)\(ratio) stations updated · tap a station for directions."
return "\(mode) \(fuel) within \(miles) \(unit)\(ratio) stations updated · \(tapHint)"
}
return "\(mode) \(fuel)\(ratio) stations updated · tap a station for directions."
return "\(mode) \(fuel)\(ratio) stations updated · \(tapHint)"
}
var body: some View {
@@ -126,7 +133,9 @@ struct StationsView: View {
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
tapAction: stationTapAction,
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
}
@@ -260,6 +269,13 @@ struct StationsView: View {
.presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
.sheet(item: $detailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit
)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import Foundation
import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
}
@MainActor
final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager()
private var pendingSnapshotPush = false
private override init() {
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
pendingSnapshotPush = true
let session = WCSession.default
if session.delegate !== self {
session.delegate = self
}
session.activate()
}
func pushSnapshot() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated else {
pendingSnapshotPush = true
return
}
do {
try session.updateApplicationContext(snapshotContext())
pendingSnapshotPush = false
} catch {
print("WATCH-SYNC push failed: \(error.localizedDescription)")
}
}
private func snapshotContext() -> [String: Any] {
var context: [String: Any] = [:]
if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) {
context[FuelStore.favouritesKey] = favourites
}
// Do NOT send the full station dump to watchOS via WatchConnectivity.
// The UK dataset is megabytes large and exceeds application-context
// payload limits, which prevents any snapshot from arriving. The
// favourites payload already carries station snapshots + prices, so the
// watch can render favourites without the full list.
context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue
context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue
context[FuelStore.priceDisplayStyleKey] = FuelStore.loadPriceDisplayStyle().rawValue
if let lastRefresh = FuelStore.loadLastRefresh() {
context[FuelStore.lastRefreshKey] = String(lastRefresh.timeIntervalSince1970)
}
if let handled = FuelStore.loadWatchRefreshHandled() {
context[FuelStore.watchRefreshHandledKey] = String(handled.timeIntervalSince1970)
}
if let location = FuelStore.loadLocationWithDate() {
context[FuelStore.locationKey] = "\(location.coordinate.lat),\(location.coordinate.lng),\(location.date.timeIntervalSince1970)"
}
return context
}
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return
}
Task { @MainActor in
if activationState == .activated, self.pendingSnapshotPush {
self.pushSnapshot()
}
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
Task { @MainActor in
WCSession.default.activate()
}
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
handleIncomingMessage(userInfo)
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
handleIncomingMessage(message)
}
private nonisolated func handleIncomingMessage(_ payload: [String: Any]) {
if payload["fuelboard.snapshotRequest"] as? String != nil {
Task { @MainActor in
self.pushSnapshot()
}
}
guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String,
let timestamp = TimeInterval(raw) else { return }
let date = Date(timeIntervalSince1970: timestamp)
Task { @MainActor in
FuelStore.requestWatchRefresh(date)
NotificationCenter.default.post(name: .fuelBoardWatchRefreshRequested, object: nil)
}
}
}
+8
View File
@@ -56,6 +56,14 @@
"Units" = "Units";
"Price display" = "Price display";
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
"Tap station" = "Tap station";
"Tap action" = "Tap action";
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
"Open map" = "Open map";
"More info" = "More info";
"Name" = "Name";
"Address" = "Address";
"Distance" = "Distance";
"Show introduction" = "Replay onboarding";
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
"Test alert notification (real data)" = "Test alert notification (real data)";
@@ -1,24 +0,0 @@
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."
}
}
}
@@ -0,0 +1 @@
../../../Shared/DataRefreshMode.swift
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,156 @@
{
"images": [
{
"filename": "Watch-NotificationCenter-48.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "24x24",
"subtype": "38mm"
},
{
"filename": "Watch-NotificationCenter-55.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "27.5x27.5",
"subtype": "42mm"
},
{
"filename": "Watch-NotificationCenter-66.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "33x33",
"subtype": "45mm"
},
{
"filename": "Watch-CompanionSettings-58.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "2x",
"size": "29x29"
},
{
"filename": "Watch-CompanionSettings-87.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "3x",
"size": "29x29"
},
{
"filename": "Watch-AppLauncher-80.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "40x40",
"subtype": "38mm"
},
{
"filename": "Watch-AppLauncher-88.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "44x44",
"subtype": "40mm"
},
{
"filename": "Watch-AppLauncher-92.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "46x46",
"subtype": "41mm"
},
{
"filename": "Watch-AppLauncher-100.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
},
{
"filename": "Watch-AppLauncher-102.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "51x51",
"subtype": "45mm"
},
{
"filename": "Watch-AppLauncher-108.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "54x54",
"subtype": "49mm"
},
{
"filename": "Watch-QuickLook-172.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "86x86",
"subtype": "38mm"
},
{
"filename": "Watch-QuickLook-196.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "98x98",
"subtype": "42mm"
},
{
"filename": "Watch-QuickLook-216.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "108x108",
"subtype": "44mm"
},
{
"filename": "Watch-QuickLook-234.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "117x117",
"subtype": "45mm"
},
{
"filename": "Watch-QuickLook-258.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "129x129",
"subtype": "49mm"
},
{
"filename": "Watch-Marketing-1024.png",
"idiom": "watch-marketing",
"scale": "1x",
"size": "1024x1024"
},
{
"filename": "Watch-LongLook-88.png",
"idiom": "watch",
"role": "longLook",
"scale": "2x",
"size": "44x44",
"subtype": "42mm"
},
{
"filename": "Watch-LongLook-100.png",
"idiom": "watch",
"role": "longLook",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+609
View File
@@ -0,0 +1,609 @@
import SwiftUI
import Foundation
import MapKit
private enum WatchFuelType: String, CaseIterable, Identifiable, Codable {
case e10
case e5
case diesel
var id: String { rawValue }
var displayName: String {
switch self {
case .e10: return "Unleaded"
case .e5: return "Premium"
case .diesel: return "Diesel"
}
}
var shortName: String {
switch self {
case .e10: return "E10"
case .e5: return "E5"
case .diesel: return "B7"
}
}
var color: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
var symbol: String { "fuelpump.fill" }
var sortRank: Int {
switch self {
case .e10: return 0
case .e5: return 1
case .diesel: return 2
}
}
}
private enum WatchDistanceUnit: String, Codable {
case miles
case kilometers
func format(km: Double) -> String {
switch self {
case .miles:
return String(format: "%.1f mi", km * 0.621371)
case .kilometers:
return String(format: "%.1f km", km)
}
}
}
private enum WatchPriceStyle: String, Codable {
case stationSign
case poundsPence
}
private struct WatchCoordinate {
let lat: Double
let lng: Double
}
private struct WatchStation: Codable, Identifiable {
let id: String
let name: String
let brand: String
let address: String
let postcode: String
let lat: Double
let lng: Double
let prices: [WatchFuelType: Double]
let priceUpdated: TimeInterval?
func distanceKM(to location: WatchCoordinate) -> Double {
let r = 6371.0
let dLat = (location.lat - lat) * .pi / 180
let dLng = (location.lng - lng) * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2)
+ cos(lat * .pi / 180) * cos(location.lat * .pi / 180)
* sin(dLng / 2) * sin(dLng / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
}
private struct WatchFavouriteEntry: Codable, Identifiable {
let station: WatchStation
let fuel: WatchFuelType
var id: String { "\(fuel.rawValue)|\(station.id)" }
}
private struct WatchFavouriteRow: Identifiable {
let id: String
let name: String
let priceText: String
let distanceText: String?
let isCheapest: Bool
let latitude: Double
let longitude: Double
}
private enum WatchRefreshState {
case idle(lastUpdated: String)
case stale(lastUpdated: String)
case checking
case requested(lastUpdated: String)
case checkedNoNewData(lastUpdated: String)
case needsPhone
case noData
var label: String {
switch self {
case .idle(let lastUpdated): return "Updated \(lastUpdated)"
case .stale(let lastUpdated): return "Cache from \(lastUpdated)"
case .checking: return "Checking…"
case .requested(let lastUpdated): return "Updated · \(lastUpdated)"
case .checkedNoNewData(let lastUpdated): return "No newer data · \(lastUpdated)"
case .needsPhone: return "Refresh on iPhone"
case .noData: return "No cached prices yet"
}
}
var tint: Color {
switch self {
case .idle: return .secondary
case .stale: return .yellow
case .checking: return .yellow
case .requested: return .green
case .checkedNoNewData: return .secondary
case .needsPhone: return .orange
case .noData: return .secondary
}
}
var icon: String {
switch self {
case .idle: return "clock"
case .stale: return "exclamationmark.circle"
case .checking: return "arrow.triangle.2.circlepath"
case .requested: return "checkmark.circle"
case .checkedNoNewData: return "clock.badge.exclamationmark"
case .needsPhone: return "iphone"
case .noData: return "tray"
}
}
}
private enum WatchFuelCache {
static let appGroupSuite = "group.com.apt.fuelboard"
static let favouritesKey = "fuelboard.favourites"
static let stationsKey = "fuelboard.stations"
static let fuelKey = "fuelboard.selectedFuel"
static let distanceUnitKey = "fuelboard.distanceUnit"
static let priceStyleKey = "fuelboard.priceDisplayStyle"
static let locationKey = "fuelboard.lastLocation"
static let lastRefreshKey = "fuelboard.lastRefresh"
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest"
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled"
static let refreshInterval: TimeInterval = 12 * 60 * 60
static func defaults() -> UserDefaults? {
UserDefaults(suiteName: appGroupSuite)
}
static func loadSelectedFuel() -> WatchFuelType {
guard let raw = defaults()?.string(forKey: fuelKey), let fuel = WatchFuelType(rawValue: raw) else {
return .e10
}
return fuel
}
static func loadDistanceUnit() -> WatchDistanceUnit {
guard let raw = defaults()?.string(forKey: distanceUnitKey), let unit = WatchDistanceUnit(rawValue: raw) else {
return .miles
}
return unit
}
static func loadPriceStyle() -> WatchPriceStyle {
guard let raw = defaults()?.string(forKey: priceStyleKey), let style = WatchPriceStyle(rawValue: raw) else {
return .stationSign
}
return style
}
static func loadLocation() -> WatchCoordinate? {
guard let raw = defaults()?.string(forKey: locationKey) else { return nil }
let parts = raw.split(separator: ",").compactMap { Double($0) }
guard parts.count >= 2 else { return nil }
return WatchCoordinate(lat: parts[0], lng: parts[1])
}
static func loadLastRefresh() -> Date? {
guard let raw = defaults()?.string(forKey: lastRefreshKey), let ts = TimeInterval(raw) else { return nil }
return Date(timeIntervalSince1970: ts)
}
static func isCacheFresh(lastRefresh: Date?) -> Bool {
guard let lastRefresh else { return false }
return Date().timeIntervalSince(lastRefresh) < refreshInterval
}
static func requestRefresh(_ date: Date = Date()) {
defaults()?.set(String(date.timeIntervalSince1970), forKey: watchRefreshRequestKey)
}
static func loadRefreshRequest() -> Date? {
guard let raw = defaults()?.string(forKey: watchRefreshRequestKey), let ts = TimeInterval(raw) else { return nil }
return Date(timeIntervalSince1970: ts)
}
static func loadRefreshHandled() -> Date? {
guard let raw = defaults()?.string(forKey: watchRefreshHandledKey), let ts = TimeInterval(raw) else { return nil }
return Date(timeIntervalSince1970: ts)
}
static func wasRefreshHandled(since requestDate: Date) -> Bool {
guard let handled = loadRefreshHandled() else { return false }
return handled >= requestDate
}
static func loadStations() -> [WatchStation] {
guard let data = defaults()?.data(forKey: stationsKey),
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
return []
}
return stations
}
static func loadFavourites() -> [WatchFavouriteEntry] {
guard let data = defaults()?.data(forKey: favouritesKey),
let favourites = try? JSONDecoder().decode([WatchFavouriteEntry].self, from: data) else {
return []
}
return favourites
}
static func refreshedFavourites(_ favourites: [WatchFavouriteEntry], from stations: [WatchStation]) -> [WatchFavouriteEntry] {
favourites.map { favourite in
guard let fresh = stations.first(where: { $0.id == favourite.station.id }) else { return favourite }
return WatchFavouriteEntry(station: fresh, fuel: favourite.fuel)
}
}
static func cheapestFavourite(in favourites: [WatchFavouriteEntry], fuel: WatchFuelType) -> WatchFavouriteEntry? {
var best: WatchFavouriteEntry?
for favourite in favourites where favourite.fuel == fuel {
guard let price = favourite.station.prices[fuel] else { continue }
guard let current = best, let currentPrice = current.station.prices[fuel] else {
best = favourite
continue
}
if price < currentPrice {
best = favourite
}
}
return best
}
static func priceText(_ pence: Double, style: WatchPriceStyle) -> String {
switch style {
case .stationSign:
return String(format: "%.1f", pence)
case .poundsPence:
let tenths = Int((pence * 10).rounded())
let whole = tenths / 1000
let major = (tenths % 1000) / 10
let minor = tenths % 10
let superscripts: [Character] = ["", "¹", "²", "³", "", "", "", "", "", ""]
return String(format: "£%d.%02d", whole, major) + String(superscripts[minor]) + "/L"
}
}
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel()
@State private var refreshState: WatchRefreshState = .noData
@State private var favourites: [WatchFavouriteEntry] = []
@State private var stations: [WatchStation] = []
@State private var lastLocation: WatchCoordinate?
@State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit()
@State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle()
private var displayedFuels: [WatchFuelType] {
let fuels = Set(favourites.map(\.fuel))
let sorted = fuels.sorted { $0.sortRank < $1.sortRank }
return sorted.isEmpty ? [fuel] : sorted
}
private var hasAnyFavourites: Bool {
!favourites.isEmpty
}
var body: some View {
Group {
if hasAnyFavourites {
TabView(selection: $fuel) {
ForEach(displayedFuels) { fuelCase in
fuelPage(for: fuelCase)
.tag(fuelCase)
}
}
.tabViewStyle(.verticalPage)
} else {
emptyStateView
}
}
.onAppear {
reloadFromCache()
WatchSyncManager.shared.requestSnapshot()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in
reloadFromCache()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
reloadFromCache()
WatchSyncManager.shared.requestSnapshot()
}
}
}
@ViewBuilder
private func fuelPage(for fuelCase: WatchFuelType) -> some View {
let fuelFavourites = favouritesForFuel(fuelCase)
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
let rows = rows(for: fuelCase)
ScrollView {
VStack(alignment: .leading, spacing: 12) {
header(for: fuelCase)
if let cheapest {
cheapestCard(for: fuelCase, favourite: cheapest)
}
statusRow
Button {
runCheckNow()
} label: {
HStack {
Image(systemName: refreshState.icon)
Text("Check now")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(fuelCase.color)
VStack(alignment: .leading, spacing: 8) {
Text("Favourites")
.font(.headline)
ForEach(rows) { row in
favouriteRow(row, fuel: fuelCase)
}
}
}
.padding(.horizontal, 10)
.padding(.vertical, 12)
}
}
private func favouritesForFuel(_ fuelCase: WatchFuelType) -> [WatchFavouriteEntry] {
favourites.filter { $0.fuel == fuelCase }
}
private func rows(for fuelCase: WatchFuelType) -> [WatchFavouriteRow] {
let fuelFavourites = favouritesForFuel(fuelCase)
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
return fuelFavourites.map { favourite in
let distanceText = lastLocation.map { distanceUnit.format(km: favourite.station.distanceKM(to: $0)) }
let priceText = favourite.station.prices[fuelCase].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? ""
return WatchFavouriteRow(
id: favourite.id,
name: favourite.station.name,
priceText: priceText,
distanceText: distanceText,
isCheapest: favourite.id == cheapest?.id,
latitude: favourite.station.lat,
longitude: favourite.station.lng
)
}
}
private func header(for fuel: WatchFuelType) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: fuel.symbol)
.foregroundStyle(fuel.color)
VStack(alignment: .leading, spacing: 1) {
Text("FuelBoard")
.font(.headline)
Text(fuel.displayName)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
}
private var emptyStateView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "fuelpump.fill")
.foregroundStyle(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
Text("FuelBoard")
.font(.headline)
Spacer()
}
statusRow
Button {
runCheckNow()
} label: {
HStack {
Image(systemName: refreshState.icon)
Text("Check now")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
VStack(alignment: .leading, spacing: 6) {
Text("No favourites yet")
.font(.headline)
Text("Add favourites on iPhone and theyll appear here.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.padding(.horizontal, 10)
.padding(.vertical, 12)
}
}
private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View {
Button {
openMaps(latitude: favourite.station.lat, longitude: favourite.station.lng)
} label: {
VStack(alignment: .leading, spacing: 6) {
Text("Cheapest favourite")
.font(.caption2)
.foregroundStyle(.secondary)
Text(favourite.station.name)
.font(.headline)
.lineLimit(2)
HStack(alignment: .firstTextBaseline) {
Text(favourite.station.prices[fuel].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "")
.font(.title3)
.fontWeight(.bold)
Spacer()
if let location = lastLocation {
Text(distanceUnit.format(km: favourite.station.distanceKM(to: location)))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
private var statusRow: some View {
Label(refreshState.label, systemImage: refreshState.icon)
.font(.caption)
.foregroundStyle(refreshState.tint)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View {
Button {
openMaps(latitude: favourite.latitude, longitude: favourite.longitude)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(favourite.name)
.font(.caption)
.fontWeight(favourite.isCheapest ? .semibold : .regular)
.lineLimit(1)
if favourite.isCheapest {
Text("TOP")
.font(.system(size: 9, weight: .bold))
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(fuel.color.opacity(0.18), in: Capsule())
}
}
if let distanceText = favourite.distanceText {
Text(distanceText)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(favourite.priceText)
.font(.callout.monospacedDigit())
.fontWeight(.semibold)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private func reloadFromCache() {
stations = WatchFuelCache.loadStations()
favourites = WatchFuelCache.refreshedFavourites(WatchFuelCache.loadFavourites(), from: stations)
distanceUnit = WatchFuelCache.loadDistanceUnit()
priceStyle = WatchFuelCache.loadPriceStyle()
lastLocation = WatchFuelCache.loadLocation()
refreshState = statusFromCache()
let availableFuels = Set(favourites.map(\.fuel))
if !availableFuels.isEmpty, !availableFuels.contains(fuel) {
fuel = availableFuels.sorted { $0.sortRank < $1.sortRank }.first ?? .e10
}
}
private func statusFromCache() -> WatchRefreshState {
let hasUsablePrices = !stations.isEmpty
|| favourites.contains { favourite in favourite.station.prices[fuel] != nil }
guard hasUsablePrices else { return .noData }
guard let lastRefresh = WatchFuelCache.loadLastRefresh() else { return .stale(lastUpdated: "earlier") }
let formatted = relativeRefreshText(for: lastRefresh)
return WatchFuelCache.isCacheFresh(lastRefresh: lastRefresh)
? .idle(lastUpdated: formatted)
: .stale(lastUpdated: formatted)
}
private func runCheckNow() {
refreshState = .checking
let previousLastRefresh = WatchFuelCache.loadLastRefresh()
let requestDate = Date()
WatchFuelCache.requestRefresh(requestDate)
WatchSyncManager.shared.requestRefresh(at: requestDate)
Task {
for _ in 0..<12 {
try? await Task.sleep(for: .seconds(1))
if WatchFuelCache.wasRefreshHandled(since: requestDate) {
await MainActor.run {
reloadFromCache()
let currentLastRefresh = WatchFuelCache.loadLastRefresh()
if let currentLastRefresh,
currentLastRefresh >= requestDate,
currentLastRefresh > (previousLastRefresh ?? .distantPast) {
refreshState = .requested(lastUpdated: relativeRefreshText(for: currentLastRefresh))
} else if let currentLastRefresh {
refreshState = .checkedNoNewData(lastUpdated: relativeRefreshText(for: currentLastRefresh))
} else {
refreshState = .noData
}
}
return
}
}
await MainActor.run {
reloadFromCache()
refreshState = .needsPhone
}
}
}
private func relativeRefreshText(for date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter.localizedString(for: date, relativeTo: Date())
}
private func openMaps(latitude: Double, longitude: Double) {
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
let destination = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
_ = MKMapItem.openMaps(
with: [MKMapItem.forCurrentLocation(), destination],
launchOptions: [
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
]
)
}
}
#Preview {
ContentView()
}
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct FuelBoardWatchApp: App {
init() {
WatchSyncManager.shared.activate()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@@ -0,0 +1,101 @@
import Foundation
import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchDataDidUpdate = Notification.Name("FuelBoardWatchDataDidUpdate")
}
final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager()
private let suiteName = "group.com.apt.fuelboard"
private let snapshotRequestKey = "fuelboard.snapshotRequest"
private override init() {
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
if session.delegate !== self {
session.delegate = self
}
session.activate()
}
func requestRefresh(at date: Date = Date()) {
guard WCSession.isSupported() else { return }
let raw = String(date.timeIntervalSince1970)
let payload = ["fuelboard.watchRefreshRequest": raw]
let session = WCSession.default
if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
} else {
session.transferUserInfo(payload)
}
}
func requestSnapshot() {
guard WCSession.isSupported() else { return }
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
let session = WCSession.default
if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
} else {
session.transferUserInfo(payload)
}
}
private func sharedDefaults() -> UserDefaults? {
UserDefaults(suiteName: suiteName)
}
private func storeSnapshot(_ payload: [String: Any]) {
guard let defaults = sharedDefaults() else { return }
if let favourites = payload["fuelboard.favourites"] as? Data {
defaults.set(favourites, forKey: "fuelboard.favourites")
}
if let fuel = payload["fuelboard.selectedFuel"] as? String {
defaults.set(fuel, forKey: "fuelboard.selectedFuel")
}
if let distanceUnit = payload["fuelboard.distanceUnit"] as? String {
defaults.set(distanceUnit, forKey: "fuelboard.distanceUnit")
}
if let priceStyle = payload["fuelboard.priceDisplayStyle"] as? String {
defaults.set(priceStyle, forKey: "fuelboard.priceDisplayStyle")
}
if let location = payload["fuelboard.lastLocation"] as? String {
defaults.set(location, forKey: "fuelboard.lastLocation")
}
if let lastRefresh = payload["fuelboard.lastRefresh"] as? String {
defaults.set(lastRefresh, forKey: "fuelboard.lastRefresh")
}
if let handled = payload["fuelboard.watchRefreshHandled"] as? String {
defaults.set(handled, forKey: "fuelboard.watchRefreshHandled")
}
NotificationCenter.default.post(name: .fuelBoardWatchDataDidUpdate, object: nil)
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return
}
if activationState == .activated {
requestSnapshot()
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
storeSnapshot(applicationContext)
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
storeSnapshot(userInfo)
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,140 @@
{
"images": [
{
"filename": "Watch-NotificationCenter-48.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "24x24",
"subtype": "38mm"
},
{
"filename": "Watch-NotificationCenter-55.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "27.5x27.5",
"subtype": "42mm"
},
{
"filename": "Watch-NotificationCenter-66.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "33x33",
"subtype": "45mm"
},
{
"filename": "Watch-CompanionSettings-58.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "2x",
"size": "29x29"
},
{
"filename": "Watch-CompanionSettings-87.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "3x",
"size": "29x29"
},
{
"filename": "Watch-AppLauncher-80.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "40x40",
"subtype": "38mm"
},
{
"filename": "Watch-AppLauncher-88.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "44x44",
"subtype": "40mm"
},
{
"filename": "Watch-AppLauncher-92.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "46x46",
"subtype": "41mm"
},
{
"filename": "Watch-AppLauncher-100.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
},
{
"filename": "Watch-AppLauncher-102.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "51x51",
"subtype": "45mm"
},
{
"filename": "Watch-AppLauncher-108.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "54x54",
"subtype": "49mm"
},
{
"filename": "Watch-QuickLook-172.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "86x86",
"subtype": "38mm"
},
{
"filename": "Watch-QuickLook-196.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "98x98",
"subtype": "42mm"
},
{
"filename": "Watch-QuickLook-216.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "108x108",
"subtype": "44mm"
},
{
"filename": "Watch-QuickLook-234.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "117x117",
"subtype": "45mm"
},
{
"filename": "Watch-QuickLook-258.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "129x129",
"subtype": "49mm"
},
{
"filename": "Watch-Marketing-1024.png",
"idiom": "watch-marketing",
"scale": "1x",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -147,9 +147,9 @@ private struct FuelBoardLiveActivityView: View {
}
/// Minimal strip for small space (CarPlay small / Watch smart stack):
/// fuel type + bold price on one line, station · distance below.
/// Deliberately no app name and no "Tap for directions" CarPlay is
/// display-only, and the user's asks here are just fuel + price + distance.
/// fuel type + distance + bold price on one line, station name below.
/// Deliberately no app name and no full address the watch-sized slot is
/// too tight, so the second line is reserved for the station name only.
private var compactBody: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
@@ -157,16 +157,21 @@ private struct FuelBoardLiveActivityView: View {
.font(.caption.bold())
.lineLimit(1)
Spacer(minLength: 4)
Text(context.state.distanceText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 15, weight: .bold)
.lineLimit(1)
}
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.system(size: 9))
Text(context.state.stationName)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.minimumScaleFactor(0.75)
.truncationMode(.tail)
}
.padding(8)
+39
View File
@@ -0,0 +1,39 @@
# FuelBoard Release Notes
## Next TestFlight build
### Summary
- Added a new Data checking setting with Standard and Smart modes.
- Clarified the behavior and background-update expectations in Settings.
### What changed
- Added a dedicated **Data checking** section in Settings.
- Added two user-facing modes:
- **Standard** — Saves battery. Refreshes price data up to twice daily.
- **Smart** — Checks for newer data more often in the background and refreshes full prices only when an update is available.
- Smart mode now uses a lightweight metadata probe first, then only refreshes the full dataset if newer data is available.
- Added clear explanatory copy that background checks are best-effort and happen only when iOS allows.
- Kept larger refresh behavior conservative rather than implying exact hourly updates.
### Why
- The previous refresh model was conservative but not very configurable.
- The new setting makes the behavior clearer and gives users a choice between battery-friendly default behavior and a smarter background-check mode.
- The wording is designed to be honest about iOS background limits.
### Suggested TestFlight notes
- Added new Data checking modes in Settings: Standard and Smart.
- Smart mode now checks for newer data more intelligently in the background when iOS allows.
- Improved settings copy to better explain data freshness behavior.
### Suggested What to Test
- Open Settings and confirm **Data checking** appears as its own section.
- Confirm the two options read clearly and feel understandable:
- Standard
- Smart
- Switch between Standard and Smart and confirm the selection persists.
- Confirm the explanatory copy feels accurate and not misleading about iOS background behavior.
### Implementation note
- Standard keeps the existing cache-first refresh behavior.
- Smart adds a lightweight probe path before deciding whether to download the full data set.
- Background timing remains best-effort under iOS scheduling.
+65
View File
@@ -208,6 +208,25 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
}
}
// MARK: - Station tap action
/// What happens when a station row is tapped in the Stations/Favourites tabs:
/// open Apple Maps directions immediately (historic behaviour, default), or
/// show a detail sheet (name, address, distance, Directions button).
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
case openMap // tap Apple Maps directions straight away
case showDetails // tap detail sheet, Directions button inside
var id: String { rawValue }
var displayName: String {
switch self {
case .openMap: return "Open map"
case .showDetails: return "More info"
}
}
}
// MARK: - Station model
struct FuelStation: Identifiable, Codable, Equatable {
@@ -372,6 +391,8 @@ struct FuelStore {
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970)
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970)
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String station count
static let dataUpdatedKey = "fuelboard.dataUpdated" // String govUK dataset update time
@@ -540,6 +561,22 @@ struct FuelStore {
saveString(style.rawValue, service: priceDisplayStyleKey)
}
// MARK: Station tap action open map vs detail sheet. Stored raw value;
// default open map preserves the historic tap behaviour for existing installs.
static let stationTapActionKey = "fuelboard.stationTapAction"
static func loadStationTapAction() -> StationTapAction {
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
return action
}
return .openMap
}
static func saveStationTapAction(_ action: StationTapAction) {
saveString(action.rawValue, service: stationTapActionKey)
}
/// Superscript digit glyphs for the pounds & pence format's small raised
/// third digit (the forecourt style: "£1.29").
private static let superscriptDigits: [Character] = ["", "¹", "²", "³", "", "", "", "", "", ""]
@@ -904,6 +941,34 @@ struct FuelStore {
saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
}
static func requestWatchRefresh(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey)
}
static func loadWatchRefreshRequest() -> Date? {
if let raw = loadString(service: watchRefreshRequestKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func markWatchRefreshHandled(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: watchRefreshHandledKey)
}
static func loadWatchRefreshHandled() -> Date? {
if let raw = loadString(service: watchRefreshHandledKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static var hasPendingWatchRefreshRequest: Bool {
guard let requested = loadWatchRefreshRequest() else { return false }
guard let handled = loadWatchRefreshHandled() else { return true }
return requested > handled
}
// MARK: Relay metadata shown in Settings About. Written after each
// successful full fetch so the About section reflects the live source.
+122
View File
@@ -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"