67 changed files with 701 additions and 158 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>2</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>FuelBoardWatch</string> <string>FuelBoard</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string> <string>FuelBoard</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
+2 -2
View File
@@ -17,9 +17,9 @@
<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>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>2</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
+12 -12
View File
@@ -406,7 +406,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 = 2; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -472,7 +472,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 = 2; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -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 = 2; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -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 = 2; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -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 = 1; CURRENT_PROJECT_VERSION = 3;
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; MARKETING_VERSION = 1.0.1;
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 = 1; CURRENT_PROJECT_VERSION = 3;
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; MARKETING_VERSION = 1.0.1;
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 = 1; CURRENT_PROJECT_VERSION = 3;
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; MARKETING_VERSION = 1.0.1;
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 = 1; CURRENT_PROJECT_VERSION = 3;
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; MARKETING_VERSION = 1.0.1;
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;
@@ -29,6 +29,138 @@
"idiom": "universal", "idiom": "universal",
"platform": "ios", "platform": "ios",
"size": "1024x1024" "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": { "info": {
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

-1
View File
@@ -6,7 +6,6 @@ import SwiftUI
struct FuelBoardApp: App { struct FuelBoardApp: App {
init() { init() {
WatchSyncManager.shared.activate() WatchSyncManager.shared.activate()
SmartDataRefreshScheduler.register()
#if DEBUG #if DEBUG
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a // QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
// Live Activity with a long station name so the Lock Screen / island // Live Activity with a long station name so the Lock Screen / island
+14 -3
View File
@@ -46,6 +46,11 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) { if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) {
context[FuelStore.favouritesKey] = favourites 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.fuelKey] = FuelStore.loadSelectedFuel().rawValue
context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue
@@ -87,14 +92,20 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
} }
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
handleIncomingRefreshRequest(userInfo) handleIncomingMessage(userInfo)
} }
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
handleIncomingRefreshRequest(message) handleIncomingMessage(message)
}
private nonisolated func handleIncomingMessage(_ payload: [String: Any]) {
if payload["fuelboard.snapshotRequest"] as? String != nil {
Task { @MainActor in
self.pushSnapshot()
}
} }
private nonisolated func handleIncomingRefreshRequest(_ payload: [String: Any]) {
guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String, guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String,
let timestamp = TimeInterval(raw) else { return } let timestamp = TimeInterval(raw) else { return }
let date = Date(timeIntervalSince1970: timestamp) let date = Date(timeIntervalSince1970: timestamp)
@@ -0,0 +1 @@
../../../Shared/DataRefreshMode.swift
@@ -1,9 +1,152 @@
{ {
"images": [ "images": [
{ {
"idiom" : "universal", "filename": "Watch-NotificationCenter-48.png",
"platform" : "watchos", "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" "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": { "info": {
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

+114 -45
View File
@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import Foundation import Foundation
import MapKit
private enum WatchFuelType: String, CaseIterable, Identifiable, Codable { private enum WatchFuelType: String, CaseIterable, Identifiable, Codable {
case e10 case e10
@@ -26,19 +27,13 @@ private enum WatchFuelType: String, CaseIterable, Identifiable, Codable {
var color: Color { var color: Color {
switch self { switch self {
case .e10: return .green case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return .yellow case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return .blue case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
} }
} }
var symbol: String { var symbol: String { "fuelpump.fill" }
switch self {
case .e10: return "fuelpump.fill"
case .e5: return "fuelpump.circle.fill"
case .diesel: return "drop.fill"
}
}
var sortRank: Int { var sortRank: Int {
switch self { switch self {
@@ -108,6 +103,8 @@ private struct WatchFavouriteRow: Identifiable {
let priceText: String let priceText: String
let distanceText: String? let distanceText: String?
let isCheapest: Bool let isCheapest: Bool
let latitude: Double
let longitude: Double
} }
private enum WatchRefreshState { private enum WatchRefreshState {
@@ -115,6 +112,7 @@ private enum WatchRefreshState {
case stale(lastUpdated: String) case stale(lastUpdated: String)
case checking case checking
case requested(lastUpdated: String) case requested(lastUpdated: String)
case checkedNoNewData(lastUpdated: String)
case needsPhone case needsPhone
case noData case noData
@@ -123,7 +121,8 @@ private enum WatchRefreshState {
case .idle(let lastUpdated): return "Updated \(lastUpdated)" case .idle(let lastUpdated): return "Updated \(lastUpdated)"
case .stale(let lastUpdated): return "Cache from \(lastUpdated)" case .stale(let lastUpdated): return "Cache from \(lastUpdated)"
case .checking: return "Checking…" case .checking: return "Checking…"
case .requested(let lastUpdated): return "Requested · \(lastUpdated)" case .requested(let lastUpdated): return "Updated · \(lastUpdated)"
case .checkedNoNewData(let lastUpdated): return "No newer data · \(lastUpdated)"
case .needsPhone: return "Refresh on iPhone" case .needsPhone: return "Refresh on iPhone"
case .noData: return "No cached prices yet" case .noData: return "No cached prices yet"
} }
@@ -135,6 +134,7 @@ private enum WatchRefreshState {
case .stale: return .yellow case .stale: return .yellow
case .checking: return .yellow case .checking: return .yellow
case .requested: return .green case .requested: return .green
case .checkedNoNewData: return .secondary
case .needsPhone: return .orange case .needsPhone: return .orange
case .noData: return .secondary case .noData: return .secondary
} }
@@ -146,6 +146,7 @@ private enum WatchRefreshState {
case .stale: return "exclamationmark.circle" case .stale: return "exclamationmark.circle"
case .checking: return "arrow.triangle.2.circlepath" case .checking: return "arrow.triangle.2.circlepath"
case .requested: return "checkmark.circle" case .requested: return "checkmark.circle"
case .checkedNoNewData: return "clock.badge.exclamationmark"
case .needsPhone: return "iphone" case .needsPhone: return "iphone"
case .noData: return "tray" case .noData: return "tray"
} }
@@ -289,21 +290,41 @@ struct ContentView: View {
@State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit() @State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit()
@State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle() @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 { var body: some View {
Group {
if hasAnyFavourites {
TabView(selection: $fuel) { TabView(selection: $fuel) {
ForEach(WatchFuelType.allCases) { fuelCase in ForEach(displayedFuels) { fuelCase in
fuelPage(for: fuelCase) fuelPage(for: fuelCase)
.tag(fuelCase) .tag(fuelCase)
} }
} }
.tabViewStyle(.verticalPage) .tabViewStyle(.verticalPage)
.onAppear(perform: reloadFromCache) } else {
emptyStateView
}
}
.onAppear {
reloadFromCache()
WatchSyncManager.shared.requestSnapshot()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in .onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in
reloadFromCache() reloadFromCache()
} }
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { if newPhase == .active {
reloadFromCache() reloadFromCache()
WatchSyncManager.shared.requestSnapshot()
} }
} }
} }
@@ -320,8 +341,6 @@ struct ContentView: View {
if let cheapest { if let cheapest {
cheapestCard(for: fuelCase, favourite: cheapest) cheapestCard(for: fuelCase, favourite: cheapest)
} else {
emptyCheapestCard(for: fuelCase)
} }
statusRow statusRow
@@ -343,17 +362,11 @@ struct ContentView: View {
Text("Favourites") Text("Favourites")
.font(.headline) .font(.headline)
if rows.isEmpty {
Text("No favourites for \(fuelCase.displayName.lowercased()) yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(rows) { row in ForEach(rows) { row in
favouriteRow(row, fuel: fuelCase) favouriteRow(row, fuel: fuelCase)
} }
} }
} }
}
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 12) .padding(.vertical, 12)
} }
@@ -375,7 +388,9 @@ struct ContentView: View {
name: favourite.station.name, name: favourite.station.name,
priceText: priceText, priceText: priceText,
distanceText: distanceText, distanceText: distanceText,
isCheapest: favourite.id == cheapest?.id isCheapest: favourite.id == cheapest?.id,
latitude: favourite.station.lat,
longitude: favourite.station.lng
) )
} }
} }
@@ -392,16 +407,55 @@ struct ContentView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(fuel.shortName) }
}
private var emptyStateView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "fuelpump.fill")
.foregroundStyle(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
Text("FuelBoard")
.font(.headline)
Spacer()
}
statusRow
Button {
runCheckNow()
} label: {
HStack {
Image(systemName: refreshState.icon)
Text("Check now")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
VStack(alignment: .leading, spacing: 6) {
Text("No favourites yet")
.font(.headline)
Text("Add favourites on iPhone and they’ll appear here.")
.font(.caption) .font(.caption)
.fontWeight(.bold) .foregroundStyle(.secondary)
.padding(.horizontal, 8) }
.padding(.vertical, 4) .padding(10)
.background(fuel.color.opacity(0.18), in: Capsule()) .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 { 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) { VStack(alignment: .leading, spacing: 6) {
Text("Cheapest favourite") Text("Cheapest favourite")
.font(.caption2) .font(.caption2)
@@ -423,22 +477,9 @@ struct ContentView: View {
} }
.padding(10) .padding(10)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
} }
.buttonStyle(.plain)
private func emptyCheapestCard(for fuel: WatchFuelType) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Cheapest favourite")
.font(.caption2)
.foregroundStyle(.secondary)
Text("No favourite saved")
.font(.headline)
Text("Add favourites on iPhone to see them here.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
} }
@@ -450,6 +491,9 @@ struct ContentView: View {
} }
private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View { private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View {
Button {
openMaps(latitude: favourite.latitude, longitude: favourite.longitude)
} label: {
HStack(spacing: 8) { HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) { HStack(spacing: 4) {
@@ -478,6 +522,9 @@ struct ContentView: View {
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 8) .padding(.vertical, 8)
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
@@ -496,7 +543,9 @@ struct ContentView: View {
} }
private func statusFromCache() -> WatchRefreshState { private func statusFromCache() -> WatchRefreshState {
guard !stations.isEmpty else { return .noData } 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") } guard let lastRefresh = WatchFuelCache.loadLastRefresh() else { return .stale(lastUpdated: "earlier") }
let formatted = relativeRefreshText(for: lastRefresh) let formatted = relativeRefreshText(for: lastRefresh)
return WatchFuelCache.isCacheFresh(lastRefresh: lastRefresh) return WatchFuelCache.isCacheFresh(lastRefresh: lastRefresh)
@@ -506,6 +555,7 @@ struct ContentView: View {
private func runCheckNow() { private func runCheckNow() {
refreshState = .checking refreshState = .checking
let previousLastRefresh = WatchFuelCache.loadLastRefresh()
let requestDate = Date() let requestDate = Date()
WatchFuelCache.requestRefresh(requestDate) WatchFuelCache.requestRefresh(requestDate)
WatchSyncManager.shared.requestRefresh(at: requestDate) WatchSyncManager.shared.requestRefresh(at: requestDate)
@@ -515,8 +565,16 @@ struct ContentView: View {
if WatchFuelCache.wasRefreshHandled(since: requestDate) { if WatchFuelCache.wasRefreshHandled(since: requestDate) {
await MainActor.run { await MainActor.run {
reloadFromCache() reloadFromCache()
let label = WatchFuelCache.loadLastRefresh().map(relativeRefreshText(for:)) ?? "just now" let currentLastRefresh = WatchFuelCache.loadLastRefresh()
refreshState = .requested(lastUpdated: label) 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 return
} }
@@ -533,6 +591,17 @@ struct ContentView: View {
formatter.unitsStyle = .short formatter.unitsStyle = .short
return formatter.localizedString(for: date, relativeTo: Date()) 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 { #Preview {
@@ -9,6 +9,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager() static let shared = WatchSyncManager()
private let suiteName = "group.com.apt.fuelboard" private let suiteName = "group.com.apt.fuelboard"
private let snapshotRequestKey = "fuelboard.snapshotRequest"
private override init() { private override init() {
super.init() super.init()
@@ -36,6 +37,18 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
} }
} }
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? { private func sharedDefaults() -> UserDefaults? {
UserDefaults(suiteName: suiteName) UserDefaults(suiteName: suiteName)
} }
@@ -71,6 +84,10 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error { if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)") print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return
}
if activationState == .activated {
requestSnapshot()
} }
} }
@@ -1,8 +1,135 @@
{ {
"images": [ "images": [
{ {
"idiom" : "universal", "filename": "Watch-NotificationCenter-48.png",
"platform" : "watchos", "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" "size": "1024x1024"
} }
], ],
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

@@ -147,9 +147,9 @@ private struct FuelBoardLiveActivityView: View {
} }
/// Minimal strip for small space (CarPlay small / Watch smart stack): /// Minimal strip for small space (CarPlay small / Watch smart stack):
/// fuel type + bold price on one line, station · distance below. /// fuel type + distance + bold price on one line, station name below.
/// Deliberately no app name and no "Tap for directions" — CarPlay is /// Deliberately no app name and no full address — the watch-sized slot is
/// display-only, and the user's asks here are just fuel + price + distance. /// too tight, so the second line is reserved for the station name only.
private var compactBody: some View { private var compactBody: some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) { HStack(spacing: 5) {
@@ -157,16 +157,21 @@ private struct FuelBoardLiveActivityView: View {
.font(.caption.bold()) .font(.caption.bold())
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 4) Spacer(minLength: 4)
Text(context.state.distanceText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
FuelStore.priceTextAttributed(context.state.pricePence, FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle, style: context.state.priceDisplayStyle,
size: 15, weight: .bold) size: 15, weight: .bold)
.lineLimit(1) .lineLimit(1)
} }
Text("\(context.state.stationName) · \(context.state.distanceText)") Text(context.state.stationName)
.font(.system(size: 9)) .font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.75)
.truncationMode(.tail) .truncationMode(.tail)
} }
.padding(8) .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.