Location-driven results: widget fetches its own location (async, cache fallback), app auto-requests on first launch, Halifax sample stations
This commit is contained in:
@@ -20,6 +20,8 @@
|
|||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>FuelBoard uses your location to show the cheapest nearby petrol stations.</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import SwiftUI
|
|||||||
import CoreLocation
|
import CoreLocation
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
struct Coordinate: Equatable {
|
|
||||||
let lat: Double
|
|
||||||
let lng: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -111,6 +106,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
// Auto-request location on first launch so the widget gets a
|
||||||
|
// fresh fix without the user hunting for the button.
|
||||||
|
if FuelStore.loadLocation() == nil {
|
||||||
|
locationManager.requestUpdate()
|
||||||
|
}
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
|||||||
@@ -2,17 +2,23 @@ import WidgetKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// FuelBoard widget — cheapest petrol stations near you.
|
// FuelBoard widget — cheapest petrol stations near you.
|
||||||
// systemMedium: top 4 stations with price + distance, each row opens Maps
|
// systemMedium: top 3-4 stations with price + distance, each row opens Maps
|
||||||
// systemSmall: single cheapest station, whole widget opens Maps
|
// systemSmall: single cheapest station, whole widget opens Maps
|
||||||
// Taps deep-link to Apple Maps directions (http://maps.apple.com/?daddr=).
|
// Taps deep-link to Apple Maps directions (http://maps.apple.com/?daddr=).
|
||||||
// Note: on the home screen Link opens Maps directly. Inside CarPlay the widget
|
// Note: on the home screen Link opens Maps directly. Inside CarPlay the widget
|
||||||
// renders but can't launch Maps (widgets can only launch their own CarPlay app).
|
// renders but can't launch Maps (widgets can only launch their own CarPlay app).
|
||||||
|
//
|
||||||
|
// Location flow: the provider requests location itself (async, timeout-bounded).
|
||||||
|
// On success it caches the fix in the shared store for the app; on failure
|
||||||
|
// (no permission yet / timeout) it falls back to the app's cached location,
|
||||||
|
// then to price-only sorting. So the widget is location-driven the moment the
|
||||||
|
// app has been opened once and permission granted.
|
||||||
|
|
||||||
struct FuelPriceWidget: Widget {
|
struct FuelPriceWidget: Widget {
|
||||||
let kind = "FuelPriceWidget"
|
let kind = "FuelPriceWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
StaticConfiguration(kind: kind, provider: FuelPriceProvider2()) { entry in
|
StaticConfiguration(kind: kind, provider: FuelPriceTimelineProvider()) { entry in
|
||||||
FuelPriceWidgetView(entry: entry)
|
FuelPriceWidgetView(entry: entry)
|
||||||
.containerBackground(for: .widget) {
|
.containerBackground(for: .widget) {
|
||||||
Color(.systemBackground)
|
Color(.systemBackground)
|
||||||
@@ -28,31 +34,57 @@ struct FuelPriceEntry: TimelineEntry {
|
|||||||
let date: Date
|
let date: Date
|
||||||
let stations: [FuelStation] // already filtered + sorted for the selected fuel
|
let stations: [FuelStation] // already filtered + sorted for the selected fuel
|
||||||
let fuel: FuelType
|
let fuel: FuelType
|
||||||
let location: (lat: Double, lng: Double)?
|
let location: Coordinate?
|
||||||
|
let locationSource: String // "live" | "cached" | "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceProvider2: TimelineProvider {
|
struct FuelPriceTimelineProvider: TimelineProvider {
|
||||||
func placeholder(in context: Context) -> FuelPriceEntry {
|
func placeholder(in context: Context) -> FuelPriceEntry {
|
||||||
let fuel = FuelStore.loadSelectedFuel()
|
let fuel = FuelStore.loadSelectedFuel()
|
||||||
let sample = SampleFuelProvider.sampleStations
|
let sample = SampleFuelProvider.sampleStations
|
||||||
.filter { $0.prices[fuel] != nil }
|
.filter { $0.prices[fuel] != nil }
|
||||||
.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
||||||
return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil)
|
return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) {
|
func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) {
|
||||||
completion(makeEntry())
|
Task {
|
||||||
|
completion(await makeEntry())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<FuelPriceEntry>) -> Void) {
|
func getTimeline(in context: Context, completion: @escaping (Timeline<FuelPriceEntry>) -> Void) {
|
||||||
let entry = makeEntry()
|
Task {
|
||||||
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
let entry = await makeEntry()
|
||||||
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
|
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
||||||
|
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeEntry() -> FuelPriceEntry {
|
private func makeEntry() async -> FuelPriceEntry {
|
||||||
let fuel = FuelStore.loadSelectedFuel()
|
let fuel = FuelStore.loadSelectedFuel()
|
||||||
let location = FuelStore.loadLocation().map { (lat: $0.lat, lng: $0.lng) }
|
|
||||||
|
// 1) Try a fresh location fix (bounded to a few seconds).
|
||||||
|
var location = await WidgetLocationFetcher.shared.currentLocation().map {
|
||||||
|
Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude)
|
||||||
|
}
|
||||||
|
var source = "live"
|
||||||
|
|
||||||
|
// 2) Fall back to the app's cached fix.
|
||||||
|
if location == nil, let cached = FuelStore.loadLocation() {
|
||||||
|
location = cached
|
||||||
|
source = "cached"
|
||||||
|
}
|
||||||
|
if location == nil {
|
||||||
|
source = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist a live fix so the app shows fresh coords on next launch.
|
||||||
|
if let location {
|
||||||
|
FuelStore.saveLocation(lat: location.lat, lng: location.lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Load stations, then sort by distance-weighted score.
|
||||||
var stations = FuelStore.loadStations()
|
var stations = FuelStore.loadStations()
|
||||||
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
||||||
let filtered = stations.filter { $0.prices[fuel] != nil }
|
let filtered = stations.filter { $0.prices[fuel] != nil }
|
||||||
@@ -66,11 +98,13 @@ struct FuelPriceProvider2: TimelineProvider {
|
|||||||
} else {
|
} else {
|
||||||
sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! }
|
||||||
}
|
}
|
||||||
return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location)
|
|
||||||
|
return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location, locationSource: source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceWidgetView: View {
|
struct FuelPriceWidgetView: View {
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
let entry: FuelPriceEntry
|
let entry: FuelPriceEntry
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -84,7 +118,7 @@ struct FuelPriceWidgetView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
} else if entry.stations.count == 1 || UIDevice.current.userInterfaceIdiom == .pad {
|
} else if family == .systemSmall {
|
||||||
singleCheapest
|
singleCheapest
|
||||||
} else {
|
} else {
|
||||||
stationList
|
stationList
|
||||||
@@ -114,10 +148,11 @@ struct FuelPriceWidgetView: View {
|
|||||||
Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng)))
|
Text(String(format: "%.1f km away", station.distanceKM(to: location.lat, lng2: location.lng)))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Tap for directions")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text("Tap for directions")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
.widgetURL(station.mapsDirectionsURL)
|
.widgetURL(station.mapsDirectionsURL)
|
||||||
@@ -132,7 +167,7 @@ struct FuelPriceWidgetView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Tap → Maps")
|
Text(entry.locationSource == "none" ? "by price" : "near you")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ enum FuelPriceProvider {
|
|||||||
|
|
||||||
// MARK: - Sample provider (default for the scaffold)
|
// MARK: - Sample provider (default for the scaffold)
|
||||||
|
|
||||||
/// Ships realistic stations around Cambridge (52.2053, 0.1218) so the app and
|
/// Ships realistic stations around Halifax, West Yorkshire (53.7270, -1.8575)
|
||||||
/// widget render meaningful data with zero setup. Prices in pence/litre.
|
/// so the app and widget render meaningful distances for the user with zero
|
||||||
|
/// setup. Prices in pence/litre.
|
||||||
struct SampleFuelProvider: FuelPriceProviding {
|
struct SampleFuelProvider: FuelPriceProviding {
|
||||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) async throws -> [FuelStation] {
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType) async throws -> [FuelStation] {
|
||||||
try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch
|
try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch
|
||||||
@@ -32,45 +33,45 @@ struct SampleFuelProvider: FuelPriceProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static let sampleStations: [FuelStation] = [
|
static let sampleStations: [FuelStation] = [
|
||||||
FuelStation(id: "s1", name: "Shell Cambridge Retail Park", brand: "Shell",
|
FuelStation(id: "h1", name: "Tesco Express Halifax", brand: "Tesco",
|
||||||
address: "12 Retail Park Way", postcode: "CB1 3EW",
|
address: "12 Clare Road", postcode: "HX1 2HX",
|
||||||
lat: 52.1955, lng: 0.1380,
|
lat: 53.7200, lng: -1.8630,
|
||||||
prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9],
|
|
||||||
priceUpdated: nil),
|
|
||||||
FuelStation(id: "s2", name: "Tesco Express Hills Road", brand: "Tesco",
|
|
||||||
address: "245 Hills Road", postcode: "CB2 8RP",
|
|
||||||
lat: 52.1809, lng: 0.1398,
|
|
||||||
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
|
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
|
||||||
priceUpdated: nil),
|
priceUpdated: nil),
|
||||||
FuelStation(id: "s3", name: "BP Milton Road", brand: "BP",
|
FuelStation(id: "h2", name: "Shell Skircoat Road", brand: "Shell",
|
||||||
address: "161 Milton Road", postcode: "CB4 1XE",
|
address: "245 Skircoat Road", postcode: "HX3 0HP",
|
||||||
lat: 52.2160, lng: 0.1410,
|
lat: 53.7120, lng: -1.8710,
|
||||||
prices: [.e10: 140.9, .diesel: 147.9],
|
prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9],
|
||||||
priceUpdated: nil),
|
priceUpdated: nil),
|
||||||
FuelStation(id: "s4", name: "Morrisons Newmarket Road", brand: "Morrisons",
|
FuelStation(id: "h3", name: "Morrisons Halifax", brand: "Morrisons",
|
||||||
address: "Newmarket Road", postcode: "CB5 8AA",
|
address: "Haugh Shaw Road", postcode: "HX1 3TU",
|
||||||
lat: 52.2164, lng: 0.1599,
|
lat: 53.7265, lng: -1.8580,
|
||||||
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
|
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
|
||||||
priceUpdated: nil),
|
priceUpdated: nil),
|
||||||
FuelStation(id: "s5", name: "Sainsbury's Coldhams Lane", brand: "Sainsbury's",
|
FuelStation(id: "h4", name: "BP Queensbury", brand: "BP",
|
||||||
address: "Coldhams Lane", postcode: "CB1 3HY",
|
address: "316 High Street, Queensbury", postcode: "BD13 2NB",
|
||||||
lat: 52.2025, lng: 0.1589,
|
lat: 53.7550, lng: -1.8450,
|
||||||
prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9],
|
prices: [.e10: 140.9, .diesel: 147.9],
|
||||||
priceUpdated: nil),
|
priceUpdated: nil),
|
||||||
FuelStation(id: "s6", name: "Esso Cherry Hinton", brand: "Esso",
|
FuelStation(id: "h5", name: "Asda Halifax", brand: "Asda",
|
||||||
address: "Cherry Hinton Road", postcode: "CB1 9AP",
|
address: "Ovenden Way", postcode: "HX2 8DD",
|
||||||
lat: 52.1854, lng: 0.1657,
|
lat: 53.7340, lng: -1.8890,
|
||||||
|
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
|
||||||
|
priceUpdated: nil),
|
||||||
|
FuelStation(id: "h6", name: "Esso Sowerby Bridge", brand: "Esso",
|
||||||
|
address: "Wharf Street", postcode: "HX6 2AH",
|
||||||
|
lat: 53.7080, lng: -1.9080,
|
||||||
prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9],
|
prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9],
|
||||||
priceUpdated: nil),
|
priceUpdated: nil),
|
||||||
FuelStation(id: "s7", name: "Gulf Fen Road", brand: "Gulf",
|
FuelStation(id: "h7", name: "Sainsbury's Elland", brand: "Sainsbury's",
|
||||||
address: "Fen Road", postcode: "CB4 1UN",
|
address: "Southgate", postcode: "HX5 0PA",
|
||||||
lat: 52.2201, lng: 0.1470,
|
lat: 53.6860, lng: -1.8380,
|
||||||
prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9],
|
prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9],
|
||||||
priceUpdated: nil),
|
priceUpdated: nil),
|
||||||
FuelStation(id: "s8", name: "Asda Beehive Centre", brand: "Asda",
|
FuelStation(id: "h8", name: "Gulf Brighouse", brand: "Gulf",
|
||||||
address: "Coldhams Lane", postcode: "CB1 3ER",
|
address: "Bradford Road", postcode: "HD6 1RW",
|
||||||
lat: 52.1995, lng: 0.1641,
|
lat: 53.7000, lng: -1.7850,
|
||||||
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
|
prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9],
|
||||||
priceUpdated: nil),
|
priceUpdated: nil),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import Security
|
|||||||
|
|
||||||
// MARK: - Fuel types
|
// MARK: - Fuel types
|
||||||
|
|
||||||
|
struct Coordinate: Equatable, Codable {
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
}
|
||||||
|
|
||||||
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
||||||
case e10 // Unleaded 95 (E10)
|
case e10 // Unleaded 95 (E10)
|
||||||
case e5 // Premium 97/98 (E5)
|
case e5 // Premium 97/98 (E5)
|
||||||
@@ -94,11 +99,11 @@ struct FuelStore {
|
|||||||
|
|
||||||
// MARK: Last known location ("lat,lng,unixTime")
|
// MARK: Last known location ("lat,lng,unixTime")
|
||||||
|
|
||||||
static func loadLocation() -> (lat: Double, lng: Double, date: Date)? {
|
static func loadLocation() -> Coordinate? {
|
||||||
let raw = loadString(service: locationKey)
|
let raw = loadString(service: locationKey)
|
||||||
let parts = raw?.split(separator: ",").compactMap { Double($0) }
|
let parts = raw?.split(separator: ",").compactMap { Double($0) }
|
||||||
guard let parts, parts.count == 3 else { return nil }
|
guard let parts, parts.count == 3 else { return nil }
|
||||||
return (parts[0], parts[1], Date(timeIntervalSince1970: parts[2]))
|
return Coordinate(lat: parts[0], lng: parts[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveLocation(lat: Double, lng: Double, date: Date = Date()) {
|
static func saveLocation(lat: Double, lng: Double, date: Date = Date()) {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// WidgetLocationFetcher.swift — one-shot location for the WIDGET EXTENSION.
|
||||||
|
//
|
||||||
|
// WidgetKit providers run in a short budget, so this fetcher is deliberately
|
||||||
|
// timeout-bounded: it requests a single location and gives up after a few
|
||||||
|
// seconds, falling back to whatever the caller already has cached.
|
||||||
|
//
|
||||||
|
// Location permission is shared with the containing app (same TCC identity),
|
||||||
|
// so if the user granted "While Using" in FuelBoard, the widget can use
|
||||||
|
// location directly — no extra prompt in the car.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WidgetLocationFetcher: NSObject, CLLocationManagerDelegate {
|
||||||
|
static let shared = WidgetLocationFetcher()
|
||||||
|
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
private var continuation: CheckedContinuation<CLLocation?, Never>?
|
||||||
|
private var timeoutTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
manager.delegate = self
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests the current location. Returns nil on denial, timeout, or
|
||||||
|
/// no-permission — callers should fall back to cached data.
|
||||||
|
func currentLocation(timeout: TimeInterval = 4) async -> CLLocation? {
|
||||||
|
guard CLLocationManager.locationServicesEnabled() else { return nil }
|
||||||
|
|
||||||
|
switch manager.authorizationStatus {
|
||||||
|
case .authorizedWhenInUse, .authorizedAlways:
|
||||||
|
break
|
||||||
|
case .notDetermined:
|
||||||
|
// Widget extensions share the app's TCC identity; if the app was
|
||||||
|
// never opened, the system can't prompt from here — fall back.
|
||||||
|
return nil
|
||||||
|
case .denied, .restricted:
|
||||||
|
return nil
|
||||||
|
@unknown default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
continuation = cont
|
||||||
|
manager.requestLocation()
|
||||||
|
timeoutTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||||
|
self?.continuation?.resume(returning: nil)
|
||||||
|
self?.continuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
timeoutTask?.cancel()
|
||||||
|
continuation?.resume(returning: locations.last)
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
timeoutTask?.cancel()
|
||||||
|
continuation?.resume(returning: nil)
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user