Commit 99d03b18 by Demid Merzlyakov

Merge branch 'feature/air-quality-parsing'

# Conflicts:
#	1Weather/UI/View controllers/Today/Cells/TodayAirQualityCell/TodayAirQualityCell.swift
parents 1167521c 1f4dd173
...@@ -119,7 +119,7 @@ ...@@ -119,7 +119,7 @@
CDEE8AD725DA882200C289DE /* ForecastPeriodButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEE8AD625DA882200C289DE /* ForecastPeriodButton.swift */; }; CDEE8AD725DA882200C289DE /* ForecastPeriodButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEE8AD625DA882200C289DE /* ForecastPeriodButton.swift */; };
CDF9BF8E26133D050037847D /* LocationSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF9BF8D26133D050037847D /* LocationSearchCoordinator.swift */; }; CDF9BF8E26133D050037847D /* LocationSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF9BF8D26133D050037847D /* LocationSearchCoordinator.swift */; };
CE28474F26159857006C8DC5 /* HealthSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28474E26159857006C8DC5 /* HealthSource.swift */; }; CE28474F26159857006C8DC5 /* HealthSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28474E26159857006C8DC5 /* HealthSource.swift */; };
CE28475226159A32006C8DC5 /* BlendHealthCenterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28475126159A32006C8DC5 /* BlendHealthCenterModels.swift */; }; CE28475226159A32006C8DC5 /* BlendHealthModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28475126159A32006C8DC5 /* BlendHealthModels.swift */; };
CE28475D2615A5B3006C8DC5 /* Health.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28475C2615A5B3006C8DC5 /* Health.swift */; }; CE28475D2615A5B3006C8DC5 /* Health.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28475C2615A5B3006C8DC5 /* Health.swift */; };
CE2847602615A8AD006C8DC5 /* BlendHealthSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28475F2615A8AD006C8DC5 /* BlendHealthSource.swift */; }; CE2847602615A8AD006C8DC5 /* BlendHealthSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28475F2615A8AD006C8DC5 /* BlendHealthSource.swift */; };
CE578FD325F7E89400E8B85D /* DayTimeWeather.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE578FD225F7E89400E8B85D /* DayTimeWeather.swift */; }; CE578FD325F7E89400E8B85D /* DayTimeWeather.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE578FD225F7E89400E8B85D /* DayTimeWeather.swift */; };
...@@ -276,7 +276,7 @@ ...@@ -276,7 +276,7 @@
CDEE8AD625DA882200C289DE /* ForecastPeriodButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastPeriodButton.swift; sourceTree = "<group>"; }; CDEE8AD625DA882200C289DE /* ForecastPeriodButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastPeriodButton.swift; sourceTree = "<group>"; };
CDF9BF8D26133D050037847D /* LocationSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSearchCoordinator.swift; sourceTree = "<group>"; }; CDF9BF8D26133D050037847D /* LocationSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSearchCoordinator.swift; sourceTree = "<group>"; };
CE28474E26159857006C8DC5 /* HealthSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthSource.swift; sourceTree = "<group>"; }; CE28474E26159857006C8DC5 /* HealthSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthSource.swift; sourceTree = "<group>"; };
CE28475126159A32006C8DC5 /* BlendHealthCenterModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlendHealthCenterModels.swift; sourceTree = "<group>"; }; CE28475126159A32006C8DC5 /* BlendHealthModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlendHealthModels.swift; sourceTree = "<group>"; };
CE28475C2615A5B3006C8DC5 /* Health.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Health.swift; sourceTree = "<group>"; }; CE28475C2615A5B3006C8DC5 /* Health.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Health.swift; sourceTree = "<group>"; };
CE28475F2615A8AD006C8DC5 /* BlendHealthSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlendHealthSource.swift; sourceTree = "<group>"; }; CE28475F2615A8AD006C8DC5 /* BlendHealthSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlendHealthSource.swift; sourceTree = "<group>"; };
CE578FD225F7E89400E8B85D /* DayTimeWeather.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayTimeWeather.swift; sourceTree = "<group>"; }; CE578FD225F7E89400E8B85D /* DayTimeWeather.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayTimeWeather.swift; sourceTree = "<group>"; };
...@@ -761,7 +761,7 @@ ...@@ -761,7 +761,7 @@
CE28474D261597F1006C8DC5 /* Model */ = { CE28474D261597F1006C8DC5 /* Model */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CE28475126159A32006C8DC5 /* BlendHealthCenterModels.swift */, CE28475126159A32006C8DC5 /* BlendHealthModels.swift */,
); );
path = Model; path = Model;
sourceTree = "<group>"; sourceTree = "<group>";
...@@ -1072,7 +1072,7 @@ ...@@ -1072,7 +1072,7 @@
CE28474F26159857006C8DC5 /* HealthSource.swift in Sources */, CE28474F26159857006C8DC5 /* HealthSource.swift in Sources */,
CEAFF08C25DFC6BD00DF4EBF /* DailyWeather.swift in Sources */, CEAFF08C25DFC6BD00DF4EBF /* DailyWeather.swift in Sources */,
CEDE4F0B25EFA3A7007457E9 /* UpdatableModelObject.swift in Sources */, CEDE4F0B25EFA3A7007457E9 /* UpdatableModelObject.swift in Sources */,
CE28475226159A32006C8DC5 /* BlendHealthCenterModels.swift in Sources */, CE28475226159A32006C8DC5 /* BlendHealthModels.swift in Sources */,
87C171EE25FF79CC00DA3464 /* AdConfigManager.swift in Sources */, 87C171EE25FF79CC00DA3464 /* AdConfigManager.swift in Sources */,
CDD0F1E82572429E00CF5017 /* AppFont.swift in Sources */, CDD0F1E82572429E00CF5017 /* AppFont.swift in Sources */,
CE28475D2615A5B3006C8DC5 /* Health.swift in Sources */, CE28475D2615A5B3006C8DC5 /* Health.swift in Sources */,
......
...@@ -20,6 +20,17 @@ ...@@ -20,6 +20,17 @@
<string>1</string> <string>1</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string>1Weather uses your location to provide you with weather forecasts and ads. For more info visit 1weatherapp.com/privacy</string>
<key>UIAppFonts</key> <key>UIAppFonts</key>
<array> <array>
<string>SF-Pro.ttf</string> <string>SF-Pro.ttf</string>
...@@ -43,7 +54,5 @@ ...@@ -43,7 +54,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>1Weather uses your location to provide you with weather forecasts and ads. For more info visit 1weatherapp.com/privacy</string>
</dict> </dict>
</plist> </plist>
...@@ -20,6 +20,7 @@ public class LocationManager { ...@@ -20,6 +20,7 @@ public class LocationManager {
private let deviceLocationMonitor: DeviceLocationMonitor private let deviceLocationMonitor: DeviceLocationMonitor
private let weatherUpdateSource: WeatherSource private let weatherUpdateSource: WeatherSource
private let healthSource: HealthSource
private var defaultLocation = Location(deviceLocation: false, private var defaultLocation = Location(deviceLocation: false,
coordinates: .init(latitude: 37.3230, longitude: -122.0322), // Cupertino coordinates: .init(latitude: 37.3230, longitude: -122.0322), // Cupertino
timeZone: TimeZone(abbreviation: "PST")!) { timeZone: TimeZone(abbreviation: "PST")!) {
...@@ -124,11 +125,12 @@ public class LocationManager { ...@@ -124,11 +125,12 @@ public class LocationManager {
} }
} }
public static let shared = LocationManager(weatherUpdateSource: WdtWeatherSource()) public static let shared = LocationManager(weatherUpdateSource: WdtWeatherSource(), healthSource: BlendHealthSource())
public let maxLocationsCount = 12 public let maxLocationsCount = 12
public init(weatherUpdateSource: WeatherSource) { public init(weatherUpdateSource: WeatherSource, healthSource: HealthSource) {
self.weatherUpdateSource = weatherUpdateSource self.weatherUpdateSource = weatherUpdateSource
self.healthSource = healthSource
self.deviceLocationMonitor = DeviceLocationMonitor() self.deviceLocationMonitor = DeviceLocationMonitor()
self.deviceLocationMonitor.delegate = self self.deviceLocationMonitor.delegate = self
} }
...@@ -138,6 +140,7 @@ public class LocationManager { ...@@ -138,6 +140,7 @@ public class LocationManager {
guard locations.count > 0 else { guard locations.count > 0 else {
log.info("Update all: update default location if needed.") log.info("Update all: update default location if needed.")
updateWeather(for: defaultLocation, updateType: .full) updateWeather(for: defaultLocation, updateType: .full)
updateHealth(for: defaultLocation)
return return
} }
log.info("Update all \(locations.count) locations if needed...") log.info("Update all \(locations.count) locations if needed...")
...@@ -148,6 +151,41 @@ public class LocationManager { ...@@ -148,6 +151,41 @@ public class LocationManager {
if selectedLocation != location { if selectedLocation != location {
updateWeather(for: location, updateType: .preferIncremental) updateWeather(for: location, updateType: .preferIncremental)
} }
updateHealth(for: location)
}
}
public func updateHealth(for location: Location) {
if let lastTimeUpdated = location.health?.lastUpdateTime {
guard Date().timeIntervalSince(lastTimeUpdated) >= healthSource.healthUpdateInterval else {
log.info("Update health (\(location)): fresh enough (last updated at \(lastTimeUpdated)), skip update.")
return
}
}
log.info("Update health for: \(location)")
healthSource.updateHelath(for: location) { [weak self] (health, error) in
guard let self = self else { return }
guard let health = health else {
if let error = error {
self.log.error("Update health (\(location)) error: \(error)")
}
else {
self.log.error("Update health (\(location)) error: unknown error")
}
return
// TODO: we need to somehow track failed attempts, so that we didn't request again and again in case of an error (e.g. server is down).
}
DispatchQueue.main.async {
if let indexToUpdate = self.locations.firstIndex(where: { $0 == location }) {
self.locations[indexToUpdate].health = health
}
else if self.defaultLocation == location {
self.defaultLocation.health = health
}
else {
self.log.warning("Update health: Failed to find location after update. Maybe it was deleted while the update was performed. Maybe something went wrong. Location: \(location)")
}
}
} }
} }
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
import Foundation import Foundation
public enum BlendHealthError: Error { public enum BlendHealthSourceError: Error {
case insufficientLocationInfo case insufficientLocationInfo
case badUrl case badUrl
case networkError(Error?) case networkError(Error?)
...@@ -17,6 +17,100 @@ public enum BlendHealthError: Error { ...@@ -17,6 +17,100 @@ public enum BlendHealthError: Error {
case alreadyBeingUpdated case alreadyBeingUpdated
} }
public class BlendHealthSource { public class BlendHealthSource: HealthSource {
private let log = Logger(componentName: "BlendHealthSource")
#warning("Not implemented: staging / prod switching!")
//TODO: Not implemented: staging / prod switching!
private static let healthCenterUrlStaging = "http://sta-1w-dataaggregator.onelouder.com/1weather/api/v1/weather/current"
private static let healthCenterUrlProduction = "https://pro-1w-dataaggregator.onelouder.com/1weather/api/v1/weather/current"
private static var healthCenterUrl: String {
return healthCenterUrlProduction
}
private static let blendAPIKeyHeaderName = "blend-api-key"
private static let blendAPIKey = "0imfnc8mVLWwsAawjYr4Rx-Af50DDqtlx"
public var healthUpdateInterval: TimeInterval = TimeInterval(15 * 60) // 15 minutes
/// This queue is needed to synchronize access to locationsBeingUpdated. Also, to make logging more clear.
private let internalQueue: OperationQueue = {
let queue = OperationQueue()
queue.name = "BlendHealthSource Queue"
queue.maxConcurrentOperationCount = 1
return queue
}()
private var locationsBeingUpdated = Set<Location>()
public func updateHelath(for location: Location, completion: @escaping HealthSourceCompletion) {
internalQueue.addOperation { [weak self] in
let extendedCompletion: HealthSourceCompletion = { [weak self] (health, error) in
self?.internalQueue.addOperation {
completion(health, error)
self?.locationsBeingUpdated.remove(location)
}
}
self?.updateHelathInternal(for: location, completion: extendedCompletion)
}
}
private func updateHelathInternal(for location: Location, completion: @escaping HealthSourceCompletion) {
guard !locationsBeingUpdated.contains(location) else {
completion(nil, BlendHealthSourceError.alreadyBeingUpdated)
return
}
locationsBeingUpdated.insert(location)
guard var urlComponents = URLComponents(string: BlendHealthSource.healthCenterUrl) else {
assertionFailure("Should never happen. The URL should be correct.")
return
}
var queryParameters = [String: String]()
if let coordinates = location.coordinates {
queryParameters["lat"] = String(format: "%.5f", coordinates.latitude)
queryParameters["lon"] = String(format: "%.5f", coordinates.longitude)
}
queryParameters["zip"] = location.zip
queryParameters["city"] = location.cityName
queryParameters["state"] = location.region
queryParameters["country"] = location.countryCode
guard !queryParameters.isEmpty else {
completion(nil, BlendHealthSourceError.insufficientLocationInfo)
log.error("Not enough information about location.")
return
}
urlComponents.queryItems = queryParameters.map { URLQueryItem(name: $0, value: $1) }
guard let url = urlComponents.url else {
completion(nil, BlendHealthSourceError.badUrl)
return
}
log.debug("query params: \(queryParameters)")
var request = URLRequest(url: url)
var headers = request.allHTTPHeaderFields ?? [String: String]()
headers[BlendHealthSource.blendAPIKeyHeaderName] = BlendHealthSource.blendAPIKey
request.allHTTPHeaderFields = headers
let urlSession = URLSession.shared
let dataTask = urlSession.dataTask(with: request) { (data, reponse, error) in
// TODO: check response HTTP code
guard let data = data else {
completion(nil, BlendHealthSourceError.networkError(error))
return
}
let decoder = JSONDecoder()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
decoder.dateDecodingStrategy = .formatted(dateFormatter)
do {
let updatedHealth = try decoder.decode(BlendHealthCenter.self, from: data)
let appHealth = updatedHealth.toAppModel()
completion(appHealth, nil)
}
catch {
completion(nil, BlendHealthSourceError.badServerResponse(error))
}
}
dataTask.resume()
}
} }
...@@ -10,18 +10,30 @@ import UIKit ...@@ -10,18 +10,30 @@ import UIKit
// MARK: - HealthCenter // MARK: - HealthCenter
struct BlendHealthCenter: Codable { struct BlendHealthCenter: Codable {
public let s2CellID: String
public let updatedOn: Date public let updatedOn: Date
public let airQuality: BlendAirQuality public let airQuality: BlendAirQuality?
public let fire: BlendFire public let fire: BlendFire
public let pollutants, pollen: [BlendPoll] public let pollutants, pollen: [BlendPoll]?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case s2CellID = "s2_cell_id"
case updatedOn = "updated_on" case updatedOn = "updated_on"
case airQuality = "air_quality" case airQuality = "air_quality"
case fire, pollutants, pollen case fire, pollutants, pollen
} }
func toAppModel() -> Health {
let airQuality = self.airQuality?.toAppModel()
let pollutants = self.pollutants?.reduce([String: Pollutant]()) { (dict, blendPoll) in
var dict = dict
if let pollutant = blendPoll.toAppModel() {
dict[pollutant.name] = pollutant
}
return dict
}
let result = Health(lastUpdateTime: Date(), airQuality: airQuality, pollutants: pollutants ?? [:])
return result
}
} }
// MARK: - AirQuality // MARK: - AirQuality
...@@ -39,6 +51,13 @@ struct BlendAirQuality: Codable { ...@@ -39,6 +51,13 @@ struct BlendAirQuality: Codable {
case color = "color_code" case color = "color_code"
case imageURL = "image_url" case imageURL = "image_url"
} }
func toAppModel() -> AirQuality {
#warning("Not implemented!")
//TODO: implement health advice groups support (general / sensitive / active)
let result = AirQuality(index: aqi, advice: healthAdvice.general)
return result
}
} }
// MARK: - HealthAdvice // MARK: - HealthAdvice
...@@ -72,6 +91,17 @@ struct BlendPoll: Codable { ...@@ -72,6 +91,17 @@ struct BlendPoll: Codable {
case status case status
case colorCode = "color_code" case colorCode = "color_code"
} }
// Note: the same model in Blend is also used for pollen, which, in my opinion, should be
// a different app-level model object.
func toAppModel() -> Pollutant? {
// Note: pollutants always seem to have a value. Pollen doesn't.
guard let value = value else {
return nil
}
let result = Pollutant(name: name, value: value)
return result
}
} }
struct BlendColor: Codable { struct BlendColor: Codable {
...@@ -85,7 +115,7 @@ struct BlendColor: Codable { ...@@ -85,7 +115,7 @@ struct BlendColor: Codable {
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
guard let hexRepresentation = uiColor.toHex() else { guard let hexRepresentation = uiColor.toHex() else {
throw BlendHealthError.dataEncodingError("BlendColor to HEX conversion failed for color \(uiColor)") throw BlendHealthSourceError.dataEncodingError("BlendColor to HEX conversion failed for color \(uiColor)")
} }
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
try container.encode("#\(hexRepresentation)") try container.encode("#\(hexRepresentation)")
......
...@@ -31,7 +31,6 @@ public class WdtWeatherSource: WeatherSource { ...@@ -31,7 +31,6 @@ public class WdtWeatherSource: WeatherSource {
return queue return queue
}() }()
/// This is used
private var locationsBeingUpdated = Set<Location>() private var locationsBeingUpdated = Set<Location>()
public let weatherUpdateInterval = TimeInterval(15 * 60) // 15 minutes public let weatherUpdateInterval = TimeInterval(15 * 60) // 15 minutes
......
...@@ -47,11 +47,11 @@ class TodayAirQualityCell: UITableViewCell { ...@@ -47,11 +47,11 @@ class TodayAirQualityCell: UITableViewCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
public func configure(health: Health) { public func configure(health: Health?) {
//Air quality label //Air quality label
airQualityValueLabel.text = "\(Int(health.airQuality?.index ?? 0))" airQualityValueLabel.text = "\(Int(health?.airQuality?.index ?? 0))"
let aqiText = "air.quality.is".localized() let aqiText = "air.quality.is".localized()
let aqiConditionText = health.airQuality?.status.localized ?? "" let aqiConditionText = health?.airQuality?.status.localized ?? ""
let attrString = NSMutableAttributedString(string: "\(aqiText)\n\(aqiConditionText)", let attrString = NSMutableAttributedString(string: "\(aqiText)\n\(aqiConditionText)",
attributes: [.font : AppFont.SFPro.regular(size: 24), attributes: [.font : AppFont.SFPro.regular(size: 24),
.foregroundColor :ThemeManager.currentTheme.secondaryTextColor]) .foregroundColor :ThemeManager.currentTheme.secondaryTextColor])
...@@ -62,7 +62,7 @@ class TodayAirQualityCell: UITableViewCell { ...@@ -62,7 +62,7 @@ class TodayAirQualityCell: UITableViewCell {
//Fill pollutions //Fill pollutions
stackView.removeAll() stackView.removeAll()
health.pollutants.map{$1}.forEach { health?.pollutants.map{$1}.forEach {
let pollutionView = PollutantView() let pollutionView = PollutantView()
pollutionView.configure(pollutant: $0) pollutionView.configure(pollutant: $0)
stackView.addArrangedSubview(pollutionView) stackView.addArrangedSubview(pollutionView)
......
...@@ -40,12 +40,12 @@ class TodayCellFactory: CellFactoryProtocol { ...@@ -40,12 +40,12 @@ class TodayCellFactory: CellFactoryProtocol {
private var todaySection = TodaySection(rows: [.alert, .forecast, .ad, private var todaySection = TodaySection(rows: [.alert, .forecast, .ad,
.conditions, .forecastPeriod, .precipitation, .conditions, .forecastPeriod, .precipitation,
.airQuality, .dayTime, .sun, .moon]) .airQuality, .dayTime, .sun, .moon])
private let health = Health(lastUpdateTime: Date(), // private let health = Health(lastUpdateTime: Date(),
airQuality: .init(index: 48, advice: "some"), // airQuality: .init(index: 48, advice: "some"),
pollutants: ["pm25" : .init(name: "PM 2.5", value: 48), // pollutants: ["pm25" : .init(name: "PM 2.5", value: 48),
"pm10" : .init(name: "PM 10", value: 42), // "pm10" : .init(name: "PM 10", value: 42),
"no2" : .init(name: "NO2", value: 74), // "no2" : .init(name: "NO2", value: 74),
"so2" : .init(name: "SO2", value: 135)]) // "so2" : .init(name: "SO2", value: 135)])
//Public //Public
init(viewModel: TodayViewModel) { init(viewModel: TodayViewModel) {
...@@ -112,7 +112,7 @@ class TodayCellFactory: CellFactoryProtocol { ...@@ -112,7 +112,7 @@ class TodayCellFactory: CellFactoryProtocol {
return cell return cell
case .airQuality: case .airQuality:
let cell = dequeueReusableCell(type: TodayAirQualityCell.self, tableView: tableView, indexPath: indexPath) let cell = dequeueReusableCell(type: TodayAirQualityCell.self, tableView: tableView, indexPath: indexPath)
cell.configure(health: self.health) cell.configure(health: loc.health)
return cell return cell
case .dayTime: case .dayTime:
let cell = dequeueReusableCell(type: TodayDayTimesCell.self, tableView: tableView, indexPath: indexPath) let cell = dequeueReusableCell(type: TodayDayTimesCell.self, tableView: tableView, indexPath: indexPath)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment