73 changed files with 1106 additions and 157 deletions
+6 -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>
@@ -41,8 +41,13 @@
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string> <string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.apt.fuelboard.smart-refresh</string>
</array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string>
<string>location</string> <string>location</string>
</array> </array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
+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>
+16 -12
View File
@@ -406,7 +406,8 @@
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;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -471,7 +472,8 @@
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;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -535,7 +537,8 @@
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;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -599,7 +602,8 @@
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;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -695,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";
@@ -703,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;
@@ -718,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";
@@ -726,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;
@@ -741,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";
@@ -750,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;
@@ -766,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";
@@ -775,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;
@@ -1,38 +1,170 @@
{ {
"images" : [ "images": [
{ {
"filename" : "AppIcon.png", "filename": "AppIcon.png",
"idiom" : "universal", "idiom": "universal",
"platform" : "ios", "platform": "ios",
"size" : "1024x1024" "size": "1024x1024"
}, },
{ {
"appearances" : [ "appearances": [
{ {
"appearance" : "luminosity", "appearance": "luminosity",
"value" : "dark" "value": "dark"
} }
], ],
"filename" : "AppIcon-dark.png", "filename": "AppIcon-dark.png",
"idiom" : "universal", "idiom": "universal",
"platform" : "ios", "platform": "ios",
"size" : "1024x1024" "size": "1024x1024"
}, },
{ {
"appearances" : [ "appearances": [
{ {
"appearance" : "luminosity", "appearance": "luminosity",
"value" : "tinted" "value": "tinted"
} }
], ],
"filename" : "AppIcon-tinted.png", "filename": "AppIcon-tinted.png",
"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": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+8
View File
@@ -16,6 +16,7 @@ struct ContentView: View {
@State private var stations: [FuelStation] = FuelStore.loadStations() @State private var stations: [FuelStation] = FuelStore.loadStations()
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel() @State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
@State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit() @State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@@ -275,6 +276,7 @@ struct ContentView: View {
} }
if !favs.isEmpty { if !favs.isEmpty {
FuelStore.saveFavourites(favs) FuelStore.saveFavourites(favs)
WatchSyncManager.shared.pushSnapshot()
} }
} }
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count { if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
@@ -396,6 +398,7 @@ struct ContentView: View {
if let newLocation { if let newLocation {
location = newLocation location = newLocation
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng) FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
// Geofences follow the user's position, but the station list is // Geofences follow the user's position, but the station list is
// NOT re-fetched on every movement (cached, twice-a-day policy). // NOT re-fetched on every movement (cached, twice-a-day policy).
@@ -414,6 +417,7 @@ struct ContentView: View {
.onChange(of: selectedFuel) { _, _ in .onChange(of: selectedFuel) { _, _ in
// No re-fetch needed one response carries E5/E10/DIESEL prices. // No re-fetch needed one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
} }
.onChange(of: stationLimit) { _, newValue in .onChange(of: stationLimit) { _, newValue in
// Distance filter is LOCAL math now the cache holds the full-UK // Distance filter is LOCAL math now the cache holds the full-UK
@@ -790,6 +794,7 @@ struct ContentView: View {
tipStore: tipStore, tipStore: tipStore,
distanceUnit: $distanceUnit, distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle, priceDisplayStyle: $priceDisplayStyle,
dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel, alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius, alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult, testAlertResult: monitor.lastTestResult,
@@ -818,6 +823,7 @@ struct ContentView: View {
favourites.append(key) favourites.append(key)
} }
FuelStore.saveFavourites(favourites) FuelStore.saveFavourites(favourites)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
@@ -829,6 +835,7 @@ struct ContentView: View {
private func reorderFavourites(_ newOrder: [FavouriteEntry]) { private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
favourites = newOrder favourites = newOrder
FuelStore.saveFavourites(favourites) FuelStore.saveFavourites(favourites)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites, monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
@@ -860,6 +867,7 @@ struct ContentView: View {
if force, FuelStore.hasPendingWatchRefreshRequest { if force, FuelStore.hasPendingWatchRefreshRequest {
FuelStore.markWatchRefreshHandled() FuelStore.markWatchRefreshHandled()
} }
WatchSyncManager.shared.pushSnapshot()
// Persist envelope metadata (source, station count, GOV.UK // Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings About section the // dataset update time) for the Settings About section the
// live chain records whichever leg served the fetch. // live chain records whichever leg served the fetch.
+8
View File
@@ -1,9 +1,11 @@
import ActivityKit import ActivityKit
import BackgroundTasks
import SwiftUI import SwiftUI
@main @main
struct FuelBoardApp: App { struct FuelBoardApp: App {
init() { init() {
WatchSyncManager.shared.activate()
#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
@@ -21,6 +23,12 @@ struct FuelBoardApp: App {
WindowGroup { WindowGroup {
ContentView() ContentView()
.onOpenURL(perform: handleOpenURL) .onOpenURL(perform: handleOpenURL)
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
WatchSyncManager.shared.pushSnapshot()
}
}
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
await SmartDataRefreshCoordinator.runBackgroundProbe()
} }
} }
+27
View File
@@ -18,6 +18,7 @@ struct SettingsView: View {
@ObservedObject var tipStore: TipStore @ObservedObject var tipStore: TipStore
@Binding var distanceUnit: DistanceUnit @Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle @Binding var priceDisplayStyle: PriceDisplayStyle
@Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say. /// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10 var alertsFuel: FuelType = .e10
@@ -103,6 +104,7 @@ struct SettingsView: View {
.onChange(of: distanceUnit) { _, newValue in .onChange(of: distanceUnit) { _, newValue in
FuelStore.saveDistanceUnit(newValue) FuelStore.saveDistanceUnit(newValue)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
} }
Picker("Price display", selection: $priceDisplayStyle) { Picker("Price display", selection: $priceDisplayStyle) {
ForEach(PriceDisplayStyle.allCases) { style in ForEach(PriceDisplayStyle.allCases) { style in
@@ -113,6 +115,7 @@ struct SettingsView: View {
.onChange(of: priceDisplayStyle) { _, newValue in .onChange(of: priceDisplayStyle) { _, newValue in
FuelStore.savePriceDisplayStyle(newValue) FuelStore.savePriceDisplayStyle(newValue)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
} }
} header: { } header: {
Text("Units") Text("Units")
@@ -120,6 +123,30 @@ struct SettingsView: View {
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).") Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
} }
Section {
Picker("Data checking", selection: $dataRefreshMode) {
ForEach(DataRefreshMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.onChange(of: dataRefreshMode) { _, newValue in
FuelStore.saveDataRefreshMode(newValue)
SmartDataRefreshScheduler.scheduleNextIfNeeded()
WatchSyncManager.shared.pushSnapshot()
}
VStack(alignment: .leading, spacing: 8) {
Text(dataRefreshMode.summary)
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
.foregroundStyle(.secondary)
}
.font(.footnote)
} header: {
Text("Data checking")
} footer: {
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
}
Section { Section {
Button { Button {
onShowOnboarding() onShowOnboarding()
+89
View File
@@ -0,0 +1,89 @@
import BackgroundTasks
import Foundation
import WidgetKit
enum SmartDataRefreshScheduler {
static let taskIdentifier = "com.apt.fuelboard.smart-refresh"
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
guard let task = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}
handle(task)
}
}
static func scheduleNextIfNeeded() {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier)
guard FuelStore.loadDataRefreshMode() == .smart else { return }
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
#if DEBUG
print("SMART-REFRESH schedule failed: \(error)")
#endif
}
}
private static func handle(_ task: BGAppRefreshTask) {
scheduleNextIfNeeded()
let refreshTask = Task {
let success = await SmartDataRefreshCoordinator.runBackgroundProbe()
task.setTaskCompleted(success: success)
}
task.expirationHandler = {
refreshTask.cancel()
}
}
}
enum SmartDataRefreshCoordinator {
@discardableResult
static func runBackgroundProbe(now: Date = Date()) async -> Bool {
guard FuelStore.loadDataRefreshMode() == .smart else { return true }
guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else {
return true
}
FuelStore.saveLastSmartProbe(now)
guard !Task.isCancelled else { return false }
guard let latest = await FuelHistoryStore.fetchLatest() else {
return false
}
let latestDay = latest.availableTo ?? latest.date
let latestUpdated = latest.dataUpdated
let cachedDay = MirrorFuelProvider.loadDumpCache()?.day
let savedUpdated = FuelStore.loadDataUpdated()
let shouldFetchFullDump = FuelStore.loadStations().isEmpty
|| !FuelStore.isCacheFresh(now: now)
|| (latestDay != nil && latestDay != cachedDay)
|| (latestUpdated != nil && latestUpdated != savedUpdated)
guard shouldFetchFullDump, !Task.isCancelled else { return true }
do {
let fetched = try await FuelPriceProvider.active.fetchStations(
near: nil,
lng: nil,
fuel: FuelStore.loadSelectedFuel(),
radiusKM: nil
)
guard !Task.isCancelled else { return false }
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh(now)
await WatchSyncManager.shared.pushSnapshot()
if let meta = LiveChainProvider.latestMeta {
FuelStore.saveRelayMeta(meta)
}
WidgetCenter.shared.reloadAllTimelines()
return true
} catch {
return false
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import Foundation
import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
}
@MainActor
final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager()
private var pendingSnapshotPush = false
private override init() {
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
pendingSnapshotPush = true
let session = WCSession.default
if session.delegate !== self {
session.delegate = self
}
session.activate()
}
func pushSnapshot() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated else {
pendingSnapshotPush = true
return
}
do {
try session.updateApplicationContext(snapshotContext())
pendingSnapshotPush = false
} catch {
print("WATCH-SYNC push failed: \(error.localizedDescription)")
}
}
private func snapshotContext() -> [String: Any] {
var context: [String: Any] = [:]
if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) {
context[FuelStore.favouritesKey] = favourites
}
// Do NOT send the full station dump to watchOS via WatchConnectivity.
// The UK dataset is megabytes large and exceeds application-context
// payload limits, which prevents any snapshot from arriving. The
// favourites payload already carries station snapshots + prices, so the
// watch can render favourites without the full list.
context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue
context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue
context[FuelStore.priceDisplayStyleKey] = FuelStore.loadPriceDisplayStyle().rawValue
if let lastRefresh = FuelStore.loadLastRefresh() {
context[FuelStore.lastRefreshKey] = String(lastRefresh.timeIntervalSince1970)
}
if let handled = FuelStore.loadWatchRefreshHandled() {
context[FuelStore.watchRefreshHandledKey] = String(handled.timeIntervalSince1970)
}
if let location = FuelStore.loadLocationWithDate() {
context[FuelStore.locationKey] = "\(location.coordinate.lat),\(location.coordinate.lng),\(location.date.timeIntervalSince1970)"
}
return context
}
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return
}
Task { @MainActor in
if activationState == .activated, self.pendingSnapshotPush {
self.pushSnapshot()
}
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
Task { @MainActor in
WCSession.default.activate()
}
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
handleIncomingMessage(userInfo)
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
handleIncomingMessage(message)
}
private nonisolated func handleIncomingMessage(_ payload: [String: Any]) {
if payload["fuelboard.snapshotRequest"] as? String != nil {
Task { @MainActor in
self.pushSnapshot()
}
}
guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String,
let timestamp = TimeInterval(raw) else { return }
let date = Date(timeIntervalSince1970: timestamp)
Task { @MainActor in
FuelStore.requestWatchRefresh(date)
NotificationCenter.default.post(name: .fuelBoardWatchRefreshRequested, object: nil)
}
}
}
@@ -0,0 +1 @@
../../../Shared/DataRefreshMode.swift
@@ -1,13 +1,156 @@
{ {
"images" : [ "images": [
{ {
"idiom" : "universal", "filename": "Watch-NotificationCenter-48.png",
"platform" : "watchos", "idiom": "watch",
"size" : "1024x1024" "role": "notificationCenter",
"scale": "2x",
"size": "24x24",
"subtype": "38mm"
},
{
"filename": "Watch-NotificationCenter-55.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "27.5x27.5",
"subtype": "42mm"
},
{
"filename": "Watch-NotificationCenter-66.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "33x33",
"subtype": "45mm"
},
{
"filename": "Watch-CompanionSettings-58.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "2x",
"size": "29x29"
},
{
"filename": "Watch-CompanionSettings-87.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "3x",
"size": "29x29"
},
{
"filename": "Watch-AppLauncher-80.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "40x40",
"subtype": "38mm"
},
{
"filename": "Watch-AppLauncher-88.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "44x44",
"subtype": "40mm"
},
{
"filename": "Watch-AppLauncher-92.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "46x46",
"subtype": "41mm"
},
{
"filename": "Watch-AppLauncher-100.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
},
{
"filename": "Watch-AppLauncher-102.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "51x51",
"subtype": "45mm"
},
{
"filename": "Watch-AppLauncher-108.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "54x54",
"subtype": "49mm"
},
{
"filename": "Watch-QuickLook-172.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "86x86",
"subtype": "38mm"
},
{
"filename": "Watch-QuickLook-196.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "98x98",
"subtype": "42mm"
},
{
"filename": "Watch-QuickLook-216.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "108x108",
"subtype": "44mm"
},
{
"filename": "Watch-QuickLook-234.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "117x117",
"subtype": "45mm"
},
{
"filename": "Watch-QuickLook-258.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "129x129",
"subtype": "49mm"
},
{
"filename": "Watch-Marketing-1024.png",
"idiom": "watch-marketing",
"scale": "1x",
"size": "1024x1024"
},
{
"filename": "Watch-LongLook-88.png",
"idiom": "watch",
"role": "longLook",
"scale": "2x",
"size": "44x44",
"subtype": "42mm"
},
{
"filename": "Watch-LongLook-100.png",
"idiom": "watch",
"role": "longLook",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
} }
], ],
"info" : { "info": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+124 -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"
} }
@@ -280,6 +281,7 @@ private enum WatchFuelCache {
} }
struct ContentView: View { struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel() @State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel()
@State private var refreshState: WatchRefreshState = .noData @State private var refreshState: WatchRefreshState = .noData
@State private var favourites: [WatchFavouriteEntry] = [] @State private var favourites: [WatchFavouriteEntry] = []
@@ -288,15 +290,43 @@ 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
reloadFromCache()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
reloadFromCache()
WatchSyncManager.shared.requestSnapshot()
}
}
} }
@ViewBuilder @ViewBuilder
@@ -311,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
@@ -334,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)
} }
@@ -366,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
) )
} }
} }
@@ -383,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 theyll 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)
@@ -414,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))
} }
@@ -441,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) {
@@ -469,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))
} }
@@ -487,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)
@@ -497,16 +555,26 @@ 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)
Task { Task {
for _ in 0..<12 { for _ in 0..<12 {
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
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
} }
@@ -523,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 {
@@ -2,6 +2,10 @@ import SwiftUI
@main @main
struct FuelBoardWatchApp: App { struct FuelBoardWatchApp: App {
init() {
WatchSyncManager.shared.activate()
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
@@ -0,0 +1,101 @@
import Foundation
import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchDataDidUpdate = Notification.Name("FuelBoardWatchDataDidUpdate")
}
final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager()
private let suiteName = "group.com.apt.fuelboard"
private let snapshotRequestKey = "fuelboard.snapshotRequest"
private override init() {
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
if session.delegate !== self {
session.delegate = self
}
session.activate()
}
func requestRefresh(at date: Date = Date()) {
guard WCSession.isSupported() else { return }
let raw = String(date.timeIntervalSince1970)
let payload = ["fuelboard.watchRefreshRequest": raw]
let session = WCSession.default
if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
} else {
session.transferUserInfo(payload)
}
}
func requestSnapshot() {
guard WCSession.isSupported() else { return }
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
let session = WCSession.default
if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
} else {
session.transferUserInfo(payload)
}
}
private func sharedDefaults() -> UserDefaults? {
UserDefaults(suiteName: suiteName)
}
private func storeSnapshot(_ payload: [String: Any]) {
guard let defaults = sharedDefaults() else { return }
if let favourites = payload["fuelboard.favourites"] as? Data {
defaults.set(favourites, forKey: "fuelboard.favourites")
}
if let fuel = payload["fuelboard.selectedFuel"] as? String {
defaults.set(fuel, forKey: "fuelboard.selectedFuel")
}
if let distanceUnit = payload["fuelboard.distanceUnit"] as? String {
defaults.set(distanceUnit, forKey: "fuelboard.distanceUnit")
}
if let priceStyle = payload["fuelboard.priceDisplayStyle"] as? String {
defaults.set(priceStyle, forKey: "fuelboard.priceDisplayStyle")
}
if let location = payload["fuelboard.lastLocation"] as? String {
defaults.set(location, forKey: "fuelboard.lastLocation")
}
if let lastRefresh = payload["fuelboard.lastRefresh"] as? String {
defaults.set(lastRefresh, forKey: "fuelboard.lastRefresh")
}
if let handled = payload["fuelboard.watchRefreshHandled"] as? String {
defaults.set(handled, forKey: "fuelboard.watchRefreshHandled")
}
NotificationCenter.default.post(name: .fuelBoardWatchDataDidUpdate, object: nil)
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return
}
if activationState == .activated {
requestSnapshot()
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
storeSnapshot(applicationContext)
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
storeSnapshot(userInfo)
}
}
@@ -1,13 +1,140 @@
{ {
"images" : [ "images": [
{ {
"idiom" : "universal", "filename": "Watch-NotificationCenter-48.png",
"platform" : "watchos", "idiom": "watch",
"size" : "1024x1024" "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": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -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.
+24
View File
@@ -0,0 +1,24 @@
import Foundation
enum DataRefreshMode: String, CaseIterable, Identifiable {
case standard
case smart
var id: String { rawValue }
var displayName: String {
switch self {
case .standard: return "Standard"
case .smart: return "Smart"
}
}
var summary: String {
switch self {
case .standard:
return "Saves battery. Refreshes price data up to twice daily."
case .smart:
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
}
}
}
+39 -3
View File
@@ -370,6 +370,8 @@ struct FuelStore {
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15) static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970)
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970) static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970)
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970) static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String "api" | "csv" static let relaySourceKey = "fuelboard.relaySource" // String "api" | "csv"
@@ -863,10 +865,24 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey) saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
} }
// MARK: Refresh policy data is cached; the app only auto-refreshes // MARK: Refresh policy Standard is cache-first/twice-daily; Smart keeps
// twice a day (pull-to-refresh is the manual override). // the same full-dump freshness cap but may probe the tiny mirror pointer
// more often in the background and only refresh the full dump if it changed.
static let refreshInterval: TimeInterval = 12 * 60 * 60 static let refreshInterval: TimeInterval = 12 * 60 * 60
static let smartProbeInterval: TimeInterval = 4 * 60 * 60
static func loadDataRefreshMode() -> DataRefreshMode {
if let raw = loadString(service: dataRefreshModeKey),
let mode = DataRefreshMode(rawValue: raw) {
return mode
}
return .standard
}
static func saveDataRefreshMode(_ mode: DataRefreshMode) {
saveString(mode.rawValue, service: dataRefreshModeKey)
}
static func loadLastRefresh() -> Date? { static func loadLastRefresh() -> Date? {
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) { if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
@@ -879,6 +895,17 @@ struct FuelStore {
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey) saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
} }
static func loadLastSmartProbe() -> Date? {
if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func saveLastSmartProbe(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
}
static func requestWatchRefresh(_ date: Date = Date()) { static func requestWatchRefresh(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey) saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey)
} }
@@ -959,8 +986,17 @@ struct FuelStore {
/// True when the cached data is fresh enough that a scheduled auto-refresh /// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy). /// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool { static var isCacheFresh: Bool {
isCacheFresh(now: Date())
}
static func isCacheFresh(now: Date) -> Bool {
guard let last = loadLastRefresh() else { return false } guard let last = loadLastRefresh() else { return false }
return Date().timeIntervalSince(last) < refreshInterval return now.timeIntervalSince(last) < refreshInterval
}
static func isSmartProbeDue(now: Date = Date()) -> Bool {
guard let last = loadLastSmartProbe() else { return true }
return now.timeIntervalSince(last) >= smartProbeInterval
} }
// MARK: Onboarding the app shows the intro screen on first launch only // MARK: Onboarding the app shows the intro screen on first launch only