How to populate model properties when one of the properties is computed from another two obtained through an API call?

85 Views Asked by At

[Solved]

I was not treating reverseGeocode as an asynchronous function, leading to timing issues and data not populating correctly. Using async await solved the issue.

Solution:

func getCurrentWeather(latitude: CLLocationDegrees, longitude: CLLocationDegrees) async throws -> WeatherReport? {
        do {
            guard let url = URL(string: "https://api.openweathermap.org/data/3.0/onecall?lat=\(latitude)&lon=\(longitude)&exclude=current,minutely,hourly, alerts&appid=19054ace743dfdfd9&units=imperial") else { throw NetworkError.badURL}
            
            let (data, response) = try await URLSession.shared.data(from: url)
            
            guard let response = response as? HTTPURLResponse else { throw NetworkError.badResponse }
            guard response.statusCode >= 200 && response.statusCode < 300 else { throw NetworkError.badStatus }
            
            let decodedData = try JSONDecoder().decode(WeatherReport.self, from: data)
            let decodedDataWithCity = try await decodedData.reverseGeocode()
            return decodedDataWithCity
            
        } catch NetworkError.badURL {
            print("Error creating URL")
        } catch NetworkError.badResponse {
            print("Didn't get a valid response")
        } catch NetworkError.badStatus {
            print("Didn't get a 2xx status code from response")
        } catch {
            print("An error occurred downloading the data")
        }
        
        return nil
    }
func reverseGeocode() async throws -> WeatherReport? {
        let geocoder = CLGeocoder()
        let location = CLLocation(latitude: lat, longitude: lon)
        var report: WeatherReport?
        
        do {
            let placemarks = try await geocoder.reverseGeocodeLocation(location)
            let placemark = placemarks.first
            report = WeatherReport(lat: lat, lon: lon, city: placemark?.locality, timezone: timezone, timezoneOffset: timezoneOffset, daily: daily)
        } catch {
            print("Error: \(error.localizedDescription)")
        }
        
        return report
    }

func getCurrentWeather(latitude: CLLocationDegrees, longitude: CLLocationDegrees) async throws -> WeatherReport? {
        do {
            guard let url = URL(string: "https://api.openweathermap.org/data/3.0/onecall?lat=\(latitude)&lon=\(longitude)&exclude=current,minutely,hourly, alerts&appid=19054ace749133743dfdfd9&units=imperial") else { throw NetworkError.badURL}
            
            let (data, response) = try await URLSession.shared.data(from: url)
            
            guard let response = response as? HTTPURLResponse else { throw NetworkError.badResponse }
            guard response.statusCode >= 200 && response.statusCode < 300 else { throw NetworkError.badStatus }
            
            let decodedData = try JSONDecoder().decode(WeatherReport.self, from: data)
            let decodedDataWithCity = decodedData.reverseGeocode(latitude: decodedData.lat, longitude: decodedData.lon)
            return decodedDataWithCity
            
        } catch NetworkError.badURL {
            print("Error creating URL")
        } catch NetworkError.badResponse {
            print("Didn't get a valid response")
        } catch NetworkError.badStatus {
            print("Didn't get a 2xx status code from response")
        } catch {
            print("An error occurred downloading the data")
        }
        
        return nil
    }

[Original Post]

I'm building a simple weather app that displays:

  • User's city name (having trouble with this part)
  • Current temp
  • Daily min/max temp
  • Wind speed
  • Humidity

The OpenWeatherMap API provides data for everything except city name, and I am having trouble figuring out the logic flow to obtain it.


My current (failed) approach is:

  1. Create a model called WeatherReport containing all properties of a weather report.
struct WeatherReport: Codable {
    var lat, lon: Double
    var city: String?
    let timezone: String
    let timezoneOffset: Int
    let daily: [Daily]
  • I've added city here as optional because the API called to populate these properties will not return a city name.
  • city a computed property that's determined through reverse geocoding (provide latitude, longitude and return location name).

  1. Add .task to download data from API and decode into model (this is working).
func getCurrentWeather(latitude: CLLocationDegrees, longitude: CLLocationDegrees) async throws -> WeatherReport? {
        do {
            guard let url = URL(string: "https://api.openweathermap.org/data/3.0/onecall?lat=\(latitude)&lon=\(longitude)&exclude=current,minutely,hourly, alerts&appid=19054ace7493dfdfd9&units=imperial") else { throw NetworkError.badURL}
            
            let (data, response) = try await URLSession.shared.data(from: url)
            
            guard let response = response as? HTTPURLResponse else { throw NetworkError.badResponse }
            guard response.statusCode >= 200 && response.statusCode < 300 else { throw NetworkError.badStatus }
            
            let decodedData = try JSONDecoder().decode(WeatherReport.self, from: data)
            return decodedData
            
        } catch NetworkError.badURL {
            print("Error creating URL")
        } catch NetworkError.badResponse {
            print("Didn't get a valid response")
        } catch NetworkError.badStatus {
            print("Didn't get a 2xx status code from response")
        } catch {
            print("An error occurred downloading the data")
        }
        
        return nil
    }

(Here's where I'm having all the trouble)

