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 @@
CDEE8AD725DA882200C289DE /* ForecastPeriodButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEE8AD625DA882200C289DE /* ForecastPeriodButton.swift */; };
CDF9BF8E26133D050037847D /* LocationSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF9BF8D26133D050037847D /* LocationSearchCoordinator.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 */; };
CE2847602615A8AD006C8DC5 /* BlendHealthSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE28475F2615A8AD006C8DC5 /* BlendHealthSource.swift */; };
CE578FD325F7E89400E8B85D /* DayTimeWeather.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE578FD225F7E89400E8B85D /* DayTimeWeather.swift */; };
......@@ -276,7 +276,7 @@
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>"; };
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>"; };
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>"; };
......@@ -761,7 +761,7 @@
CE28474D261597F1006C8DC5 /* Model */ = {
isa = PBXGroup;
children = (
CE28475126159A32006C8DC5 /* BlendHealthCenterModels.swift */,
CE28475126159A32006C8DC5 /* BlendHealthModels.swift */,
);
path = Model;
sourceTree = "<group>";
......@@ -1072,7 +1072,7 @@
CE28474F26159857006C8DC5 /* HealthSource.swift in Sources */,
CEAFF08C25DFC6BD00DF4EBF /* DailyWeather.swift in Sources */,
CEDE4F0B25EFA3A7007457E9 /* UpdatableModelObject.swift in Sources */,
CE28475226159A32006C8DC5 /* BlendHealthCenterModels.swift in Sources */,
CE28475226159A32006C8DC5 /* BlendHealthModels.swift in Sources */,
87C171EE25FF79CC00DA3464 /* AdConfigManager.swift in Sources */,
CDD0F1E82572429E00CF5017 /* AppFont.swift in Sources */,
CE28475D2615A5B3006C8DC5 /* Health.swift in Sources */,
......
......@@ -20,6 +20,17 @@
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<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>
<array>
<string>SF-Pro.ttf</string>
......@@ -43,7 +54,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</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>
</plist>
......@@ -20,6 +20,7 @@ public class LocationManager {
private let deviceLocationMonitor: DeviceLocationMonitor
private let weatherUpdateSource: WeatherSource
private let healthSource: HealthSource
private var defaultLocation = Location(deviceLocation: false,
coordinates: .init(latitude: 37.3230, longitude: -122.0322), // Cupertino
timeZone: TimeZone(abbreviation: "PST")!) {
......@@ -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 init(weatherUpdateSource: WeatherSource) {
public init(weatherUpdateSource: WeatherSource, healthSource: HealthSource) {
self.weatherUpdateSource = weatherUpdateSource
self.healthSource = healthSource
self.deviceLocationMonitor = DeviceLocationMonitor()
self.deviceLocationMonitor.delegate = self
}
......@@ -138,6 +140,7 @@ public class LocationManager {
guard locations.count > 0 else {
log.info("Update all: update default location if needed.")
updateWeather(for: defaultLocation, updateType: .full)
updateHealth(for: defaultLocation)
return
}
log.info("Update all \(locations.count) locations if needed...")
......@@ -148,6 +151,41 @@ public class LocationManager {
if selectedLocation != location {
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 @@
import Foundation
public enum BlendHealthError: Error {
public enum BlendHealthSourceError: Error {
case insufficientLocationInfo
case badUrl
case networkError(Error?)
......@@ -17,6 +17,100 @@ public enum BlendHealthError: Error {
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
// MARK: - HealthCenter
struct BlendHealthCenter: Codable {
public let s2CellID: String
public let updatedOn: Date
public let airQuality: BlendAirQuality
public let airQuality: BlendAirQuality?
public let fire: BlendFire
public let pollutants, pollen: [BlendPoll]
public let pollutants, pollen: [BlendPoll]?
enum CodingKeys: String, CodingKey {
case s2CellID = "s2_cell_id"
case updatedOn = "updated_on"
case airQuality = "air_quality"
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
......@@ -39,6 +51,13 @@ struct BlendAirQuality: Codable {
case color = "color_code"
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
......@@ -72,6 +91,17 @@ struct BlendPoll: Codable {
case status
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 {
......@@ -85,7 +115,7 @@ struct BlendColor: Codable {
func encode(to encoder: Encoder) throws {
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()
try container.encode("#\(hexRepresentation)")
......
......@@ -31,7 +31,6 @@ public class WdtWeatherSource: WeatherSource {
return queue
}()
/// This is used
private var locationsBeingUpdated = Set<Location>()
public let weatherUpdateInterval = TimeInterval(15 * 60) // 15 minutes
......
......@@ -47,11 +47,11 @@ class TodayAirQualityCell: UITableViewCell {
fatalError("init(coder:) has not been implemented")
}
public func configure(health: Health) {
public func configure(health: Health?) {
//Air quality label
airQualityValueLabel.text = "\(Int(health.airQuality?.index ?? 0))"
airQualityValueLabel.text = "\(Int(health?.airQuality?.index ?? 0))"
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)",
attributes: [.font : AppFont.SFPro.regular(size: 24),
.foregroundColor :ThemeManager.currentTheme.secondaryTextColor])
......@@ -62,7 +62,7 @@ class TodayAirQualityCell: UITableViewCell {
//Fill pollutions
stackView.removeAll()
health.pollutants.map{$1}.forEach {
health?.pollutants.map{$1}.forEach {
let pollutionView = PollutantView()
pollutionView.configure(pollutant: $0)
stackView.addArrangedSubview(pollutionView)
......
......@@ -40,12 +40,12 @@ class TodayCellFactory: CellFactoryProtocol {
private var todaySection = TodaySection(rows: [.alert, .forecast, .ad,
.conditions, .forecastPeriod, .precipitation,
.airQuality, .dayTime, .sun, .moon])
private let health = Health(lastUpdateTime: Date(),
airQuality: .init(index: 48, advice: "some"),
pollutants: ["pm25" : .init(name: "PM 2.5", value: 48),
"pm10" : .init(name: "PM 10", value: 42),
"no2" : .init(name: "NO2", value: 74),
"so2" : .init(name: "SO2", value: 135)])
// private let health = Health(lastUpdateTime: Date(),
// airQuality: .init(index: 48, advice: "some"),
// pollutants: ["pm25" : .init(name: "PM 2.5", value: 48),
// "pm10" : .init(name: "PM 10", value: 42),
// "no2" : .init(name: "NO2", value: 74),
// "so2" : .init(name: "SO2", value: 135)])
//Public
init(viewModel: TodayViewModel) {
......@@ -112,7 +112,7 @@ class TodayCellFactory: CellFactoryProtocol {
return cell
case .airQuality:
let cell = dequeueReusableCell(type: TodayAirQualityCell.self, tableView: tableView, indexPath: indexPath)
cell.configure(health: self.health)
cell.configure(health: loc.health)
return cell
case .dayTime:
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