Compare commits
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -16,10 +16,12 @@ 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()
|
||||||
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
|
||||||
|
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
|
||||||
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
|
||||||
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
|
||||||
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
|
||||||
@@ -233,6 +235,30 @@ struct ContentView: View {
|
|||||||
.sheet(item: $monitor.pendingStationMap) { request in
|
.sheet(item: $monitor.pendingStationMap) { request in
|
||||||
StationMapView(request: request)
|
StationMapView(request: request)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $monitor.pendingDetailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
|
||||||
|
let info = note.userInfo ?? [:]
|
||||||
|
if let id = info["stationID"] as? String,
|
||||||
|
let station = stations.first(where: { $0.id == id }) {
|
||||||
|
monitor.pendingDetailStation = station
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Station left the cache — fall back to the map sheet so
|
||||||
|
// the tap still shows something.
|
||||||
|
if let lat = info["stationLat"] as? Double,
|
||||||
|
let lng = info["stationLng"] as? Double {
|
||||||
|
let name = info["stationName"] as? String ?? "Station"
|
||||||
|
monitor.pendingStationMap = StationMapRequest(
|
||||||
|
name: name, latitude: lat, longitude: lng
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +301,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 +423,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 +442,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
|
||||||
@@ -738,6 +767,7 @@ struct ContentView: View {
|
|||||||
topStationID: topStationID,
|
topStationID: topStationID,
|
||||||
location: location,
|
location: location,
|
||||||
favouriteIDs: favouriteIDs,
|
favouriteIDs: favouriteIDs,
|
||||||
|
stationTapAction: stationTapAction,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onRefresh: { await refresh(force: true) }
|
onRefresh: { await refresh(force: true) }
|
||||||
)
|
)
|
||||||
@@ -752,6 +782,7 @@ struct ContentView: View {
|
|||||||
location: location,
|
location: location,
|
||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
|
stationTapAction: stationTapAction,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onReorder: reorderFavourites,
|
onReorder: reorderFavourites,
|
||||||
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||||
@@ -790,6 +821,8 @@ struct ContentView: View {
|
|||||||
tipStore: tipStore,
|
tipStore: tipStore,
|
||||||
distanceUnit: $distanceUnit,
|
distanceUnit: $distanceUnit,
|
||||||
priceDisplayStyle: $priceDisplayStyle,
|
priceDisplayStyle: $priceDisplayStyle,
|
||||||
|
stationTapAction: $stationTapAction,
|
||||||
|
dataRefreshMode: $dataRefreshMode,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
alertsRadiusKM: alertsRadius,
|
alertsRadiusKM: alertsRadius,
|
||||||
testAlertResult: monitor.lastTestResult,
|
testAlertResult: monitor.lastTestResult,
|
||||||
@@ -818,6 +851,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 +863,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 +895,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.
|
||||||
@@ -923,7 +959,9 @@ struct StationRow: View {
|
|||||||
let baselinePrice: Double?
|
let baselinePrice: Double?
|
||||||
let isTopResult: Bool
|
let isTopResult: Bool
|
||||||
let isFavourite: Bool
|
let isFavourite: Bool
|
||||||
|
var tapAction: StationTapAction = .openMap
|
||||||
var onToggleFavourite: () -> Void = {}
|
var onToggleFavourite: () -> Void = {}
|
||||||
|
var onShowDetails: (FuelStation) -> Void = { _ in }
|
||||||
|
|
||||||
private var ragColor: Color {
|
private var ragColor: Color {
|
||||||
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
|
||||||
@@ -943,6 +981,21 @@ struct StationRow: View {
|
|||||||
return String(format: "%+.1fp", delta)
|
return String(format: "%+.1fp", delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleTap() {
|
||||||
|
switch tapAction {
|
||||||
|
case .openMap:
|
||||||
|
if let url = station.mapsDirectionsURL {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
case .showDetails:
|
||||||
|
showDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showDetails() {
|
||||||
|
onShowDetails(station)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
// LEFT — round brand logo (or generic fuel pump fallback)
|
// LEFT — round brand logo (or generic fuel pump fallback)
|
||||||
@@ -979,6 +1032,12 @@ struct StationRow: View {
|
|||||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
.background(Capsule().fill(.blue.opacity(0.15)))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
}
|
}
|
||||||
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
|
Image(systemName: notice.symbolName)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel(notice.title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text("\(station.address), \(station.postcode)")
|
Text("\(station.address), \(station.postcode)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -1021,11 +1080,8 @@ struct StationRow: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture(perform: handleTap)
|
||||||
if let url = station.mapsDirectionsURL {
|
.onLongPressGesture(perform: showDetails)
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct FavouritesView: View {
|
|||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
|
let stationTapAction: StationTapAction
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
/// Persists a reordered favourites array (after drag-and-drop).
|
/// Persists a reordered favourites array (after drag-and-drop).
|
||||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||||
@@ -37,6 +38,9 @@ struct FavouritesView: View {
|
|||||||
/// Trends sheet (price history chart) presentation state.
|
/// Trends sheet (price history chart) presentation state.
|
||||||
@State private var showTrends = false
|
@State private var showTrends = false
|
||||||
|
|
||||||
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
private var activeFuel: FuelType {
|
private var activeFuel: FuelType {
|
||||||
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,7 @@ struct FavouritesView: View {
|
|||||||
location: Coordinate?,
|
location: Coordinate?,
|
||||||
distanceUnit: DistanceUnit,
|
distanceUnit: DistanceUnit,
|
||||||
priceDisplayStyle: PriceDisplayStyle,
|
priceDisplayStyle: PriceDisplayStyle,
|
||||||
|
stationTapAction: StationTapAction,
|
||||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||||
onHistoryUnavailable: (() -> Void)? = nil,
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
@@ -74,6 +79,7 @@ struct FavouritesView: View {
|
|||||||
self.location = location
|
self.location = location
|
||||||
self.distanceUnit = distanceUnit
|
self.distanceUnit = distanceUnit
|
||||||
self.priceDisplayStyle = priceDisplayStyle
|
self.priceDisplayStyle = priceDisplayStyle
|
||||||
|
self.stationTapAction = stationTapAction
|
||||||
self.onToggleFavourite = onToggleFavourite
|
self.onToggleFavourite = onToggleFavourite
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
self.onHistoryUnavailable = onHistoryUnavailable
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
@@ -129,7 +135,9 @@ struct FavouritesView: View {
|
|||||||
baselinePrice: cheapestPrice,
|
baselinePrice: cheapestPrice,
|
||||||
isTopResult: index == 0,
|
isTopResult: index == 0,
|
||||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
tapAction: stationTapAction,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onMove(perform: moveFavourite)
|
.onMove(perform: moveFavourite)
|
||||||
@@ -171,6 +179,13 @@ struct FavouritesView: View {
|
|||||||
onHistoryRecovered: onHistoryRecovered
|
onHistoryRecovered: onHistoryRecovered
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// QA hook: launch with `-showTrends` to open the sheet
|
// QA hook: launch with `-showTrends` to open the sheet
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +69,40 @@ struct FuelBoardApp: App {
|
|||||||
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
||||||
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
||||||
/// Apple Maps without ever launching this app.
|
/// Apple Maps without ever launching this app.
|
||||||
|
/// A `fuelboard://station?stationID=…&lat=…&lng=…&name=…` link (from a
|
||||||
|
/// widget or Live Activity tap when Settings → Tap station = More info)
|
||||||
|
/// posts a notification that ContentView observes to present the detail
|
||||||
|
/// sheet (falling back to the map sheet when the station has left
|
||||||
|
/// the cache).
|
||||||
private func handleOpenURL(_ url: URL) {
|
private func handleOpenURL(_ url: URL) {
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
let items = components.queryItems
|
else { return }
|
||||||
|
|
||||||
|
if components.scheme == "fuelboard",
|
||||||
|
components.host == "station" {
|
||||||
|
let items = components.queryItems ?? []
|
||||||
|
var userInfo: [String: Any] = [:]
|
||||||
|
if let id = items.first(where: { $0.name == "stationID" })?.value {
|
||||||
|
userInfo["stationID"] = id
|
||||||
|
}
|
||||||
|
if let lat = items.first(where: { $0.name == "lat" })?.value.flatMap(Double.init) {
|
||||||
|
userInfo["stationLat"] = lat
|
||||||
|
}
|
||||||
|
if let lng = items.first(where: { $0.name == "lng" })?.value.flatMap(Double.init) {
|
||||||
|
userInfo["stationLng"] = lng
|
||||||
|
}
|
||||||
|
if let name = items.first(where: { $0.name == "name" })?.value {
|
||||||
|
userInfo["stationName"] = name
|
||||||
|
}
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .fuelBoardShowStationDetail,
|
||||||
|
object: nil,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let items = components.queryItems
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
@Published var lastAlert: String?
|
@Published var lastAlert: String?
|
||||||
@Published private(set) var lastTestResult: String?
|
@Published private(set) var lastTestResult: String?
|
||||||
@Published var pendingStationMap: StationMapRequest?
|
@Published var pendingStationMap: StationMapRequest?
|
||||||
|
/// Station tapped via notification when Settings → Tap station = More info.
|
||||||
|
/// ContentView presents this as a StationDetailView sheet.
|
||||||
|
@Published var pendingDetailStation: FuelStation?
|
||||||
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
||||||
/// every location/stations change via `refreshDebugStatus()`.
|
/// every location/stations change via `refreshDebugStatus()`.
|
||||||
@Published private(set) var debugStatus: DebugLocationStatus?
|
@Published private(set) var debugStatus: DebugLocationStatus?
|
||||||
@@ -791,9 +794,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tapping a notification opens Apple Maps directions to the station from
|
/// Tapping a notification honours Settings → Tap station: Open map opens
|
||||||
/// the user's current location. If Apple Maps can't be opened (e.g. inside
|
/// Apple Maps directions to the station from the user's current location;
|
||||||
/// LiveContainer), an in-app map sheet with directions is shown instead.
|
/// More info opens the station's detail sheet in the app instead. If Apple
|
||||||
|
/// Maps can't be opened (e.g. inside LiveContainer), an in-app map sheet
|
||||||
|
/// with directions is shown instead.
|
||||||
///
|
///
|
||||||
/// When the notification carried tie-choice action buttons, the tapped
|
/// When the notification carried tie-choice action buttons, the tapped
|
||||||
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
||||||
@@ -818,9 +823,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
let tieName = ties[index]["name"] as? String,
|
let tieName = ties[index]["name"] as? String,
|
||||||
let tieLat = ties[index]["lat"] as? Double,
|
let tieLat = ties[index]["lat"] as? Double,
|
||||||
let tieLng = ties[index]["lng"] as? Double {
|
let tieLng = ties[index]["lng"] as? Double {
|
||||||
openDirections(to: tieName, latitude: tieLat, longitude: tieLng)
|
routeNotificationTap(name: tieName, latitude: tieLat, longitude: tieLng)
|
||||||
} else if !isTie, let name, let lat, let lng {
|
} else if !isTie, let name, let lat, let lng {
|
||||||
openDirections(to: name, latitude: lat, longitude: lng)
|
routeNotificationTap(name: name, latitude: lat, longitude: lng)
|
||||||
}
|
}
|
||||||
// Tie body tap: intentionally no directions — the user must pick
|
// Tie body tap: intentionally no directions — the user must pick
|
||||||
// an option button to choose a station.
|
// an option button to choose a station.
|
||||||
@@ -828,6 +833,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes a resolved notification tap through the user's Tap station
|
||||||
|
/// preference: Open map → Apple Maps; More info → in-app detail sheet
|
||||||
|
/// (falling back to the map sheet when the station isn't in cache).
|
||||||
|
@MainActor
|
||||||
|
private func routeNotificationTap(name: String, latitude: Double, longitude: Double) {
|
||||||
|
guard FuelStore.loadStationTapAction() == .showDetails else {
|
||||||
|
openDirections(to: name, latitude: latitude, longitude: longitude)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let station = stations.first(where: { $0.lat == latitude && $0.lng == longitude })
|
||||||
|
?? stations.first(where: { $0.name == name }) {
|
||||||
|
pendingDetailStation = station
|
||||||
|
} else {
|
||||||
|
// Station not in cache (e.g. data refreshed since the alert fired)
|
||||||
|
// — fall back to the map sheet so the tap still shows something.
|
||||||
|
pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens Apple Maps with driving directions to the station; falls back to
|
/// Opens Apple Maps with driving directions to the station; falls back to
|
||||||
/// the in-app map sheet when the hand-off fails.
|
/// the in-app map sheet when the hand-off fails.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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 stationTapAction: StationTapAction
|
||||||
|
@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 +105,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 +116,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 +124,46 @@ 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("Tap station", selection: $stationTapAction) {
|
||||||
|
ForEach(StationTapAction.allCases) { action in
|
||||||
|
Text(action.displayName).tag(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: stationTapAction) { _, newValue in
|
||||||
|
FuelStore.saveStationTapAction(newValue)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Tap action")
|
||||||
|
} footer: {
|
||||||
|
Text("Tapping a station either opens directions straight away or shows its details first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Data checking", selection: $dataRefreshMode) {
|
||||||
|
ForEach(DataRefreshMode.allCases) { mode in
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import MapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
|
||||||
|
/// Shows a small map preview, the station name, address, distance from the
|
||||||
|
/// current location, and a Directions button that opens Apple Maps — the same
|
||||||
|
/// destination a direct tap would have opened.
|
||||||
|
struct StationDetailView: View {
|
||||||
|
let station: FuelStation
|
||||||
|
let location: Coordinate?
|
||||||
|
let distanceUnit: DistanceUnit
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var showingReportIssueConfirmation = false
|
||||||
|
|
||||||
|
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||||
|
|
||||||
|
private var stationCoordinate: CLLocationCoordinate2D {
|
||||||
|
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Map(position: .constant(.region(MKCoordinateRegion(
|
||||||
|
center: stationCoordinate,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
|
||||||
|
)))) {
|
||||||
|
Marker(station.name, coordinate: stationCoordinate)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.frame(height: 180)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
LabeledContent("Name", value: station.name)
|
||||||
|
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
|
||||||
|
if let location {
|
||||||
|
let km = FuelStore.displayDistanceKM(
|
||||||
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
|
)
|
||||||
|
LabeledContent("Distance", value: distanceUnit.format(km))
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
ForEach(StationNotice.notices(for: station)) { notice in
|
||||||
|
Label(notice.title, systemImage: notice.symbolName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
if let url = station.mapsDirectionsURL {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showingReportIssueConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(station.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Report") {
|
||||||
|
UIApplication.shared.open(reportIssueURL)
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Presentation-only notices derived from existing station metadata.
|
||||||
|
///
|
||||||
|
/// These do not alter `FuelStation`, persistence, filtering, sorting, or
|
||||||
|
/// navigation. They are intentionally small and reusable so additional
|
||||||
|
/// informational station notices can be added later from the UI layer.
|
||||||
|
enum StationNotice: Identifiable, Equatable {
|
||||||
|
case membershipRequired
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "membershipRequired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "Membership required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .membershipRequired: return "creditcard.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func notices(for station: FuelStation) -> [StationNotice] {
|
||||||
|
isCostco(station) ? [.membershipRequired] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isCostco(_ station: FuelStation) -> Bool {
|
||||||
|
containsCostcoToken(station.brand) || containsCostcoToken(station.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func containsCostcoToken(_ value: String) -> Bool {
|
||||||
|
let tokens = value.uppercased().split { !$0.isLetter && !$0.isNumber }
|
||||||
|
return tokens.contains("COSTCO")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ struct StationsView: View {
|
|||||||
let topStationID: String?
|
let topStationID: String?
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let favouriteIDs: Set<String>
|
let favouriteIDs: Set<String>
|
||||||
|
let stationTapAction: StationTapAction
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
var onRefresh: () async -> Void = {}
|
var onRefresh: () async -> Void = {}
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ struct StationsView: View {
|
|||||||
/// info button in the header — keeps the list focused on stations.
|
/// info button in the header — keeps the list focused on stations.
|
||||||
@State private var showKey = false
|
@State private var showKey = false
|
||||||
|
|
||||||
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
||||||
/// directions context + "N/TOTAL stations updated". The numerator is the
|
/// directions context + "N/TOTAL stations updated". The numerator is the
|
||||||
/// current list pool; the denominator is the full UK station total from
|
/// current list pool; the denominator is the full UK station total from
|
||||||
@@ -38,6 +42,9 @@ struct StationsView: View {
|
|||||||
let unit = distanceUnit.label(for: Double(miles))
|
let unit = distanceUnit.label(for: Double(miles))
|
||||||
let fuel = selectedFuel.displayName
|
let fuel = selectedFuel.displayName
|
||||||
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
||||||
|
let tapHint = stationTapAction == .openMap
|
||||||
|
? "tap a station for directions."
|
||||||
|
: "tap a station for details."
|
||||||
let ratio: String
|
let ratio: String
|
||||||
if let total = FuelStore.loadStationCount() {
|
if let total = FuelStore.loadStationCount() {
|
||||||
ratio = "\(totalCount)/\(total)"
|
ratio = "\(totalCount)/\(total)"
|
||||||
@@ -48,9 +55,9 @@ struct StationsView: View {
|
|||||||
if sortMode == .closest {
|
if sortMode == .closest {
|
||||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · tap a station for directions."
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
|
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -126,7 +133,9 @@ struct StationsView: View {
|
|||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
isTopResult: station.id == topStationID,
|
isTopResult: station.id == topStationID,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
tapAction: stationTapAction,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +269,13 @@ struct StationsView: View {
|
|||||||
.presentationDetents([.fraction(0.6)])
|
.presentationDetents([.fraction(0.6)])
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||||
|
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,14 @@
|
|||||||
"Units" = "Units";
|
"Units" = "Units";
|
||||||
"Price display" = "Price display";
|
"Price display" = "Price display";
|
||||||
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
||||||
|
"Tap station" = "Tap station";
|
||||||
|
"Tap action" = "Tap action";
|
||||||
|
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
|
||||||
|
"Open map" = "Open map";
|
||||||
|
"More info" = "More info";
|
||||||
|
"Name" = "Name";
|
||||||
|
"Address" = "Address";
|
||||||
|
"Distance" = "Distance";
|
||||||
"Show introduction" = "Replay onboarding";
|
"Show introduction" = "Replay onboarding";
|
||||||
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
||||||
"Test alert notification (real data)" = "Test alert notification (real data)";
|
"Test alert notification (real data)" = "Test alert notification (real data)";
|
||||||
@@ -139,6 +147,12 @@
|
|||||||
"You" = "You";
|
"You" = "You";
|
||||||
"Done" = "Done";
|
"Done" = "Done";
|
||||||
"Directions" = "Directions";
|
"Directions" = "Directions";
|
||||||
|
"Report issue" = "Report issue";
|
||||||
|
"Membership required" = "Membership required";
|
||||||
|
"Report an error?" = "Report an error?";
|
||||||
|
"Cancel" = "Cancel";
|
||||||
|
"Report" = "Report";
|
||||||
|
"Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser." = "Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.";
|
||||||
|
|
||||||
/* Station row */
|
/* Station row */
|
||||||
"best" = "best";
|
"best" = "best";
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -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 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)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -2,7 +2,8 @@
|
|||||||
//
|
//
|
||||||
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
||||||
// Shows the cheapest station for the pinned fuel within the app's chosen
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
// radius. Tapping honours Settings → Tap station: Open map opens Apple Maps
|
||||||
|
// directions to that station, More info opens the detail sheet in the app.
|
||||||
//
|
//
|
||||||
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
||||||
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
||||||
@@ -31,24 +32,34 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityCompactView(context: context)
|
FuelBoardLiveActivityCompactView(context: context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
FuelBoardLiveActivityStationView(context: context)
|
FuelBoardLiveActivityStationView(context: context)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(context.state.fuel.tintColor)
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
|
}
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
|
}
|
||||||
} minimal: {
|
} minimal: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
Text(context.state.priceText)
|
Text(context.state.priceText)
|
||||||
.font(.caption2.bold().monospacedDigit())
|
.font(.caption2.bold().monospacedDigit())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.supplementalActivityFamilies([.small])
|
.supplementalActivityFamilies([.small])
|
||||||
// Why `.small` is kept: it lets the SHARED body render a compact form
|
// Why `.small` is kept: it lets the SHARED body render a compact form
|
||||||
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
||||||
@@ -84,7 +95,7 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
@State private var slotWidth: CGFloat = 400
|
@State private var slotWidth: CGFloat = 400
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: tapDestination) {
|
||||||
Group {
|
Group {
|
||||||
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||||
// full Lock Screen width (>= threshold) → rich card, no matter
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||||
@@ -138,7 +149,8 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
style: context.state.priceDisplayStyle,
|
style: context.state.priceDisplayStyle,
|
||||||
size: 22, weight: .bold)
|
size: 22, weight: .bold)
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -147,9 +159,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 +169,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)
|
||||||
@@ -205,10 +222,27 @@ private struct FuelBoardLiveActivityStationView: View {
|
|||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap routing shared by the Lock Screen card and the island: honours
|
||||||
|
/// Settings → Tap station (Open map → Maps, More info → in-app detail).
|
||||||
|
enum FuelBoardLiveActivityTap {
|
||||||
|
static func tapDestination(for state: FuelBoardLiveActivityAttributes.ContentState) -> URL {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails ? state.detailURL : state.mapsURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FuelBoardLiveActivityView {
|
||||||
|
/// Lock Screen tap honours Settings → Tap station.
|
||||||
|
var tapDestination: URL {
|
||||||
|
FuelBoardLiveActivityTap.tapDestination(for: context.state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ContentState display helpers
|
// MARK: - ContentState display helpers
|
||||||
@@ -230,4 +264,21 @@ extension FuelBoardLiveActivityAttributes.ContentState {
|
|||||||
var mapsURL: URL {
|
var mapsURL: URL {
|
||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to the station's detail sheet, used when
|
||||||
|
/// Settings → Tap station = More info. Carries coordinate/name fallback
|
||||||
|
/// so the app can still show the map sheet when the station has left
|
||||||
|
/// the cache.
|
||||||
|
var detailURL: URL {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: stationID),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: stationName),
|
||||||
|
]
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
||||||
// Everything dynamic lives in ContentState so updates — including a fuel
|
// Everything dynamic lives in ContentState so updates — including a fuel
|
||||||
// change — apply to the running activity without needing to restart it.
|
// change — apply to the running activity without needing to restart it.
|
||||||
// Tapping the activity opens Apple Maps driving directions (maps://).
|
// Tapping the activity honours Settings → Tap station: Open map opens Apple
|
||||||
|
// Maps driving directions (maps://), More info opens the detail sheet in
|
||||||
|
// the app (fuelboard://station).
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -93,13 +93,14 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
|
.modifier(WidgetURLModifier(url: isWidget ? station.tapDestinationURL : nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many station rows each family can fit: medium widgets are short
|
/// How many station rows each family can fit: medium widgets are short
|
||||||
@@ -129,7 +130,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
}
|
}
|
||||||
ForEach(entry.stations.prefix(maxRows)) { station in
|
ForEach(entry.stations.prefix(maxRows)) { station in
|
||||||
if isWidget {
|
if isWidget {
|
||||||
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
|
Link(destination: station.tapDestinationURL ?? URL(string: "maps://")!) {
|
||||||
row(station, cheapest: cheapest)
|
row(station, cheapest: cheapest)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
@@ -208,6 +208,25 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Station tap action
|
||||||
|
|
||||||
|
/// What happens when a station row is tapped in the Stations/Favourites tabs:
|
||||||
|
/// open Apple Maps directions immediately (historic behaviour, default), or
|
||||||
|
/// show a detail sheet (name, address, distance, Directions button).
|
||||||
|
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case openMap // tap → Apple Maps directions straight away
|
||||||
|
case showDetails // tap → detail sheet, Directions button inside
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .openMap: return "Open map"
|
||||||
|
case .showDetails: return "More info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Station model
|
// MARK: - Station model
|
||||||
|
|
||||||
struct FuelStation: Identifiable, Codable, Equatable {
|
struct FuelStation: Identifiable, Codable, Equatable {
|
||||||
@@ -252,6 +271,30 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to this station's More info sheet. Carries the
|
||||||
|
/// stationID plus coordinate/name fallback so the app can still show the
|
||||||
|
/// map sheet when the station has left the cache.
|
||||||
|
var detailDeepLinkURL: URL? {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: id),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: name),
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget/Live Activity tap destination honouring Settings → Tap station:
|
||||||
|
/// Open map → Apple Maps directions; More info → in-app detail deep link.
|
||||||
|
var tapDestinationURL: URL? {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? detailDeepLinkURL
|
||||||
|
: widgetDirectionsURL
|
||||||
|
}
|
||||||
|
|
||||||
/// Name of the bundled brand logo asset, or nil if unknown.
|
/// Name of the bundled brand logo asset, or nil if unknown.
|
||||||
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
||||||
var brandImageName: String? {
|
var brandImageName: String? {
|
||||||
@@ -370,6 +413,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"
|
||||||
@@ -540,6 +585,22 @@ struct FuelStore {
|
|||||||
saveString(style.rawValue, service: priceDisplayStyleKey)
|
saveString(style.rawValue, service: priceDisplayStyleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Station tap action — open map vs detail sheet. Stored raw value;
|
||||||
|
// default open map preserves the historic tap behaviour for existing installs.
|
||||||
|
|
||||||
|
static let stationTapActionKey = "fuelboard.stationTapAction"
|
||||||
|
|
||||||
|
static func loadStationTapAction() -> StationTapAction {
|
||||||
|
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
return .openMap
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveStationTapAction(_ action: StationTapAction) {
|
||||||
|
saveString(action.rawValue, service: stationTapActionKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Superscript digit glyphs for the pounds & pence format's small raised
|
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||||
/// third digit (the forecourt style: "£1.29⁹").
|
/// third digit (the forecourt style: "£1.29⁹").
|
||||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
@@ -863,10 +924,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 +954,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 +1045,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
|
||||||
|
|||||||