  1. Populate the city property now that we have lat/lon data available.

Attempt #1

  • Create reverseGeocode function that takes latitute/longitude (CLLocationDegrees) and returns a city name (String).
  • Use didSet property observer on lat/lon properties to call reverseGeocode function to compute city

Failed because 'var' declarations with multiple variables cannot have explicit getters/setters

var lat, lon: Double {
        didSet {
            self.city = reverseGeocode(latitude: lat, longitude: lon)
        }
    }
func reverseGeocode(latitude: CLLocationDegrees, longitude: CLLocationDegrees) -> String {
        let geocoder = CLGeocoder()
        let location = CLLocation(latitude: latitude, longitude: longitude)
        var cityName: String
        
        geocoder.reverseGeocodeLocation(location) { placemarks, error in
            print("in geocoder.reverseGeocodeLocation function")
            // ensure no error
            guard error == nil else { return }
            
            // ensure there are placemarks
            if let placemarks = placemarks,
               let placemark = placemarks.first {
                cityName = placemark.locality ?? "Current location"
            }
        }
        
        return cityName
    }

Attempt #2

  • Change the reverseGeocode method to return a WeatherReport rather than a String
  • Within the original API call, once a WeatherReport (excluding city name) has been successfully decoded - we call reverseGeocode method to generate a new WeatherReport that does include city name.

Failed because it's hung up on the async task of obtaining the WeatherReport to generate the view. Originally thought the reverseGeocode method wasn't working, but added some print statements inside it that confirms it is.

The view is stuck on the loading spinner while waiting for the WeatherReport to come in.

@EnvironmentObject private var locationManager: LocationManager
private let weatherManager = WeatherManager()
@State var weatherReport: WeatherReport?

var body: some View {
        ZStack {
            // if location exists...
            if let location = locationManager.location {
                // ..and weather report exists
                if let weatherReport = weatherReport {
                    // show WeatherView
                    WeatherView(weatherReport: weatherReport)
                } else {
                    // show loading spinner
                    LoadingView()
                        .task {
                            do {
                                // obtain weather report
                                weatherReport = try await weatherManager.getCurrentWeather(latitude: location.latitude, longitude: location.longitude)
                            } catch {
                                print("Unable to get weather info - Error: \(error.localizedDescription)")
                            }
                        }
                }
func getCurrentWeather(latitude: CLLocationDegrees, longitude: CLLocationDegrees) async throws -> WeatherReport? {
        do {
            guard let url = URL(string: "https://api.openweathermap.org/data/3.0/onecall?lat=\(latitude)&lon=\(longitude)&exclude=current,minutely,hourly, alerts&appid=19054ace749743dfdfd9&units=imperial") else { throw NetworkError.badURL}
            
            let (data, response) = try await URLSession.shared.data(from: url)
            
            guard let response = response as? HTTPURLResponse else { throw NetworkError.badResponse }
            guard response.statusCode >= 200 && response.statusCode < 300 else { throw NetworkError.badStatus }
            
            let decodedData = try JSONDecoder().decode(WeatherReport.self, from: data)
            let decodedDataWithCity = decodedData.reverseGeocode(latitude: decodedData.lat, longitude: decodedData.lon)
            return decodedDataWithCity
            
        } catch NetworkError.badURL {
            print("Error creating URL")
        } catch NetworkError.badResponse {
            print("Didn't get a valid response")
        } catch NetworkError.badStatus {
            print("Didn't get a 2xx status code from response")
        } catch {
            print("An error occurred downloading the data")
        }
        
        return nil
    }
func reverseGeocode(latitude: CLLocationDegrees, longitude: CLLocationDegrees) -> WeatherReport {
        let geocoder = CLGeocoder()
        let location = CLLocation(latitude: latitude, longitude: longitude)
        var report: WeatherReport
        
        geocoder.reverseGeocodeLocation(location) { placemarks, error in
            print("in geocoder.reverseGeocodeLocation function")
            // ensure no error
            guard error == nil else { return }
            
            // ensure there are placemarks
            if let placemarks = placemarks,
               let placemark = placemarks.first {
                report = WeatherReport(lat: lat, lon: lon, city: placemark.locality, timezone: timezone, timezoneOffset: timezoneOffset, daily: daily)
                print("report: \(report)")
            }
        }
        
        return report
    }

For additional context here's the view I'm trying to build. Any help would be greatly appreciated, thanks!

enter image description here

1

There are 1 best solutions below

0
malhal On

You could make a CityView, e.g.

struct CityView: View {
    @Environment(\.myGeocoder) var myGeocoder
    let lat, lon: Double
    @State var city: City?

    var body: some View {
        Text(city.name ?? "Loading...")
        .task(id: [lat, lon]) {
            do {
                city = try await myGeocoder.reverseGeocode(latitude: lat, longitude: lon)
            }
            catch {
                print(error.localizedDescription)
            }
        }
    }
}