Live map: center on user, persist region, zoom-based aircraft cap
- Initial region cascade: restore last viewed region → on location grant, animate to a city-level view centered on user; otherwise fall back to the saved/continental default. User pans are detected via a center-delta threshold so a late location grant doesn't yank the camera away from where the user is looking. - LocationService: thin one-shot CLLocationManager wrapper with CheckedContinuation. Added NSLocationWhenInUseUsageDescription via INFOPLIST_KEY_ build setting. - Visible-aircraft cap scales with zoom (<2°: uncapped, <8°: 100, <25°: 150, else 200). Active filters bypass the cap entirely so every match always renders. When capped, we keep the N closest to the map center. - Footer shows "Showing N of M" when the cap clips, "N aircraft" otherwise. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,7 @@
|
|||||||
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */; };
|
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */; };
|
||||||
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */; };
|
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */; };
|
||||||
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; };
|
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; };
|
||||||
|
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -124,6 +125,7 @@
|
|||||||
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftDatabase.swift; sourceTree = "<group>"; };
|
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftDatabase.swift; sourceTree = "<group>"; };
|
||||||
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFilterPicker.swift; sourceTree = "<group>"; };
|
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFilterPicker.swift; sourceTree = "<group>"; };
|
||||||
LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; };
|
LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; };
|
||||||
|
LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -243,6 +245,7 @@
|
|||||||
LV3300003333000033330002 /* AircraftRegistry.swift */,
|
LV3300003333000033330002 /* AircraftRegistry.swift */,
|
||||||
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
|
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
|
||||||
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
|
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
|
||||||
|
LVDD000DDDD000DDDD000002 /* LocationService.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -414,6 +417,7 @@
|
|||||||
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */,
|
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */,
|
||||||
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */,
|
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */,
|
||||||
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
|
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
|
||||||
|
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -437,6 +441,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Show your current location on the live flight map so you can quickly see aircraft overhead.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -466,6 +471,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Show your current location on the live flight map so you can quickly see aircraft overhead.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Thin wrapper around CLLocationManager exposing an async one-shot fix
|
||||||
|
/// API. We only need "where is the user right now so I can center the
|
||||||
|
/// map" — not continuous tracking, not background updates. The Live tab
|
||||||
|
/// calls `requestOneShotLocation()` once on appear and is done.
|
||||||
|
///
|
||||||
|
/// Permission state is published so the view can decide between
|
||||||
|
/// (a) centering on the user, (b) restoring last viewed region, or
|
||||||
|
/// (c) showing a continental fallback.
|
||||||
|
@MainActor
|
||||||
|
final class LocationService: NSObject, ObservableObject {
|
||||||
|
static let shared = LocationService()
|
||||||
|
|
||||||
|
@Published private(set) var authorization: CLAuthorizationStatus
|
||||||
|
@Published private(set) var lastKnown: CLLocationCoordinate2D?
|
||||||
|
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
private var pendingContinuations: [CheckedContinuation<CLLocationCoordinate2D?, Never>] = []
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
self.authorization = manager.authorizationStatus
|
||||||
|
super.init()
|
||||||
|
manager.delegate = self
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the OS for permission (if needed) and return the next location
|
||||||
|
/// fix, or nil if the user denied access. Resolves once — does not
|
||||||
|
/// stay subscribed.
|
||||||
|
func requestOneShotLocation() async -> CLLocationCoordinate2D? {
|
||||||
|
switch authorization {
|
||||||
|
case .denied, .restricted:
|
||||||
|
return nil
|
||||||
|
case .notDetermined:
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
// Fall through to request location after the delegate flips
|
||||||
|
// auth — we capture the continuation now and resume it once
|
||||||
|
// location/auth-denied lands.
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
pendingContinuations.append(cont)
|
||||||
|
// requestLocation is a one-shot; safe to call before auth has
|
||||||
|
// been granted — CL will queue it and fire after grant.
|
||||||
|
manager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumeAll(with value: CLLocationCoordinate2D?) {
|
||||||
|
let waiters = pendingContinuations
|
||||||
|
pendingContinuations.removeAll()
|
||||||
|
for c in waiters { c.resume(returning: value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocationService: CLLocationManagerDelegate {
|
||||||
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.authorization = manager.authorizationStatus
|
||||||
|
if manager.authorizationStatus == .denied || manager.authorizationStatus == .restricted {
|
||||||
|
self.resumeAll(with: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
guard let loc = locations.last else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.lastKnown = loc.coordinate
|
||||||
|
self.resumeAll(with: loc.coordinate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.resumeAll(with: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,18 @@ struct LiveFlightsView: View {
|
|||||||
/// every time annotations re-render) don't fire fresh OpenSky calls.
|
/// every time annotations re-render) don't fire fresh OpenSky calls.
|
||||||
@State private var lastFetchedBoundingBox: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)?
|
@State private var lastFetchedBoundingBox: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)?
|
||||||
|
|
||||||
|
/// Total matches against the active filters (pre-cap). The map only
|
||||||
|
/// renders `cachedFilteredAircraft`; the footer uses this to surface
|
||||||
|
/// the "Showing N of M" message when the zoom cap clips the list.
|
||||||
|
@State private var filteredTotal: Int = 0
|
||||||
|
|
||||||
|
/// Set after the first camera change that meaningfully moves away
|
||||||
|
/// from the initial region. While this is false, an incoming location
|
||||||
|
/// fix can re-center the map on the user; after it flips true, the
|
||||||
|
/// user has expressed intent and we leave their pan alone.
|
||||||
|
@State private var userHasInteracted: Bool = false
|
||||||
|
@State private var initialRegionCenter: CLLocationCoordinate2D?
|
||||||
|
|
||||||
enum ActiveSheet: Identifiable {
|
enum ActiveSheet: Identifiable {
|
||||||
case aircraft(LiveAircraft)
|
case aircraft(LiveAircraft)
|
||||||
case settings
|
case settings
|
||||||
@@ -175,6 +187,18 @@ struct LiveFlightsView: View {
|
|||||||
.mapStyle(.standard(elevation: .flat))
|
.mapStyle(.standard(elevation: .flat))
|
||||||
.onMapCameraChange(frequency: .onEnd) { context in
|
.onMapCameraChange(frequency: .onEnd) { context in
|
||||||
visibleRegion = context.region
|
visibleRegion = context.region
|
||||||
|
if let initial = initialRegionCenter {
|
||||||
|
let dLat = abs(context.region.center.latitude - initial.latitude)
|
||||||
|
let dLon = abs(context.region.center.longitude - initial.longitude)
|
||||||
|
if dLat > 0.1 || dLon > 0.1 {
|
||||||
|
userHasInteracted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self.saveRegion(context.region)
|
||||||
|
// Span change shifts the cap, so the visible set might need
|
||||||
|
// to grow/shrink even when the underlying aircraft list is
|
||||||
|
// unchanged.
|
||||||
|
rebuildFilteredAircraft()
|
||||||
Task { await refreshIfRegionChanged() }
|
Task { await refreshIfRegionChanged() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +339,7 @@ struct LiveFlightsView: View {
|
|||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
Text("\(filteredAircraft.count) aircraft")
|
Text(countLabel)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
if let last = lastFetchAt {
|
if let last = lastFetchAt {
|
||||||
@@ -354,6 +378,16 @@ struct LiveFlightsView: View {
|
|||||||
/// cached snapshot; rebuilds happen via the .onChange handlers above.
|
/// cached snapshot; rebuilds happen via the .onChange handlers above.
|
||||||
private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft }
|
private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft }
|
||||||
|
|
||||||
|
/// Footer text. Renders "Showing N of M" when the zoom cap is
|
||||||
|
/// clipping the visible set, else "N aircraft".
|
||||||
|
private var countLabel: String {
|
||||||
|
let shown = cachedFilteredAircraft.count
|
||||||
|
if filteredTotal > shown {
|
||||||
|
return "Showing \(shown) of \(filteredTotal)"
|
||||||
|
}
|
||||||
|
return "\(shown) aircraft"
|
||||||
|
}
|
||||||
|
|
||||||
private func rebuildFilteredAircraft() {
|
private func rebuildFilteredAircraft() {
|
||||||
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
let airlines = selectedAirlineICAO
|
let airlines = selectedAirlineICAO
|
||||||
@@ -361,7 +395,7 @@ struct LiveFlightsView: View {
|
|||||||
let band = selectedAltitudeBand
|
let band = selectedAltitudeBand
|
||||||
let hideGround = hideOnGround
|
let hideGround = hideOnGround
|
||||||
|
|
||||||
cachedFilteredAircraft = aircraft.filter { ac in
|
let filtered = aircraft.filter { ac in
|
||||||
if hideGround && ac.onGround { return false }
|
if hideGround && ac.onGround { return false }
|
||||||
if !airlines.isEmpty {
|
if !airlines.isEmpty {
|
||||||
guard let code = ac.airlineICAO, airlines.contains(code) else { return false }
|
guard let code = ac.airlineICAO, airlines.contains(code) else { return false }
|
||||||
@@ -378,6 +412,83 @@ struct LiveFlightsView: View {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filteredTotal = filtered.count
|
||||||
|
|
||||||
|
// Cap policy: any active filter bypasses the cap so the user
|
||||||
|
// always sees every match for their query. With no filter, we
|
||||||
|
// tie the visible count to the zoom level so a zoomed-out view
|
||||||
|
// doesn't spew 800+ pins onto the map.
|
||||||
|
let hasFilter = !airlines.isEmpty || !types.isEmpty || band != nil || hideGround || !s.isEmpty
|
||||||
|
let cap = Self.capForSpan(visibleRegion?.span)
|
||||||
|
if hasFilter || filtered.count <= cap {
|
||||||
|
cachedFilteredAircraft = filtered
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cap by distance to map center — most users care about what's
|
||||||
|
// overhead first. Squared distance is fine for sorting.
|
||||||
|
if let center = visibleRegion?.center {
|
||||||
|
cachedFilteredAircraft = filtered
|
||||||
|
.map { ($0, Self.squaredDistance($0.coordinate, to: center)) }
|
||||||
|
.sorted { $0.1 < $1.1 }
|
||||||
|
.prefix(cap)
|
||||||
|
.map { $0.0 }
|
||||||
|
} else {
|
||||||
|
cachedFilteredAircraft = Array(filtered.prefix(cap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Target visible count for a given map span. Empirically tuned so
|
||||||
|
/// the map doesn't feel sparse when zoomed out yet stays smooth.
|
||||||
|
/// Below ~2° (≈city/metro) we don't cap at all.
|
||||||
|
private static func capForSpan(_ span: MKCoordinateSpan?) -> Int {
|
||||||
|
guard let span else { return 150 }
|
||||||
|
let maxDelta = max(span.latitudeDelta, span.longitudeDelta)
|
||||||
|
switch maxDelta {
|
||||||
|
case ..<2: return .max
|
||||||
|
case ..<8: return 100
|
||||||
|
case ..<25: return 150
|
||||||
|
default: return 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func squaredDistance(_ a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
|
||||||
|
let dLat = a.latitude - b.latitude
|
||||||
|
let dLon = a.longitude - b.longitude
|
||||||
|
return dLat * dLat + dLon * dLon
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Region persistence
|
||||||
|
|
||||||
|
private struct SavedRegion: Codable {
|
||||||
|
let lat: Double
|
||||||
|
let lon: Double
|
||||||
|
let latDelta: Double
|
||||||
|
let lonDelta: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let savedRegionKey = "live_flights.saved_region"
|
||||||
|
|
||||||
|
private static func loadSavedRegion() -> MKCoordinateRegion? {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: savedRegionKey),
|
||||||
|
let saved = try? JSONDecoder().decode(SavedRegion.self, from: data)
|
||||||
|
else { return nil }
|
||||||
|
return MKCoordinateRegion(
|
||||||
|
center: CLLocationCoordinate2D(latitude: saved.lat, longitude: saved.lon),
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: saved.latDelta, longitudeDelta: saved.lonDelta)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saveRegion(_ r: MKCoordinateRegion) {
|
||||||
|
let payload = SavedRegion(
|
||||||
|
lat: r.center.latitude,
|
||||||
|
lon: r.center.longitude,
|
||||||
|
latDelta: r.span.latitudeDelta,
|
||||||
|
lonDelta: r.span.longitudeDelta
|
||||||
|
)
|
||||||
|
if let data = try? JSONEncoder().encode(payload) {
|
||||||
|
UserDefaults.standard.set(data, forKey: savedRegionKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AirlineFilterItem: Hashable {
|
struct AirlineFilterItem: Hashable {
|
||||||
@@ -445,12 +556,35 @@ struct LiveFlightsView: View {
|
|||||||
/// symptom.
|
/// symptom.
|
||||||
private func autoRefreshLoop() async {
|
private func autoRefreshLoop() async {
|
||||||
if visibleRegion == nil {
|
if visibleRegion == nil {
|
||||||
let initial = MKCoordinateRegion(
|
// Initial region cascade:
|
||||||
|
// 1. Restore the last region the user saw, if we have one
|
||||||
|
// 2. Otherwise fall back to a continental US view
|
||||||
|
// Either way we kick off a one-shot location request in
|
||||||
|
// parallel. If the user grants location *and* hasn't panned
|
||||||
|
// by the time the fix lands, we animate to a city-level
|
||||||
|
// view centered on them.
|
||||||
|
let initial = Self.loadSavedRegion() ?? MKCoordinateRegion(
|
||||||
center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0),
|
center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0),
|
||||||
span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50)
|
span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50)
|
||||||
)
|
)
|
||||||
position = .region(initial)
|
position = .region(initial)
|
||||||
visibleRegion = initial
|
visibleRegion = initial
|
||||||
|
initialRegionCenter = initial.center
|
||||||
|
Task {
|
||||||
|
if let coord = await LocationService.shared.requestOneShotLocation(),
|
||||||
|
!userHasInteracted {
|
||||||
|
let userRegion = MKCoordinateRegion(
|
||||||
|
center: coord,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: 0.6, longitudeDelta: 0.6)
|
||||||
|
)
|
||||||
|
withAnimation(.easeInOut(duration: 0.6)) {
|
||||||
|
position = .region(userRegion)
|
||||||
|
}
|
||||||
|
visibleRegion = userRegion
|
||||||
|
initialRegionCenter = userRegion.center
|
||||||
|
Self.saveRegion(userRegion)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await refreshNow()
|
await refreshNow()
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
|
|||||||
Reference in New Issue
Block a user