Compare commits
10
Commits
1299b4a004
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2921271be7 | ||
|
|
7cb23e09c2 | ||
|
|
7dbd48786f | ||
|
|
3a1ef0bd59 | ||
|
|
551a40ddf0 | ||
|
|
3c83c9b518 | ||
|
|
6c8fd8b0de | ||
|
|
44cbd3b1e4 | ||
|
|
20fe251b8e | ||
|
|
465e6cd4d4 |
+3
-1
@@ -31,7 +31,9 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
|||||||
provisioning profile (or Xcode automatic signing once the account is in
|
provisioning profile (or Xcode automatic signing once the account is in
|
||||||
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
||||||
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
||||||
collects location. A one-page GitHub Pages privacy policy is fine.
|
collects location. Policy text is now in `PRIVACY.md`; publish it via the
|
||||||
|
FuelBoard marketing site or GitHub Pages and paste that public URL into App
|
||||||
|
Store Connect.
|
||||||
|
|
||||||
## Required tasks — owner [ME] (technical, do after account exists)
|
## Required tasks — owner [ME] (technical, do after account exists)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 724 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 686 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
@@ -17,7 +17,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.1</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -37,17 +37,6 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
|
||||||
isa = PBXCopyFilesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
dstPath = "";
|
|
||||||
dstSubfolderSpec = 13;
|
|
||||||
files = (
|
|
||||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
|
||||||
);
|
|
||||||
name = "Embed App Extensions";
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -59,6 +48,17 @@
|
|||||||
name = "Embed Watch Content";
|
name = "Embed Watch Content";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
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;
|
||||||
|
};
|
||||||
BB0000000000000000000006 /* Embed App Extensions */ = {
|
BB0000000000000000000006 /* Embed App Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -165,9 +165,9 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
ADCE5B4B3033A329008C131C /* Sources */,
|
ADCE5B4B3033A329008C131C /* Sources */,
|
||||||
ADCE5B4C3033A329008C131C /* Frameworks */,
|
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||||
@@ -405,8 +405,9 @@
|
|||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -418,14 +419,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -471,8 +471,9 @@
|
|||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -484,14 +485,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -537,7 +537,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -549,13 +549,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -602,7 +602,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -614,13 +614,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -699,7 +699,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -707,7 +707,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -722,7 +722,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -730,7 +730,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -745,7 +745,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -754,7 +754,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -770,7 +770,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 20;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -779,7 +779,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -792,7 +792,7 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
ADCE5B573033A32B008C131C /* Debug */,
|
ADCE5B573033A32B008C131C /* Debug */,
|
||||||
|
|||||||
+47
-10
@@ -235,6 +235,30 @@ struct ContentView: View {
|
|||||||
.sheet(item: $monitor.pendingStationMap) { request in
|
.sheet(item: $monitor.pendingStationMap) { request in
|
||||||
StationMapView(request: request)
|
StationMapView(request: request)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $monitor.pendingDetailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
||||||
|
let info = note.userInfo ?? [:]
|
||||||
|
if let id = info["stationID"] as? String,
|
||||||
|
let station = stations.first(where: { $0.id == id }) {
|
||||||
|
monitor.pendingDetailStation = station
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Station left the cache — fall back to the map sheet so
|
||||||
|
// the tap still shows something.
|
||||||
|
if let lat = info["stationLat"] as? Double,
|
||||||
|
let lng = info["stationLng"] as? Double {
|
||||||
|
let name = info["stationName"] as? String ?? "Station"
|
||||||
|
monitor.pendingStationMap = StationMapRequest(
|
||||||
|
name: name, latitude: lat, longitude: lng
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -957,6 +981,21 @@ struct StationRow: View {
|
|||||||
return String(format: "%+.1fp", delta)
|
return String(format: "%+.1fp", delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleTap() {
|
||||||
|
switch tapAction {
|
||||||
|
case .openMap:
|
||||||
|
if let url = station.mapsDirectionsURL {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
case .showDetails:
|
||||||
|
showDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showDetails() {
|
||||||
|
onShowDetails(station)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// LEFT — round brand logo (or generic fuel pump fallback)
|
// LEFT — round brand logo (or generic fuel pump fallback)
|
||||||
@@ -993,6 +1032,12 @@ struct StationRow: View {
|
|||||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
.background(Capsule().fill(.blue.opacity(0.15)))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
}
|
}
|
||||||
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
|
Image(systemName: notice.symbolName)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel(notice.title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text("\(station.address), \(station.postcode)")
|
Text("\(station.address), \(station.postcode)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -1035,16 +1080,8 @@ struct StationRow: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture(perform: handleTap)
|
||||||
switch tapAction {
|
.onLongPressGesture(perform: showDetails)
|
||||||
case .openMap:
|
|
||||||
if let url = station.mapsDirectionsURL {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
case .showDetails:
|
|
||||||
onShowDetails(station)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,40 @@ struct FuelBoardApp: App {
|
|||||||
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
||||||
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
||||||
/// Apple Maps without ever launching this app.
|
/// Apple Maps without ever launching this app.
|
||||||
|
/// A `fuelboard://station?stationID=…&lat=…&lng=…&name=…` link (from a
|
||||||
|
/// widget or Live Activity tap when Settings → Tap station = More info)
|
||||||
|
/// posts a notification that ContentView observes to present the detail
|
||||||
|
/// sheet (falling back to the map sheet when the station has left
|
||||||
|
/// the cache).
|
||||||
private func handleOpenURL(_ url: URL) {
|
private func handleOpenURL(_ url: URL) {
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
let items = components.queryItems
|
else { return }
|
||||||
|
|
||||||
|
if components.scheme == "fuelboard",
|
||||||
|
components.host == "station" {
|
||||||
|
let items = components.queryItems ?? []
|
||||||
|
var userInfo: [String: Any] = [:]
|
||||||
|
if let id = items.first(where: { $0.name == "stationID" })?.value {
|
||||||
|
userInfo["stationID"] = id
|
||||||
|
}
|
||||||
|
if let lat = items.first(where: { $0.name == "lat" })?.value.flatMap(Double.init) {
|
||||||
|
userInfo["stationLat"] = lat
|
||||||
|
}
|
||||||
|
if let lng = items.first(where: { $0.name == "lng" })?.value.flatMap(Double.init) {
|
||||||
|
userInfo["stationLng"] = lng
|
||||||
|
}
|
||||||
|
if let name = items.first(where: { $0.name == "name" })?.value {
|
||||||
|
userInfo["stationName"] = name
|
||||||
|
}
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .fuelBoardShowStationDetail,
|
||||||
|
object: nil,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let items = components.queryItems
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
@Published var lastAlert: String?
|
@Published var lastAlert: String?
|
||||||
@Published private(set) var lastTestResult: String?
|
@Published private(set) var lastTestResult: String?
|
||||||
@Published var pendingStationMap: StationMapRequest?
|
@Published var pendingStationMap: StationMapRequest?
|
||||||
|
/// Station tapped via notification when Settings → Tap station = More info.
|
||||||
|
/// ContentView presents this as a StationDetailView sheet.
|
||||||
|
@Published var pendingDetailStation: FuelStation?
|
||||||
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
||||||
/// every location/stations change via `refreshDebugStatus()`.
|
/// every location/stations change via `refreshDebugStatus()`.
|
||||||
@Published private(set) var debugStatus: DebugLocationStatus?
|
@Published private(set) var debugStatus: DebugLocationStatus?
|
||||||
@@ -791,9 +794,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tapping a notification opens Apple Maps directions to the station from
|
/// Tapping a notification honours Settings → Tap station: Open map opens
|
||||||
/// the user's current location. If Apple Maps can't be opened (e.g. inside
|
/// Apple Maps directions to the station from the user's current location;
|
||||||
/// LiveContainer), an in-app map sheet with directions is shown instead.
|
/// More info opens the station's detail sheet in the app instead. If Apple
|
||||||
|
/// Maps can't be opened (e.g. inside LiveContainer), an in-app map sheet
|
||||||
|
/// with directions is shown instead.
|
||||||
///
|
///
|
||||||
/// When the notification carried tie-choice action buttons, the tapped
|
/// When the notification carried tie-choice action buttons, the tapped
|
||||||
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
||||||
@@ -818,9 +823,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
let tieName = ties[index]["name"] as? String,
|
let tieName = ties[index]["name"] as? String,
|
||||||
let tieLat = ties[index]["lat"] as? Double,
|
let tieLat = ties[index]["lat"] as? Double,
|
||||||
let tieLng = ties[index]["lng"] as? Double {
|
let tieLng = ties[index]["lng"] as? Double {
|
||||||
openDirections(to: tieName, latitude: tieLat, longitude: tieLng)
|
routeNotificationTap(name: tieName, latitude: tieLat, longitude: tieLng)
|
||||||
} else if !isTie, let name, let lat, let lng {
|
} else if !isTie, let name, let lat, let lng {
|
||||||
openDirections(to: name, latitude: lat, longitude: lng)
|
routeNotificationTap(name: name, latitude: lat, longitude: lng)
|
||||||
}
|
}
|
||||||
// Tie body tap: intentionally no directions — the user must pick
|
// Tie body tap: intentionally no directions — the user must pick
|
||||||
// an option button to choose a station.
|
// an option button to choose a station.
|
||||||
@@ -828,6 +833,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes a resolved notification tap through the user's Tap station
|
||||||
|
/// preference: Open map → Apple Maps; More info → in-app detail sheet
|
||||||
|
/// (falling back to the map sheet when the station isn't in cache).
|
||||||
|
@MainActor
|
||||||
|
private func routeNotificationTap(name: String, latitude: Double, longitude: Double) {
|
||||||
|
guard FuelStore.loadStationTapAction() == .showDetails else {
|
||||||
|
openDirections(to: name, latitude: latitude, longitude: longitude)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let station = stations.first(where: { $0.lat == latitude && $0.lng == longitude })
|
||||||
|
?? stations.first(where: { $0.name == name }) {
|
||||||
|
pendingDetailStation = station
|
||||||
|
} else {
|
||||||
|
// Station not in cache (e.g. data refreshed since the alert fired)
|
||||||
|
// — fall back to the map sheet so the tap still shows something.
|
||||||
|
pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens Apple Maps with driving directions to the station; falls back to
|
/// Opens Apple Maps with driving directions to the station; falls back to
|
||||||
/// the in-app map sheet when the hand-off fails.
|
/// the in-app map sheet when the hand-off fails.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -721,7 +721,7 @@ final class TipStore: ObservableObject {
|
|||||||
// (or restored transactions) are picked up.
|
// (or restored transactions) are picked up.
|
||||||
do {
|
do {
|
||||||
let fetched = try await Product.products(for: Self.tiers.map(\.id))
|
let fetched = try await Product.products(for: Self.tiers.map(\.id))
|
||||||
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
merge(fetched)
|
||||||
} catch {
|
} catch {
|
||||||
// No products yet (sideloaded build) — the buttons still show the
|
// No products yet (sideloaded build) — the buttons still show the
|
||||||
// intended prices and report purchase attempts gracefully.
|
// intended prices and report purchase attempts gracefully.
|
||||||
@@ -729,24 +729,37 @@ final class TipStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func merge(_ fetched: [Product]) {
|
||||||
|
products.merge(Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })) { _, new in new }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func product(for tier: TipTier) async throws -> Product? {
|
||||||
|
if let product = products[tier.id] { return product }
|
||||||
|
|
||||||
|
// TestFlight/App Store Connect product availability can lag the Settings
|
||||||
|
// onAppear load. Re-query the tapped tier before falling back so a newly
|
||||||
|
// approved IAP opens the system purchase sheet instead of showing stale
|
||||||
|
// "not available" copy.
|
||||||
|
let fetched = try await Product.products(for: [tier.id])
|
||||||
|
merge(fetched)
|
||||||
|
return fetched.first { $0.id == tier.id }
|
||||||
|
}
|
||||||
|
|
||||||
func purchase(_ tier: TipTier) async {
|
func purchase(_ tier: TipTier) async {
|
||||||
guard !purchaseInProgress else { return }
|
guard !purchaseInProgress else { return }
|
||||||
purchaseInProgress = true
|
purchaseInProgress = true
|
||||||
defer { purchaseInProgress = false }
|
defer { purchaseInProgress = false }
|
||||||
|
|
||||||
// If the product hasn't loaded (e.g. not configured in App Store
|
|
||||||
// Connect yet), still allow the attempt so the user sees a clear
|
|
||||||
// outcome rather than a dead button.
|
|
||||||
guard let product = products[tier.id] else {
|
|
||||||
showOutcome(
|
|
||||||
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
|
|
||||||
icon: "info.circle.fill",
|
|
||||||
tint: Color(.secondaryLabel)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
guard let product = try await product(for: tier) else {
|
||||||
|
showOutcome(
|
||||||
|
NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""),
|
||||||
|
icon: "info.circle.fill",
|
||||||
|
tint: Color(.secondaryLabel)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let result = try await product.purchase()
|
let result = try await product.purchase()
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let verification):
|
case .success(let verification):
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import MapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
|
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
|
||||||
/// Shows the station name, address, distance from the current location, and a
|
/// Shows a small map preview, the station name, address, distance from the
|
||||||
/// Directions button that opens Apple Maps — the same destination a direct tap
|
/// current location, and a Directions button that opens Apple Maps — the same
|
||||||
/// would have opened.
|
/// destination a direct tap would have opened.
|
||||||
struct StationDetailView: View {
|
struct StationDetailView: View {
|
||||||
let station: FuelStation
|
let station: FuelStation
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var showingReportIssueConfirmation = false
|
||||||
|
|
||||||
|
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||||
|
|
||||||
|
private var stationCoordinate: CLLocationCoordinate2D {
|
||||||
|
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
|
Section {
|
||||||
|
Map(position: .constant(.region(MKCoordinateRegion(
|
||||||
|
center: stationCoordinate,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
|
||||||
|
)))) {
|
||||||
|
Marker(station.name, coordinate: stationCoordinate)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.frame(height: 180)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
LabeledContent("Name", value: station.name)
|
LabeledContent("Name", value: station.name)
|
||||||
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
|
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
|
||||||
@@ -24,6 +45,14 @@ struct StationDetailView: View {
|
|||||||
LabeledContent("Distance", value: distanceUnit.format(km))
|
LabeledContent("Distance", value: distanceUnit.format(km))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
|
Label(notice.title, systemImage: notice.symbolName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
@@ -33,10 +62,23 @@ struct StationDetailView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
||||||
}
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showingReportIssueConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(station.name)
|
.navigationTitle(station.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Report") {
|
||||||
|
UIApplication.shared.open(reportIssueURL)
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Done") { dismiss() }
|
Button("Done") { dismiss() }
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Presentation-only notices derived from existing station metadata.
|
||||||
|
///
|
||||||
|
/// These do not alter `FuelStation`, persistence, filtering, sorting, or
|
||||||
|
/// navigation. They are intentionally small and reusable so additional
|
||||||
|
/// informational station notices can be added later from the UI layer.
|
||||||
|
enum StationNotice: Identifiable, Equatable {
|
||||||
|
case membershipRequired
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "membershipRequired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "Membership required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "creditcard.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func notices(for station: FuelStation) -> [StationNotice] {
|
||||||
|
isCostco(station) ? [.membershipRequired] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isCostco(_ station: FuelStation) -> Bool {
|
||||||
|
containsCostcoToken(station.brand) || containsCostcoToken(station.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func containsCostcoToken(_ value: String) -> Bool {
|
||||||
|
let tokens = value.uppercased().split { !$0.isLetter && !$0.isNumber }
|
||||||
|
return tokens.contains("COSTCO")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import WatchConnectivity
|
|||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||||
|
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -147,6 +147,12 @@
|
|||||||
"You" = "You";
|
"You" = "You";
|
||||||
"Done" = "Done";
|
"Done" = "Done";
|
||||||
"Directions" = "Directions";
|
"Directions" = "Directions";
|
||||||
|
"Report issue" = "Report issue";
|
||||||
|
"Membership required" = "Membership required";
|
||||||
|
"Report an error?" = "Report an error?";
|
||||||
|
"Cancel" = "Cancel";
|
||||||
|
"Report" = "Report";
|
||||||
|
"Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser." = "Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.";
|
||||||
|
|
||||||
/* Station row */
|
/* Station row */
|
||||||
"best" = "best";
|
"best" = "best";
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
//
|
//
|
||||||
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
||||||
// Shows the cheapest station for the pinned fuel within the app's chosen
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
// radius. Tapping honours Settings → Tap station: Open map opens Apple Maps
|
||||||
|
// directions to that station, More info opens the detail sheet in the app.
|
||||||
//
|
//
|
||||||
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
||||||
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
||||||
@@ -31,22 +32,32 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
FuelBoardLiveActivityCompactView(context: context)
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
|
FuelBoardLiveActivityCompactView(context: context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
FuelBoardLiveActivityStationView(context: context)
|
FuelBoardLiveActivityStationView(context: context)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "fuelpump.fill")
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
.foregroundStyle(context.state.fuel.tintColor)
|
Image(systemName: "fuelpump.fill")
|
||||||
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
|
}
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
|
}
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Text(context.state.priceText)
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
.font(.caption2.bold().monospacedDigit())
|
Text(context.state.priceText)
|
||||||
|
.font(.caption2.bold().monospacedDigit())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.supplementalActivityFamilies([.small])
|
.supplementalActivityFamilies([.small])
|
||||||
@@ -84,7 +95,7 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
@State private var slotWidth: CGFloat = 400
|
@State private var slotWidth: CGFloat = 400
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: tapDestination) {
|
||||||
Group {
|
Group {
|
||||||
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||||
// full Lock Screen width (>= threshold) → rich card, no matter
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||||
@@ -138,7 +149,8 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
style: context.state.priceDisplayStyle,
|
style: context.state.priceDisplayStyle,
|
||||||
size: 22, weight: .bold)
|
size: 22, weight: .bold)
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -210,9 +222,26 @@ private struct FuelBoardLiveActivityStationView: View {
|
|||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
.font(.caption)
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
.lineLimit(1)
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap routing shared by the Lock Screen card and the island: honours
|
||||||
|
/// Settings → Tap station (Open map → Maps, More info → in-app detail).
|
||||||
|
enum FuelBoardLiveActivityTap {
|
||||||
|
static func tapDestination(for state: FuelBoardLiveActivityAttributes.ContentState) -> URL {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails ? state.detailURL : state.mapsURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FuelBoardLiveActivityView {
|
||||||
|
/// Lock Screen tap honours Settings → Tap station.
|
||||||
|
var tapDestination: URL {
|
||||||
|
FuelBoardLiveActivityTap.tapDestination(for: context.state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,4 +264,21 @@ extension FuelBoardLiveActivityAttributes.ContentState {
|
|||||||
var mapsURL: URL {
|
var mapsURL: URL {
|
||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to the station's detail sheet, used when
|
||||||
|
/// Settings → Tap station = More info. Carries coordinate/name fallback
|
||||||
|
/// so the app can still show the map sheet when the station has left
|
||||||
|
/// the cache.
|
||||||
|
var detailURL: URL {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: stationID),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: stationName),
|
||||||
|
]
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
# FuelBoard Privacy Policy
|
||||||
|
|
||||||
|
Effective date: 2026-09-19
|
||||||
|
|
||||||
|
FuelBoard helps users in the UK find nearby petrol, diesel, and premium fuel prices, save favourite stations, view widgets and Live Activities, and optionally receive location-based alerts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- FuelBoard does not track you across apps or websites.
|
||||||
|
- FuelBoard does not sell personal data.
|
||||||
|
- FuelBoard does not use advertising SDKs or analytics SDKs.
|
||||||
|
- FuelBoard uses location only to show nearby stations, calculate distances, power widgets, and provide optional fuel-price alerts.
|
||||||
|
- Most app preferences and cached data stay on your device.
|
||||||
|
- Fuel price data comes from public UK fuel-price datasets mirrored through GitHub raw content and a bundled offline snapshot.
|
||||||
|
|
||||||
|
## Data FuelBoard uses
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
FuelBoard may use your device location to:
|
||||||
|
|
||||||
|
- sort stations by distance;
|
||||||
|
- show stations within your chosen search radius;
|
||||||
|
- provide directions to a selected station;
|
||||||
|
- update home-screen widgets with nearby fuel prices;
|
||||||
|
- run optional geofenced alerts when you enable the Alerts feature.
|
||||||
|
|
||||||
|
On first launch, FuelBoard asks for When In Use location access. If you enable optional Alerts, iOS may ask you to allow Always location access so alerts can work while the app is not open.
|
||||||
|
|
||||||
|
FuelBoard stores your last known location on your device so it can refresh nearby results and widgets. FuelBoard does not sell or share your location for advertising.
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
FuelBoard may request notification permission so it can send optional fuel-price alerts. Notifications are used only for FuelBoard features such as station alerts and test alerts.
|
||||||
|
|
||||||
|
### App preferences and favourites
|
||||||
|
|
||||||
|
FuelBoard stores settings such as favourite stations, fuel type, distance unit, price display style, alert settings, widget configuration, and cached public fuel-price data on your device using Apple storage APIs such as UserDefaults and Keychain.
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
FuelBoard may send limited diagnostic beacons when developer relay fallback is enabled from the hidden Debug section. These requests are used to diagnose data-source and widget-refresh behaviour during testing. They are not used for advertising or tracking.
|
||||||
|
|
||||||
|
The consumer data path uses public HTTPS GitHub raw content and a bundled offline snapshot. Normal consumer use does not require a private local relay.
|
||||||
|
|
||||||
|
### Purchases
|
||||||
|
|
||||||
|
FuelBoard may offer optional consumable tip purchases. Payments are handled by Apple through StoreKit. FuelBoard does not receive or store your payment card details.
|
||||||
|
|
||||||
|
## Data FuelBoard does not collect
|
||||||
|
|
||||||
|
FuelBoard does not collect or access:
|
||||||
|
|
||||||
|
- contacts;
|
||||||
|
- photos or videos;
|
||||||
|
- camera or microphone content;
|
||||||
|
- browsing history;
|
||||||
|
- advertising identifiers for tracking;
|
||||||
|
- payment card details.
|
||||||
|
|
||||||
|
FuelBoard does not use App Tracking Transparency because it does not track users across apps or websites.
|
||||||
|
|
||||||
|
## Third-party services
|
||||||
|
|
||||||
|
FuelBoard may contact these services as part of normal app functionality:
|
||||||
|
|
||||||
|
- GitHub raw content, to download the public FuelBoard fuel-price mirror;
|
||||||
|
- Apple Maps, when you choose to open directions;
|
||||||
|
- Apple StoreKit, when optional tip purchases are available;
|
||||||
|
- Apple notification, widget, Live Activity, location, and keychain services provided by iOS.
|
||||||
|
|
||||||
|
Fuel price data is based on the UK Fuel Finder dataset made available under the Open Government Licence.
|
||||||
|
|
||||||
|
## Data retention
|
||||||
|
|
||||||
|
FuelBoard keeps local settings, favourites, cached station data, and cached price-history data on your device until you delete them, change the relevant settings, or delete the app. iOS may also remove cached app data according to system behaviour.
|
||||||
|
|
||||||
|
## Your choices
|
||||||
|
|
||||||
|
You can control FuelBoard's access at any time in iOS Settings:
|
||||||
|
|
||||||
|
- Location: Settings > Privacy & Security > Location Services > FuelBoard
|
||||||
|
- Notifications: Settings > Notifications > FuelBoard
|
||||||
|
- Widgets and Live Activities: managed through iOS widget and Live Activity controls
|
||||||
|
|
||||||
|
You can also delete FuelBoard to remove app data stored on the device, subject to normal iOS and iCloud/device-backup behaviour.
|
||||||
|
|
||||||
|
## Children's privacy
|
||||||
|
|
||||||
|
FuelBoard is not directed at children and does not knowingly collect personal information from children.
|
||||||
|
|
||||||
|
## Changes to this policy
|
||||||
|
|
||||||
|
This policy may be updated as FuelBoard changes. The effective date above will be updated when the policy changes.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For privacy questions, use FuelBoard's listed App Store support contact or the public project issue tracker associated with the app.
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
||||||
// Everything dynamic lives in ContentState so updates — including a fuel
|
// Everything dynamic lives in ContentState so updates — including a fuel
|
||||||
// change — apply to the running activity without needing to restart it.
|
// change — apply to the running activity without needing to restart it.
|
||||||
// Tapping the activity opens Apple Maps driving directions (maps://).
|
// Tapping the activity honours Settings → Tap station: Open map opens Apple
|
||||||
|
// Maps driving directions (maps://), More info opens the detail sheet in
|
||||||
|
// the app (fuelboard://station).
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -93,13 +93,14 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
|
.modifier(WidgetURLModifier(url: isWidget ? station.tapDestinationURL : nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many station rows each family can fit: medium widgets are short
|
/// How many station rows each family can fit: medium widgets are short
|
||||||
@@ -129,7 +130,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
}
|
}
|
||||||
ForEach(entry.stations.prefix(maxRows)) { station in
|
ForEach(entry.stations.prefix(maxRows)) { station in
|
||||||
if isWidget {
|
if isWidget {
|
||||||
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
|
Link(destination: station.tapDestinationURL ?? URL(string: "maps://")!) {
|
||||||
row(station, cheapest: cheapest)
|
row(station, cheapest: cheapest)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
@@ -271,6 +271,30 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to this station's More info sheet. Carries the
|
||||||
|
/// stationID plus coordinate/name fallback so the app can still show the
|
||||||
|
/// map sheet when the station has left the cache.
|
||||||
|
var detailDeepLinkURL: URL? {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: id),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: name),
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget/Live Activity tap destination honouring Settings → Tap station:
|
||||||
|
/// Open map → Apple Maps directions; More info → in-app detail deep link.
|
||||||
|
var tapDestinationURL: URL? {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? detailDeepLinkURL
|
||||||
|
: widgetDirectionsURL
|
||||||
|
}
|
||||||
|
|
||||||
/// Name of the bundled brand logo asset, or nil if unknown.
|
/// Name of the bundled brand logo asset, or nil if unknown.
|
||||||
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
||||||
var brandImageName: String? {
|
var brandImageName: String? {
|
||||||
|
|||||||
Reference in New Issue
Block a user