├── .gitignore ├── LICENSE ├── README.md ├── client ├── ios │ ├── Air.xcodeproj │ │ └── project.pbxproj │ ├── Application │ │ ├── Application.swift │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ ├── AppIcon-1.png │ │ │ │ ├── AppIcon-10.png │ │ │ │ ├── AppIcon-11.png │ │ │ │ ├── AppIcon-12.png │ │ │ │ ├── AppIcon-13.png │ │ │ │ ├── AppIcon-14.png │ │ │ │ ├── AppIcon-15.png │ │ │ │ ├── AppIcon-16.png │ │ │ │ ├── AppIcon-17.png │ │ │ │ ├── AppIcon-2.png │ │ │ │ ├── AppIcon-3.png │ │ │ │ ├── AppIcon-4.png │ │ │ │ ├── AppIcon-5.png │ │ │ │ ├── AppIcon-6.png │ │ │ │ ├── AppIcon-7.png │ │ │ │ ├── AppIcon-8.png │ │ │ │ ├── AppIcon-9.png │ │ │ │ ├── AppIcon.png │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ └── Location.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── Location.pdf │ │ ├── Detail.swift │ │ ├── Help.swift │ │ ├── Info.plist │ │ ├── Map.swift │ │ └── Search.swift │ └── Widget │ │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── AppIcon-1.png │ │ │ ├── AppIcon-10.png │ │ │ ├── AppIcon-11.png │ │ │ ├── AppIcon-12.png │ │ │ ├── AppIcon-13.png │ │ │ ├── AppIcon-14.png │ │ │ ├── AppIcon-15.png │ │ │ ├── AppIcon-16.png │ │ │ ├── AppIcon-17.png │ │ │ ├── AppIcon-2.png │ │ │ ├── AppIcon-3.png │ │ │ ├── AppIcon-4.png │ │ │ ├── AppIcon-5.png │ │ │ ├── AppIcon-6.png │ │ │ ├── AppIcon-7.png │ │ │ ├── AppIcon-8.png │ │ │ ├── AppIcon-9.png │ │ │ ├── AppIcon.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── MapPlaceholder.imageset │ │ │ ├── Contents.json │ │ │ ├── MapPlaceholder@1x.png │ │ │ ├── MapPlaceholder@2x.png │ │ │ └── MapPlaceholder@3x.png │ │ └── WidgetBackground.colorset │ │ │ └── Contents.json │ │ ├── Info.plist │ │ ├── LocationSelection.intentdefinition │ │ └── Widget.swift ├── macos │ ├── Air.xcodeproj │ │ └── project.pbxproj │ └── Application │ │ ├── Air.entitlements │ │ ├── Application.swift │ │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── AppIcon-1.png │ │ │ ├── AppIcon-2.png │ │ │ ├── AppIcon-3.png │ │ │ ├── AppIcon-4.png │ │ │ ├── AppIcon-5.png │ │ │ ├── AppIcon-6.png │ │ │ ├── AppIcon-7.png │ │ │ ├── AppIcon-8.png │ │ │ ├── AppIcon-9.png │ │ │ ├── AppIcon.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── Location.imageset │ │ │ ├── Contents.json │ │ │ └── Location.pdf │ │ ├── Detail.swift │ │ ├── Info.plist │ │ ├── Map.swift │ │ └── main.swift └── swift │ └── AQI │ ├── Color.swift │ ├── Download.swift │ ├── Package.swift │ ├── Reading.swift │ ├── Render.swift │ └── model.pb.swift ├── model └── model.proto ├── privacy.md └── server ├── template.yaml ├── tests ├── test_correction.py └── test_parse.py └── update_data ├── __init__.py ├── app.py ├── model_pb2.py ├── purpleair.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | project.xcworkspace/ 3 | xcuserdata/ 4 | xcshareddata/ 5 | .vscode/ 6 | .swiftpm/ 7 | .aws-sam/ 8 | samconfig.toml 9 | __pycache__ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Air Quality Reader for iOS and MacOS 2 | 3 | ## Overview 4 | 5 | An iOS and MacOS application to display [PurpleAir](https://www.purpleair.com/) 6 | air quality sensor data on a map, in a 7 | [Widget](https://support.apple.com/en-us/HT207122) and a full application. 8 | 9 | I created the application because I wanted a map of AQI on my iPhone home screen 10 | via an [iOS 14 Widget](https://support.apple.com/en-us/HT207122). PurpleAir does 11 | not have a native iOS app, and none of the other air quality apps support 12 | widgets or had the same quality and breadth of data as PurpleAir. 13 | 14 | ## Download 15 | 16 | You can [download the iOS app](https://apps.apple.com/us/app/id1535362123) in 17 | the App Store. 18 | 19 | ## Build / Contribute 20 | 21 | You can build the iOS and MacOS clients in XCode. 22 | 23 | * [iOS](client/ios/) 24 | * [MacOS](client/macos/) 25 | 26 | Our "server" is a an [AWS Lambda](https://aws.amazon.com/lambda/) function that 27 | downloads the PurpleAir JSON file on a schedule, converting its verbose format to 28 | a terse [Protocol Buffer](https://developers.google.com/protocol-buffers) format 29 | used by our clients. 30 | 31 | See [`server/`](server/) for the function and the 32 | [Serverless Application Model](https://aws.amazon.com/serverless/sam/) 33 | configuration file with which I deploy the service. 34 | 35 | ## Author 36 | 37 | This is a personal project from [Bret Taylor](mailto:btaylor@gmail.com). 38 | 39 | Data is exclusively from [PurpleAir](https://www.purpleair.com/). I am a 40 | happy owner of multiple sensors. 41 | [Buy one](https://www2.purpleair.com/collections/air-quality-sensors) and 42 | contribute to the network! 43 | 44 | The icon is from [Feather Icons](https://feathericons.com), modified slightly 45 | to conform to iOS aesthetics. 46 | -------------------------------------------------------------------------------- /client/ios/Application/Application.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import UIKit 4 | 5 | @main 6 | class ApplicationDelegate: UIResponder, UIApplicationDelegate { 7 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 8 | return true 9 | } 10 | 11 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 12 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 13 | } 14 | } 15 | 16 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 17 | var window: UIWindow? 18 | 19 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 20 | if let windowScene = scene as? UIWindowScene { 21 | let window = UIWindow(windowScene: windowScene) 22 | window.rootViewController = ApplicationController() 23 | self.window = window 24 | window.makeKeyAndVisible() 25 | } 26 | } 27 | 28 | func sceneDidBecomeActive(_ scene: UIScene) { 29 | if let applicationController = self.window?.rootViewController as? ApplicationController { 30 | applicationController.refreshStaleSensorData() 31 | } 32 | } 33 | } 34 | 35 | class ApplicationController: UINavigationController { 36 | private var _firstView = true 37 | 38 | init() { 39 | super.init(rootViewController: MapController()) 40 | self.title = Bundle.main.infoDictionary![kCFBundleNameKey as String] as? String 41 | } 42 | 43 | @available(*, unavailable) 44 | required init?(coder aDecoder: NSCoder) { 45 | fatalError("init(coder:) has not been implemented") 46 | } 47 | 48 | /// If sensor data is stale, re-download it from the server. 49 | func refreshStaleSensorData() { 50 | if let mapController = self.viewControllers.first as? MapController { 51 | mapController.refreshStaleSensorData() 52 | } 53 | } 54 | 55 | override func viewDidAppear(_ animated: Bool) { 56 | super.viewDidAppear(animated) 57 | if _firstView && !UserDefaults.standard.bool(forKey: "loaded") { 58 | self.present(HelpController(), animated: true, completion: nil) 59 | UserDefaults.standard.setValue(true, forKey: "loaded") 60 | } 61 | _firstView = false 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemPurpleColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-1.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-10.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-11.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-12.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-13.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-14.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-15.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-17.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-2.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-3.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-4.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-5.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-6.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-7.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-8.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-9.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Application/Assets.xcassets/AppIcon.appiconset/AppIcon.png -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon-14.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "AppIcon-15.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "AppIcon-10.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "AppIcon-6.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "AppIcon-7.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "AppIcon-3.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "AppIcon-5.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "AppIcon-4.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "AppIcon-17.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "AppIcon-13.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "AppIcon-16.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "AppIcon-11.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "AppIcon-12.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "AppIcon-8.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "AppIcon-9.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "AppIcon-2.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "AppIcon-1.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "AppIcon.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/Location.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Location.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "preserves-vector-representation" : true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/ios/Application/Assets.xcassets/Location.imageset/Location.pdf: -------------------------------------------------------------------------------- 1 | %PDF-1.7 2 | 3 | 1 0 obj 4 | << >> 5 | endobj 6 | 7 | 2 0 obj 8 | << /Length 3 0 R >> 9 | stream 10 | /DeviceRGB CS 11 | /DeviceRGB cs 12 | q 13 | 1.000000 0.000000 -0.000000 1.000000 2.099976 1.908966 cm 14 | 0.000000 0.000000 0.000000 scn 15 | 0.000000 13.257720 m 16 | -0.321065 13.935523 l 17 | -0.607351 13.799913 -0.777565 13.499056 -0.746350 13.183817 c 18 | -0.715135 12.868578 -0.489224 12.606944 -0.181902 12.530113 c 19 | 0.000000 13.257720 l 20 | h 21 | 22.166668 23.757721 m 22 | 22.844471 23.436657 l 23 | 22.980158 23.723104 22.921124 24.063927 22.696999 24.288052 c 24 | 22.472874 24.512177 22.132051 24.571211 21.845604 24.435524 c 25 | 22.166668 23.757721 l 26 | h 27 | 11.666668 1.591053 m 28 | 10.939061 1.409151 l 29 | 11.015892 1.101830 11.277526 0.875917 11.592765 0.844704 c 30 | 11.908004 0.813488 12.208862 0.983702 12.344471 1.269989 c 31 | 11.666668 1.591053 l 32 | h 33 | 9.333334 10.924387 m 34 | 10.060941 11.106289 l 35 | 9.993762 11.375003 9.783950 11.584815 9.515236 11.651994 c 36 | 9.333334 10.924387 l 37 | h 38 | 0.321065 12.579917 m 39 | 22.487732 23.079918 l 40 | 21.845604 24.435524 l 41 | -0.321065 13.935523 l 42 | 0.321065 12.579917 l 43 | h 44 | 21.488865 24.078785 m 45 | 10.988865 1.912117 l 46 | 12.344471 1.269989 l 47 | 22.844471 23.436657 l 48 | 21.488865 24.078785 l 49 | h 50 | 12.394275 1.772955 m 51 | 10.060941 11.106289 l 52 | 8.605727 10.742485 l 53 | 10.939061 1.409151 l 54 | 12.394275 1.772955 l 55 | h 56 | 9.515236 11.651994 m 57 | 0.181902 13.985327 l 58 | -0.181902 12.530113 l 59 | 9.151432 10.196780 l 60 | 9.515236 11.651994 l 61 | h 62 | f 63 | n 64 | Q 65 | 66 | endstream 67 | endobj 68 | 69 | 3 0 obj 70 | 1251 71 | endobj 72 | 73 | 4 0 obj 74 | << /Annots [] 75 | /Type /Page 76 | /MediaBox [ 0.000000 0.000000 28.000000 28.000000 ] 77 | /Resources 1 0 R 78 | /Contents 2 0 R 79 | /Parent 5 0 R 80 | >> 81 | endobj 82 | 83 | 5 0 obj 84 | << /Kids [ 4 0 R ] 85 | /Count 1 86 | /Type /Pages 87 | >> 88 | endobj 89 | 90 | 6 0 obj 91 | << /Type /Catalog 92 | /Pages 5 0 R 93 | >> 94 | endobj 95 | 96 | xref 97 | 0 7 98 | 0000000000 65535 f 99 | 0000000010 00000 n 100 | 0000000034 00000 n 101 | 0000001341 00000 n 102 | 0000001364 00000 n 103 | 0000001537 00000 n 104 | 0000001611 00000 n 105 | trailer 106 | << /ID [ (some) (id) ] 107 | /Root 6 0 R 108 | /Size 7 109 | >> 110 | startxref 111 | 1670 112 | %%EOF -------------------------------------------------------------------------------- /client/ios/Application/Detail.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import AQI 4 | import UIKit 5 | 6 | class SensorDetailView: UIView { 7 | var reading: AQI.Reading? { 8 | didSet { 9 | if let reading = self.reading { 10 | self._currentReading.aqi = reading.aqi 11 | self._aqi10M.aqi = reading.aqi10M 12 | self._aqi30M.aqi = reading.aqi30M 13 | self._aqi1H.aqi = reading.aqi1H 14 | self._aqi6H.aqi = reading.aqi6H 15 | self._aqi24H.aqi = reading.aqi24H 16 | let formatter = RelativeDateTimeFormatter() 17 | formatter.unitsStyle = .full 18 | formatter.dateTimeStyle = .named 19 | self._aqiHeading.text = "Updated " + formatter.localizedString(for: reading.lastUpdated, relativeTo: Date()) 20 | } 21 | } 22 | } 23 | 24 | private lazy var _aqiHeading: UILabel = { 25 | return self._heading(NSLocalizedString("AQI", comment: "Title of air quality index detail")) 26 | }() 27 | 28 | private lazy var _currentReading: CurrentReadingView = { 29 | return CurrentReadingView(); 30 | }() 31 | 32 | private lazy var _aqi10M: HistoricalReadingView = { 33 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("10m", comment: "Very short abbreviation of 10 minutes")) 34 | }() 35 | 36 | private lazy var _aqi30M: HistoricalReadingView = { 37 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("30m", comment: "Very short abbreviation of 30 minutes")) 38 | }() 39 | 40 | private lazy var _aqi1H: HistoricalReadingView = { 41 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("1h", comment: "Very short abbreviation of 1 hour")) 42 | }() 43 | 44 | private lazy var _aqi6H: HistoricalReadingView = { 45 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("6h", comment: "Very short abbreviation of 6 hours")) 46 | }() 47 | 48 | private lazy var _aqi24H: HistoricalReadingView = { 49 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("24h", comment: "Very short abbreviation of 24 hours")) 50 | }() 51 | 52 | init() { 53 | super.init(frame: .zero) 54 | 55 | let historicalReadings = UIStackView(arrangedSubviews: [ 56 | self._aqi10M, 57 | self._aqi30M, 58 | self._aqi1H, 59 | self._aqi6H, 60 | self._aqi24H, 61 | ]) 62 | historicalReadings.axis = .horizontal 63 | historicalReadings.spacing = 1 64 | 65 | let stack = UIStackView(arrangedSubviews: [ 66 | self._aqiHeading, 67 | self._currentReading, 68 | self._heading(NSLocalizedString("Air Quality Average", comment: "Title of table of average AQI from last 24 hours")), 69 | historicalReadings, 70 | ]) 71 | stack.translatesAutoresizingMaskIntoConstraints = false 72 | stack.spacing = 5 73 | stack.axis = .vertical 74 | stack.setCustomSpacing(20, after: self._currentReading) 75 | self.addSubview(stack) 76 | 77 | NSLayoutConstraint.activate([ 78 | stack.leftAnchor.constraint(equalTo: self.leftAnchor), 79 | stack.rightAnchor.constraint(equalTo: self.rightAnchor), 80 | stack.topAnchor.constraint(equalTo: self.topAnchor), 81 | stack.bottomAnchor.constraint(equalTo: self.bottomAnchor), 82 | ]) 83 | self.translatesAutoresizingMaskIntoConstraints = false 84 | } 85 | 86 | private func _heading(_ title: String) -> UILabel { 87 | let label = UILabel(frame: .zero) 88 | label.font = UIFont.systemFont(ofSize: 14, weight: .medium) 89 | label.text = title 90 | label.backgroundColor = .clear 91 | label.textColor = UIColor.label.withAlphaComponent(0.5) 92 | label.translatesAutoresizingMaskIntoConstraints = false 93 | return label 94 | } 95 | 96 | @available(*, unavailable) 97 | required init?(coder aDecoder: NSCoder) { 98 | fatalError("init(coder:) has not been implemented") 99 | } 100 | } 101 | 102 | private class CurrentReadingView: UIView { 103 | let _circleSize: CGFloat = 50 104 | 105 | var aqi: UInt32 = 0 { 106 | didSet { 107 | self._reading.text = String(aqi) 108 | self._reading.backgroundColor = uiColor(AQI.color(aqi: aqi)) 109 | self._reading.textColor = uiColor(AQI.textColor(aqi: aqi)).withAlphaComponent(0.8) 110 | if aqi <= 50 { 111 | self._description.text = NSLocalizedString("Good", comment: "AQI risk description") 112 | } else if aqi <= 100 { 113 | self._description.text = NSLocalizedString("Moderate", comment: "AQI risk description") 114 | } else if aqi <= 150 { 115 | self._description.text = NSLocalizedString("Unhealthy for Sensitive Groups", comment: "AQI risk description") 116 | } else if aqi <= 200 { 117 | self._description.text = NSLocalizedString("Unhealthy", comment: "AQI risk description") 118 | } else if aqi <= 300 { 119 | self._description.text = NSLocalizedString("Very Unhealthy", comment: "AQI risk description") 120 | } else { 121 | self._description.text = NSLocalizedString("Hazardous", comment: "AQI risk description") 122 | } 123 | } 124 | } 125 | 126 | private lazy var _reading: UILabel = { 127 | let label = UILabel(frame: .zero) 128 | label.font = UIFont.systemFont(ofSize: 20, weight: .semibold) 129 | label.backgroundColor = .clear 130 | label.textAlignment = .center 131 | label.numberOfLines = 1 132 | label.adjustsFontSizeToFitWidth = true 133 | label.translatesAutoresizingMaskIntoConstraints = false 134 | label.clipsToBounds = true 135 | label.layer.cornerRadius = self._circleSize / 2 136 | return label 137 | }() 138 | 139 | private lazy var _description: UILabel = { 140 | let label = UILabel(frame: .zero) 141 | label.font = UIFont.systemFont(ofSize: 20, weight: .semibold) 142 | label.backgroundColor = .clear 143 | label.numberOfLines = 0 144 | label.translatesAutoresizingMaskIntoConstraints = false 145 | return label 146 | }() 147 | 148 | init() { 149 | super.init(frame: .zero) 150 | self.addSubview(self._reading) 151 | self.addSubview(self._description) 152 | 153 | let spacing: CGFloat = 8 154 | NSLayoutConstraint.activate([ 155 | self._reading.widthAnchor.constraint(equalToConstant: self._circleSize), 156 | self._reading.heightAnchor.constraint(equalToConstant: self._circleSize), 157 | self._reading.topAnchor.constraint(equalTo: self.topAnchor), 158 | self._reading.leftAnchor.constraint(equalTo: self.leftAnchor), 159 | self._reading.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor), 160 | self._description.leftAnchor.constraint(equalTo: self._reading.rightAnchor, constant: spacing), 161 | self._description.centerYAnchor.constraint(equalTo: self._reading.centerYAnchor), 162 | self._description.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor), 163 | self._description.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor), 164 | ]) 165 | self.translatesAutoresizingMaskIntoConstraints = false 166 | } 167 | 168 | @available(*, unavailable) 169 | required init?(coder aDecoder: NSCoder) { 170 | fatalError("init(coder:) has not been implemented") 171 | } 172 | } 173 | 174 | private class HistoricalReadingView: UIView { 175 | var aqi: UInt32 = 0 { 176 | didSet { 177 | self._reading.text = String(aqi) 178 | self.backgroundColor = uiColor(AQI.color(aqi: aqi)) 179 | self._reading.textColor = uiColor(AQI.textColor(aqi: aqi)).withAlphaComponent(0.8) 180 | self._descriptor.textColor = self._reading.textColor 181 | } 182 | } 183 | 184 | private lazy var _descriptor: UILabel = { 185 | let label = UILabel(frame: .zero) 186 | label.font = UIFont.systemFont(ofSize: 12) 187 | label.backgroundColor = .clear 188 | label.textAlignment = .center 189 | label.numberOfLines = 1 190 | label.adjustsFontSizeToFitWidth = true 191 | label.translatesAutoresizingMaskIntoConstraints = false 192 | return label 193 | }() 194 | 195 | private lazy var _reading: UILabel = { 196 | let label = UILabel(frame: .zero) 197 | label.font = UIFont.systemFont(ofSize: 20, weight: .semibold) 198 | label.backgroundColor = .clear 199 | label.textAlignment = .center 200 | label.numberOfLines = 1 201 | label.adjustsFontSizeToFitWidth = true 202 | label.translatesAutoresizingMaskIntoConstraints = false 203 | return label 204 | }() 205 | 206 | init(timeDescriptor: String) { 207 | super.init(frame: .zero) 208 | self._descriptor.text = timeDescriptor 209 | self.addSubview(self._reading) 210 | self.addSubview(self._descriptor) 211 | 212 | let verticalPadding: CGFloat = 8 213 | NSLayoutConstraint.activate([ 214 | self.widthAnchor.constraint(equalToConstant: 44), 215 | self._reading.topAnchor.constraint(equalTo: self.topAnchor, constant: verticalPadding), 216 | self._reading.leftAnchor.constraint(equalTo: self.leftAnchor), 217 | self._reading.rightAnchor.constraint(equalTo: self.rightAnchor), 218 | self._descriptor.topAnchor.constraint(equalTo: self._reading.bottomAnchor, constant: 2), 219 | self._descriptor.leftAnchor.constraint(equalTo: self.leftAnchor), 220 | self._descriptor.rightAnchor.constraint(equalTo: self.rightAnchor), 221 | self._descriptor.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -verticalPadding), 222 | ]) 223 | self.translatesAutoresizingMaskIntoConstraints = false 224 | } 225 | 226 | @available(*, unavailable) 227 | required init?(coder aDecoder: NSCoder) { 228 | fatalError("init(coder:) has not been implemented") 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /client/ios/Application/Help.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import AQI 4 | import UIKit 5 | 6 | class HelpController: UINavigationController { 7 | init() { 8 | super.init(rootViewController: HelpControllerContent()) 9 | self.modalPresentationStyle = .formSheet 10 | } 11 | 12 | @available(*, unavailable) 13 | required init?(coder aDecoder: NSCoder) { 14 | fatalError("init(coder:) has not been implemented") 15 | } 16 | } 17 | 18 | private class HelpControllerContent: UIViewController { 19 | init() { 20 | super.init(nibName: nil, bundle: nil) 21 | self.title = NSLocalizedString("Air Quality Index", comment: "Title of help overview dialog") 22 | self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(self._onDone)) 23 | } 24 | 25 | @available(*, unavailable) 26 | required init?(coder aDecoder: NSCoder) { 27 | fatalError("init(coder:) has not been implemented") 28 | } 29 | 30 | override func loadView() { 31 | self.view = UIView(frame: UIScreen.main.bounds) 32 | self.view.backgroundColor = .systemBackground 33 | self.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 34 | 35 | let scrollView = UIScrollView(frame: self.view.bounds) 36 | scrollView.translatesAutoresizingMaskIntoConstraints = false 37 | self.view.addSubview(scrollView) 38 | 39 | let safeArea = self.view.safeAreaLayoutGuide 40 | let stackView = UIStackView(arrangedSubviews: [ 41 | self._bodyLabel(NSLocalizedString("The Air Quality Index (AQI) tells you how clean or polluted your air is and what associated health effects might be a concern for you. The Environmental Protection Agency (EPA) divides AQI into six categories:", comment: "Describes the AQI table")), 42 | _aqiTable(), 43 | self._purpleAirLabel(), 44 | ]) 45 | stackView.axis = .vertical 46 | stackView.alignment = .fill 47 | stackView.distribution = .equalSpacing 48 | stackView.spacing = 20 49 | stackView.translatesAutoresizingMaskIntoConstraints = false 50 | scrollView.addSubview(stackView) 51 | 52 | let horizontalPadding: CGFloat = 15 53 | let verticalPadding: CGFloat = 15 54 | scrollView.contentInset = UIEdgeInsets(top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, right: -horizontalPadding) 55 | NSLayoutConstraint.activate([ 56 | scrollView.topAnchor.constraint(equalTo: safeArea.topAnchor), 57 | scrollView.leftAnchor.constraint(equalTo: safeArea.leftAnchor), 58 | scrollView.rightAnchor.constraint(equalTo: safeArea.rightAnchor), 59 | scrollView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), 60 | stackView.topAnchor.constraint(equalTo: scrollView.topAnchor), 61 | stackView.leftAnchor.constraint(equalTo: scrollView.leftAnchor), 62 | stackView.rightAnchor.constraint(equalTo: scrollView.rightAnchor), 63 | stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), 64 | stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -2 * horizontalPadding), 65 | ]) 66 | } 67 | 68 | private func _bodyLabel(_ text: String) -> UILabel { 69 | let label = UILabel(frame: UIScreen.main.bounds) 70 | label.numberOfLines = 0 71 | label.font = UIFont.preferredFont(forTextStyle: .body) 72 | label.backgroundColor = .clear 73 | label.textColor = .label 74 | label.text = text 75 | label.translatesAutoresizingMaskIntoConstraints = false 76 | return label 77 | } 78 | 79 | private func _aqiTable() -> UIView { 80 | let stackView = UIStackView(arrangedSubviews: [ 81 | self._aqiRow(0, 50, NSLocalizedString("Good", comment: "AQI risk description")), 82 | self._aqiRow(51, 100, NSLocalizedString("Moderate", comment: "AQI risk description")), 83 | self._aqiRow(101, 150, NSLocalizedString("Unhealthy for Sensitive Groups", comment: "AQI risk description")), 84 | self._aqiRow(151, 200, NSLocalizedString("Unhealthy", comment: "AQI risk description")), 85 | self._aqiRow(201, 300, NSLocalizedString("Very Unhealthy", comment: "AQI risk description")), 86 | self._aqiRow(301, 500, NSLocalizedString("Hazardous", comment: "AQI risk description")), 87 | ]) 88 | stackView.axis = .vertical 89 | stackView.alignment = .fill 90 | stackView.distribution = .equalSpacing 91 | stackView.spacing = 1 92 | stackView.translatesAutoresizingMaskIntoConstraints = false 93 | return stackView 94 | } 95 | 96 | private func _aqiRow(_ low: UInt32, _ high: UInt32, _ description: String) -> UIView { 97 | let range = String(format: "%d - %d", low, high) 98 | let readingCell = self._aqiCell(range, low) 99 | readingCell.widthAnchor.constraint(equalToConstant: 100).isActive = true 100 | let stackView = UIStackView(arrangedSubviews: [ 101 | readingCell, 102 | self._aqiCell(description, low), 103 | ]) 104 | stackView.axis = .horizontal 105 | stackView.alignment = .fill 106 | stackView.distribution = .fill 107 | stackView.spacing = 1 108 | stackView.translatesAutoresizingMaskIntoConstraints = false 109 | return stackView 110 | } 111 | 112 | private func _aqiCell(_ text: String, _ aqi: UInt32) -> UITextView { 113 | let textView = UITextView(frame: UIScreen.main.bounds) 114 | textView.isScrollEnabled = false 115 | textView.font = UIFont.preferredFont(forTextStyle: .body) 116 | textView.backgroundColor = uiColor(AQI.color(aqi: aqi)) 117 | textView.text = text 118 | textView.textColor = uiColor(AQI.textColor(aqi: aqi)) 119 | textView.translatesAutoresizingMaskIntoConstraints = false 120 | textView.textContainerInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) 121 | textView.textContainer.lineFragmentPadding = 0 122 | return textView 123 | } 124 | 125 | private func _purpleAirLabel() -> UITextView { 126 | let text = NSLocalizedString("This app displays AQI readings from PurpleAir, a collection of air quality sensors run by enthusiasts and air quality professionals around the world.", comment: "Described the source of AQI data") 127 | let formatted = NSMutableAttributedString(string: text, attributes: [ 128 | NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .body), 129 | NSAttributedString.Key.foregroundColor: UIColor.label, 130 | ]) 131 | if let range = formatted.string.range(of: "PurpleAir") { 132 | formatted.addAttribute(NSAttributedString.Key.link, value: "https://www.purpleair.com/", range: NSRange(location: range.lowerBound.utf16Offset(in: formatted.string), length: 9)) 133 | } 134 | let textView = UITextView(frame: UIScreen.main.bounds) 135 | textView.isScrollEnabled = false 136 | textView.backgroundColor = .clear 137 | textView.attributedText = formatted 138 | textView.translatesAutoresizingMaskIntoConstraints = false 139 | textView.textContainerInset = .zero 140 | textView.textContainer.lineFragmentPadding = 0 141 | return textView 142 | } 143 | 144 | @objc private func _onDone() { 145 | self.dismiss(animated: true, completion: nil) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /client/ios/Application/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | LSRequiresIPhoneOS 22 | 23 | NSAppTransportSecurity 24 | 25 | NSAllowsLocalNetworking 26 | 27 | 28 | NSLocationAlwaysUsageDescription 29 | Display air quality around your current location 30 | NSLocationUsageDescription 31 | Display air quality around your current location 32 | NSLocationWhenInUseUsageDescription 33 | Display air quality around your current location 34 | NSUserActivityTypes 35 | 36 | LocationSelectionIntent 37 | 38 | UIApplicationSceneManifest 39 | 40 | UIApplicationSupportsMultipleScenes 41 | 42 | UISceneConfigurations 43 | 44 | UIWindowSceneSessionRoleApplication 45 | 46 | 47 | UISceneConfigurationName 48 | Default Configuration 49 | UISceneDelegateClassName 50 | $(PRODUCT_MODULE_NAME).SceneDelegate 51 | 52 | 53 | 54 | 55 | UIApplicationSupportsIndirectInputEvents 56 | 57 | UILaunchScreen 58 | 59 | UINavigationBar 60 | 61 | UIImageName 62 | 63 | 64 | 65 | UIRequiredDeviceCapabilities 66 | 67 | armv7 68 | 69 | UISupportedInterfaceOrientations 70 | 71 | UIInterfaceOrientationPortrait 72 | UIInterfaceOrientationLandscapeLeft 73 | UIInterfaceOrientationLandscapeRight 74 | 75 | UISupportedInterfaceOrientations~ipad 76 | 77 | UIInterfaceOrientationPortrait 78 | UIInterfaceOrientationPortraitUpsideDown 79 | UIInterfaceOrientationLandscapeLeft 80 | UIInterfaceOrientationLandscapeRight 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /client/ios/Application/Map.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import AQI 4 | import GameKit 5 | import MapKit 6 | import UIKit 7 | import os.log 8 | 9 | class MapController: UIViewController, MKMapViewDelegate, CLLocationManagerDelegate, MapSearchControllerDelegate { 10 | private var _readings = GKRTree(maxNumberOfChildren: 2) 11 | private var _redrawing = false 12 | private var _needsRedraw = false 13 | private var _downloadStartTime: CFAbsoluteTime = 0 14 | private var _locationLoadTime: CFAbsoluteTime = 0 15 | private let _maximumAnnoationsToDisplay = 750 16 | private var _errorTimer: Timer? 17 | 18 | private lazy var _searchController: MapSearchController = { 19 | let searchController = MapSearchController(mapView: self._mapView) 20 | searchController.hidesNavigationBarDuringPresentation = false 21 | return searchController 22 | }() 23 | 24 | private lazy var _mapView: MKMapView = { 25 | let mapView = MKMapView(frame: self.view.bounds) 26 | mapView.translatesAutoresizingMaskIntoConstraints = false 27 | mapView.delegate = self 28 | mapView.register(ReadingView.self, forAnnotationViewWithReuseIdentifier: NSStringFromClass(AQI.Reading.self)) 29 | mapView.showsUserLocation = true 30 | return mapView 31 | }() 32 | 33 | private lazy var _progressView: UIProgressView = { 34 | let progressView = UIProgressView(progressViewStyle: .bar) 35 | progressView.translatesAutoresizingMaskIntoConstraints = false 36 | progressView.progress = 0.5 37 | progressView.isHidden = true 38 | return progressView 39 | }() 40 | 41 | private lazy var _locationManager: CLLocationManager = { 42 | let locationManager = CLLocationManager() 43 | locationManager.delegate = self 44 | locationManager.desiredAccuracy = kCLLocationAccuracyKilometer 45 | if (CLLocationManager.locationServicesEnabled()) { 46 | locationManager.requestWhenInUseAuthorization() 47 | } 48 | if (CLLocationManager.significantLocationChangeMonitoringAvailable()) { 49 | locationManager.startMonitoringSignificantLocationChanges() 50 | } else { 51 | locationManager.startUpdatingLocation() 52 | } 53 | return locationManager 54 | }() 55 | 56 | private lazy var _measurementToggle: UISegmentedControl = { 57 | let segmentedControl = UISegmentedControl(items: [ 58 | NSLocalizedString("EPA AQI", comment: "Terse description of EPA Air Quality Index"), 59 | NSLocalizedString("PurpleAir AQI", comment: "Terse description of unmodified PurpleAir AQI"), 60 | ]) 61 | segmentedControl.translatesAutoresizingMaskIntoConstraints = false 62 | segmentedControl.selectedSegmentIndex = 0 63 | segmentedControl.addTarget(self, action: #selector(self._onMeasurementToggleChanged), for: .valueChanged) 64 | return segmentedControl 65 | }() 66 | 67 | private lazy var _measurementBackground: UIView = { 68 | let visualEffect = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) 69 | visualEffect.layer.cornerRadius = self._measurementToggle.layer.cornerRadius 70 | visualEffect.layer.masksToBounds = true 71 | visualEffect.translatesAutoresizingMaskIntoConstraints = false 72 | return visualEffect 73 | }() 74 | 75 | private lazy var _errorBar: UITextView = { 76 | let errorBar = UITextView(frame: self.view.bounds) 77 | errorBar.backgroundColor = .systemRed 78 | errorBar.text = NSLocalizedString("Unable to download air quality data", comment: "Error message when new map data cannot be downloaded") 79 | errorBar.textAlignment = .center 80 | errorBar.isScrollEnabled = false 81 | errorBar.isUserInteractionEnabled = false 82 | let font = UIFont.preferredFont(forTextStyle: .callout) 83 | errorBar.font = UIFont.systemFont(ofSize: font.pointSize, weight: .medium) 84 | errorBar.translatesAutoresizingMaskIntoConstraints = false 85 | errorBar.textColor = .white 86 | errorBar.alpha = 0 87 | errorBar.isHidden = true 88 | return errorBar 89 | }() 90 | 91 | private var _readingType: AQI.ReadingType { 92 | return self._measurementToggle.selectedSegmentIndex == 0 ? .epaCorrected : .rawPurpleAir; 93 | } 94 | 95 | init() { 96 | super.init(nibName: nil, bundle: nil) 97 | self.title = NSLocalizedString("Air Quality Readings", comment: "Title of main map displaying AQI by location") 98 | let centerMap = UIBarButtonItem(image: UIImage(named: "Location"), style: .plain, target: self, action: #selector(self._centerMap)) 99 | centerMap.accessibilityLabel = NSLocalizedString("Center Map on Location", comment: "Button to center the map on the current location") 100 | self.navigationItem.leftBarButtonItem = centerMap 101 | let download = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(self._downloadSensorData)) 102 | download.accessibilityLabel = NSLocalizedString("Refresh Data", comment: "Button to download new data from the server") 103 | self.navigationItem.rightBarButtonItem = download 104 | self.definesPresentationContext = true 105 | } 106 | 107 | @available(*, unavailable) 108 | required init?(coder aDecoder: NSCoder) { 109 | fatalError("init(coder:) has not been implemented") 110 | } 111 | 112 | override func loadView() { 113 | self.view = UIView(frame: UIScreen.main.bounds) 114 | self.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 115 | self.view.addSubview(self._mapView) 116 | self.view.addSubview(self._errorBar) 117 | self.view.addSubview(self._progressView) 118 | self.view.addSubview(self._measurementBackground) 119 | self.view.addSubview(self._measurementToggle) 120 | if let region = self._preferredZoomRegion() { 121 | self._mapView.region = region 122 | } else { 123 | self._locationLoadTime = CFAbsoluteTimeGetCurrent() 124 | } 125 | navigationItem.titleView = self._searchController.searchBar 126 | self._searchController.mapSearchControllerDelegate = self 127 | 128 | let safeArea = self.view.safeAreaLayoutGuide 129 | NSLayoutConstraint.activate([ 130 | self._errorBar.leftAnchor.constraint(equalTo: self.view.leftAnchor), 131 | self._errorBar.rightAnchor.constraint(equalTo: self.view.rightAnchor), 132 | self._errorBar.topAnchor.constraint(equalTo: safeArea.topAnchor), 133 | self._progressView.leftAnchor.constraint(equalTo: self.view.leftAnchor), 134 | self._progressView.rightAnchor.constraint(equalTo: self.view.rightAnchor), 135 | self._progressView.topAnchor.constraint(equalTo: safeArea.topAnchor), 136 | self._mapView.topAnchor.constraint(equalTo: self.view.topAnchor), 137 | self._mapView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), 138 | self._mapView.leftAnchor.constraint(equalTo: self.view.leftAnchor), 139 | self._mapView.rightAnchor.constraint(equalTo: self.view.rightAnchor), 140 | self._measurementToggle.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), 141 | self._measurementToggle.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), 142 | self._measurementBackground.leftAnchor.constraint(equalTo: self._measurementToggle.leftAnchor), 143 | self._measurementBackground.rightAnchor.constraint(equalTo: self._measurementToggle.rightAnchor), 144 | self._measurementBackground.topAnchor.constraint(equalTo: self._measurementToggle.topAnchor), 145 | self._measurementBackground.bottomAnchor.constraint(equalTo: self._measurementToggle.bottomAnchor), 146 | ]) 147 | } 148 | 149 | /// If sensor data is stale (older than 15 minutes), re-download it from the server. 150 | func refreshStaleSensorData() { 151 | let dataAge = CFAbsoluteTimeGetCurrent() - self._downloadStartTime 152 | if dataAge > 900 { 153 | self._downloadSensorData() 154 | } 155 | } 156 | 157 | override func viewDidAppear(_ animated: Bool) { 158 | super.viewDidAppear(animated) 159 | self.refreshStaleSensorData() 160 | } 161 | 162 | func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { 163 | guard annotation is AQI.Reading else { return nil } 164 | return mapView.dequeueReusableAnnotationView(withIdentifier: NSStringFromClass(AQI.Reading.self), for: annotation) 165 | } 166 | 167 | func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { 168 | _redrawAnnotations() 169 | } 170 | 171 | func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 172 | if let region = self._preferredZoomRegion() { 173 | self._mapView.setRegion(region, animated: true) 174 | } 175 | } 176 | 177 | func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 178 | // If not available in loadView(), we have to handle setting it asynchronously here 179 | if self._locationLoadTime > 0 { 180 | let time = CFAbsoluteTimeGetCurrent() - self._locationLoadTime 181 | if time < 10 { 182 | if let region = self._preferredZoomRegion() { 183 | self._mapView.setRegion(region, animated: time > 0.5) 184 | } 185 | } 186 | self._locationLoadTime = 0 187 | } 188 | } 189 | 190 | func mapSearchController(_ controller: MapSearchController, selectedItem: MKMapItem) { 191 | if let region = selectedItem.placemark.region as? CLCircularRegion { 192 | self._mapView.setRegion(MKCoordinateRegion(center: region.center, latitudinalMeters: region.radius * 2, longitudinalMeters: region.radius * 2), animated: true) 193 | } else if let location = selectedItem.placemark.location { 194 | self._mapView.setRegion(MKCoordinateRegion(center: location.coordinate, latitudinalMeters: 8046, longitudinalMeters: 8046), animated: true) 195 | } 196 | } 197 | 198 | private func _preferredZoomRegion() -> MKCoordinateRegion? { 199 | if let location = self._locationManager.location { 200 | return MKCoordinateRegion(center: location.coordinate, latitudinalMeters: 8046, longitudinalMeters: 8046) 201 | } else { 202 | return nil 203 | } 204 | } 205 | 206 | private func _redrawAnnotations() { 207 | if _redrawing { 208 | _needsRedraw = true 209 | return 210 | } 211 | AQI.calculateRenderOperation(readings: self._readings, region: self._mapView.region, currentAnnotations: self._mapView.annotations, maximumAnnotationsToDisplay: self._maximumAnnoationsToDisplay) { (addAnnotations, removeAnnotations, updatedAnnotations) in 212 | self._mapView.removeAnnotations(removeAnnotations) 213 | self._mapView.addAnnotations(addAnnotations) 214 | for (existing, updated) in updatedAnnotations { 215 | if let readingView = self._mapView.view(for: existing) as? ReadingView { 216 | if let oldReading = readingView.annotation as? AQI.Reading { 217 | oldReading.update(updated) 218 | } 219 | readingView.prepareForDisplay() 220 | } 221 | } 222 | self._redrawing = false 223 | if self._needsRedraw { 224 | self._needsRedraw = false 225 | self._redrawAnnotations() 226 | } 227 | } 228 | } 229 | 230 | @objc private func _centerMap() { 231 | if let region = self._preferredZoomRegion() { 232 | self._mapView.setRegion(region, animated: true) 233 | } else { 234 | let alert = UIAlertController(title: title, message: NSLocalizedString("The application does not currently have permission to access your location. You can give the application permission in Settings.", comment: "Error message when application cannot read location"), preferredStyle: .alert) 235 | alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Close alert dialog"), style: .cancel, handler: nil)) 236 | alert.addAction(UIAlertAction(title: NSLocalizedString("Open Settings", comment: "Dialog prompt to open location settings"), style: .default) { (UIAlertAction) in 237 | UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)! as URL, options: [:], completionHandler: nil) 238 | }) 239 | self.present(alert, animated: true, completion: nil) 240 | } 241 | } 242 | 243 | @objc private func _onMeasurementToggleChanged() { 244 | self._readings = GKRTree(maxNumberOfChildren: 2) 245 | self._redrawAnnotations() 246 | self._downloadSensorData() 247 | } 248 | 249 | @objc private func _downloadSensorData() { 250 | if !self._progressView.isHidden { 251 | return 252 | } 253 | self._progressView.progress = 0 254 | self._progressView.isHidden = false 255 | self._downloadStartTime = CFAbsoluteTimeGetCurrent() 256 | AQI.downloadReadings(type: self._readingType, onProgess: { (percentage) in 257 | self._progressView.progress = percentage 258 | }, onResponse: { (readings, error) in 259 | let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "map") 260 | if let error = error { 261 | os_log("🔴 Failed to update readings: %{public}s", log: log, type: .info, error.localizedDescription) 262 | self._showDownloadError() 263 | } else if let readings = readings { 264 | os_log("🟢 Updated readings from server (%.3fs)", log: log, type: .info, CFAbsoluteTimeGetCurrent() - self._downloadStartTime) 265 | self._readings = readings 266 | self._redrawAnnotations() 267 | } 268 | self._progressView.progress = 1 269 | UIView.animate(withDuration: 0.25) { 270 | self._progressView.alpha = 0 271 | } completion: { (finished) in 272 | self._progressView.isHidden = true 273 | self._progressView.alpha = 1 274 | } 275 | }) 276 | } 277 | 278 | private func _showDownloadError() { 279 | if let timer = self._errorTimer { 280 | timer.invalidate() 281 | } 282 | self._errorBar.isHidden = false 283 | UIView.animate(withDuration: 0.25) { 284 | self._errorBar.alpha = 1 285 | } 286 | self._errorTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { (timer) in 287 | self._errorTimer = nil 288 | UIView.animate(withDuration: 0.5) { 289 | self._errorBar.alpha = 0 290 | } completion: { (finished) in 291 | self._errorBar.isHidden = true 292 | } 293 | } 294 | } 295 | } 296 | 297 | private class ReadingView: MKAnnotationView { 298 | private let _textAlpha: CGFloat = 0.8 299 | private let _size: CGFloat = 28 300 | private var _detailCalloutAccessoryView: SensorDetailView? 301 | 302 | private lazy var _label: UILabel = { 303 | let label = UILabel(frame: .zero) 304 | label.font = UIFont.systemFont(ofSize: 12) 305 | label.translatesAutoresizingMaskIntoConstraints = false 306 | label.textAlignment = .center 307 | label.layer.cornerRadius = _size / 2 308 | label.layer.masksToBounds = true 309 | label.adjustsFontSizeToFitWidth = true 310 | return label 311 | }() 312 | 313 | override init(annotation: MKAnnotation?, reuseIdentifier: String?) { 314 | super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) 315 | self.canShowCallout = true 316 | self.calloutOffset = CGPoint(x: 0, y: _size / 4) 317 | self.addSubview(self._label) 318 | self.bounds = CGRect(x: 0, y: 0, width: _size, height: _size) 319 | NSLayoutConstraint.activate([ 320 | self._label.widthAnchor.constraint(equalToConstant: _size), 321 | self._label.heightAnchor.constraint(equalToConstant: _size), 322 | self._label.centerXAnchor.constraint(equalTo: self.centerXAnchor), 323 | self._label.centerYAnchor.constraint(equalTo: self.centerYAnchor), 324 | ]) 325 | } 326 | 327 | override var detailCalloutAccessoryView: UIView? { 328 | get { 329 | if self._detailCalloutAccessoryView == nil { 330 | self._detailCalloutAccessoryView = SensorDetailView() 331 | self._detailCalloutAccessoryView?.reading = self.annotation as? AQI.Reading 332 | } 333 | return self._detailCalloutAccessoryView 334 | } 335 | set { 336 | } 337 | } 338 | 339 | @available(*, unavailable) 340 | required init?(coder aDecoder: NSCoder) { 341 | fatalError("init(coder:) has not been implemented") 342 | } 343 | 344 | override func setSelected(_ selected: Bool, animated: Bool) { 345 | let update: () -> Void = { 346 | if selected { 347 | self._label.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) 348 | self._label.textColor = self._label.textColor.withAlphaComponent(0) 349 | } else { 350 | self._label.transform = .identity 351 | self._label.textColor = self._label.textColor.withAlphaComponent(self._textAlpha) 352 | } 353 | } 354 | if animated { 355 | UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0, options: [], animations: update, completion: nil) 356 | } else { 357 | update() 358 | } 359 | } 360 | 361 | override func prepareForDisplay() { 362 | super.prepareForDisplay() 363 | if let reading = self.annotation as? AQI.Reading { 364 | self._label.text = reading.aqiString 365 | self._label.backgroundColor = uiColor(AQI.color(aqi: reading.aqi)) 366 | self._label.textColor = uiColor(AQI.textColor(aqi: reading.aqi)).withAlphaComponent(self._textAlpha) 367 | self._detailCalloutAccessoryView?.reading = reading 368 | } 369 | } 370 | } 371 | 372 | func uiColor(_ color: AQI.Color) -> UIColor { 373 | return UIColor(red: CGFloat(color.r / 255.0), green: CGFloat(color.g / 255.0), blue: CGFloat(color.b / 255.0), alpha: 1) 374 | } 375 | -------------------------------------------------------------------------------- /client/ios/Application/Search.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import Contacts 4 | import MapKit 5 | import UIKit 6 | 7 | protocol MapSearchControllerDelegate: AnyObject { 8 | func mapSearchController(_ controller: MapSearchController, selectedItem: MKMapItem) 9 | } 10 | 11 | class MapSearchController: UISearchController, MapSearchResultsControllerDelegate { 12 | weak var mapSearchControllerDelegate: MapSearchControllerDelegate? 13 | 14 | init(mapView: MKMapView) { 15 | let results = MapSearchResultsController(mapView: mapView) 16 | super.init(searchResultsController: results) 17 | self.searchResultsUpdater = results 18 | results.mapSearchResultsControllerDelegate = self 19 | } 20 | 21 | @available(*, unavailable) 22 | required init?(coder aDecoder: NSCoder) { 23 | fatalError("init(coder:) has not been implemented") 24 | } 25 | 26 | fileprivate func mapSearchResultsController(_ controller: MapSearchResultsController, selectedItem: MKMapItem) { 27 | self.mapSearchControllerDelegate?.mapSearchController(self, selectedItem: selectedItem) 28 | self.searchBar.text = "" 29 | self.searchResultsController?.dismiss(animated: true, completion: nil) 30 | } 31 | } 32 | 33 | private protocol MapSearchResultsControllerDelegate: AnyObject { 34 | func mapSearchResultsController(_ controller: MapSearchResultsController, selectedItem: MKMapItem) 35 | } 36 | 37 | private class MapSearchResultsController: UITableViewController, UISearchResultsUpdating { 38 | weak var mapSearchResultsControllerDelegate: MapSearchResultsControllerDelegate? 39 | 40 | private weak var _mapView: MKMapView? 41 | private var _results: [MKMapItem] = [] 42 | 43 | init(mapView: MKMapView) { 44 | self._mapView = mapView 45 | super.init(style: .plain) 46 | } 47 | 48 | @available(*, unavailable) 49 | required init?(coder aDecoder: NSCoder) { 50 | fatalError("init(coder:) has not been implemented") 51 | } 52 | 53 | override func viewDidLoad() { 54 | super.viewDidLoad() 55 | self.tableView.register(MapSearchResultCell.self, forCellReuseIdentifier: NSStringFromClass(MapSearchResultCell.self)) 56 | } 57 | 58 | func updateSearchResults(for searchController: UISearchController) { 59 | guard let query = searchController.searchBar.text else { 60 | return 61 | } 62 | let request = MKLocalSearch.Request() 63 | request.naturalLanguageQuery = query 64 | if let region = self._mapView?.region { 65 | request.region = region 66 | } 67 | let search = MKLocalSearch(request: request) 68 | search.start { (response, error) in 69 | if let response = response { 70 | self._results = response.mapItems 71 | self.tableView.reloadData() 72 | } 73 | } 74 | } 75 | 76 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 77 | return _results.count 78 | } 79 | 80 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 81 | let cell = tableView.dequeueReusableCell(withIdentifier: NSStringFromClass(MapSearchResultCell.self), for: indexPath) as! MapSearchResultCell 82 | cell.mapItem = self._results[indexPath.row] 83 | return cell 84 | } 85 | 86 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 87 | self.mapSearchResultsControllerDelegate?.mapSearchResultsController(self, selectedItem: self._results[indexPath.row]) 88 | } 89 | } 90 | 91 | private class MapSearchResultCell: UITableViewCell { 92 | var mapItem: MKMapItem? { 93 | didSet { 94 | self._redraw() 95 | } 96 | } 97 | 98 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 99 | super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) 100 | } 101 | 102 | @available(*, unavailable) 103 | required init?(coder: NSCoder) { 104 | fatalError("init(coder:) has not been implemented") 105 | } 106 | 107 | override func prepareForReuse() { 108 | super.prepareForReuse() 109 | self.textLabel?.text = "" 110 | self.detailTextLabel?.text = "" 111 | } 112 | 113 | private func _redraw() { 114 | guard let mapItem = self.mapItem else { 115 | return 116 | } 117 | self.textLabel?.text = mapItem.name 118 | let formatter = CNPostalAddressFormatter() 119 | if let address = mapItem.placemark.postalAddress { 120 | let formatted = formatter.string(from: address) 121 | self.detailTextLabel?.text = formatted.replacingOccurrences(of: "\n", with: ", ") 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "ios", 6 | "reference" : "systemPurpleColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-1.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-10.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-11.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-12.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-13.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-14.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-15.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-17.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-2.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-3.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-4.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-5.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-6.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-7.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-8.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon-9.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/AppIcon.appiconset/AppIcon.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon-14.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "AppIcon-15.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "AppIcon-10.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "AppIcon-6.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "AppIcon-7.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "AppIcon-3.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "AppIcon-5.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "AppIcon-4.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "AppIcon-17.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "AppIcon-13.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "AppIcon-16.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "AppIcon-11.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "AppIcon-12.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "AppIcon-8.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "AppIcon-9.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "AppIcon-2.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "AppIcon-1.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "AppIcon.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/MapPlaceholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "MapPlaceholder@1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "MapPlaceholder@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "MapPlaceholder@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/MapPlaceholder.imageset/MapPlaceholder@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/MapPlaceholder.imageset/MapPlaceholder@1x.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/MapPlaceholder.imageset/MapPlaceholder@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/MapPlaceholder.imageset/MapPlaceholder@2x.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/MapPlaceholder.imageset/MapPlaceholder@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/ios/Widget/Assets.xcassets/MapPlaceholder.imageset/MapPlaceholder@3x.png -------------------------------------------------------------------------------- /client/ios/Widget/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "237", 9 | "green" : "245", 10 | "red" : "249" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/ios/Widget/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Widget 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSAppTransportSecurity 24 | 25 | NSAllowsLocalNetworking 26 | 27 | 28 | NSExtension 29 | 30 | NSExtensionPointIdentifier 31 | com.apple.widgetkit-extension 32 | 33 | NSLocationAlwaysAndWhenInUseUsageDescription 34 | Display air quality around your current location 35 | NSLocationUsageDescription 36 | Display air quality around your current location 37 | NSLocationWhenInUseUsageDescription 38 | Display air quality around your current location 39 | NSWidgetWantsLocation 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /client/ios/Widget/LocationSelection.intentdefinition: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | INEnums 6 | 7 | INIntentDefinitionModelVersion 8 | 1.2 9 | INIntentDefinitionNamespace 10 | 88xZPY 11 | INIntentDefinitionSystemVersion 12 | 19H2 13 | INIntentDefinitionToolsBuildVersion 14 | 12A7300 15 | INIntentDefinitionToolsVersion 16 | 12.0.1 17 | INIntents 18 | 19 | 20 | INIntentCategory 21 | information 22 | INIntentConfigurable 23 | 24 | INIntentDescription 25 | Select Location 26 | INIntentDescriptionID 27 | tVvJ9c 28 | INIntentEligibleForWidgets 29 | 30 | INIntentIneligibleForSuggestions 31 | 32 | INIntentLastParameterTag 33 | 4 34 | INIntentManagedParameterCombinations 35 | 36 | location 37 | 38 | INIntentParameterCombinationSupportsBackgroundExecution 39 | 40 | INIntentParameterCombinationUpdatesLinked 41 | 42 | 43 | 44 | INIntentName 45 | LocationSelection 46 | INIntentParameters 47 | 48 | 49 | INIntentParameterConfigurable 50 | 51 | INIntentParameterDisplayName 52 | Location 53 | INIntentParameterDisplayNameID 54 | zxIBTj 55 | INIntentParameterDisplayPriority 56 | 1 57 | INIntentParameterMetadata 58 | 59 | INIntentParameterMetadataType 60 | Address 61 | 62 | INIntentParameterName 63 | location 64 | INIntentParameterPromptDialogs 65 | 66 | 67 | INIntentParameterPromptDialogCustom 68 | 69 | INIntentParameterPromptDialogType 70 | Primary 71 | 72 | 73 | INIntentParameterPromptDialogCustom 74 | 75 | INIntentParameterPromptDialogType 76 | Configuration 77 | 78 | 79 | INIntentParameterTag 80 | 2 81 | INIntentParameterType 82 | Placemark 83 | 84 | 85 | INIntentResponse 86 | 87 | INIntentResponseCodes 88 | 89 | 90 | INIntentResponseCodeName 91 | success 92 | INIntentResponseCodeSuccess 93 | 94 | 95 | 96 | INIntentResponseCodeName 97 | failure 98 | 99 | 100 | 101 | INIntentTitle 102 | Location Selection 103 | INIntentTitleID 104 | gpCwrM 105 | INIntentType 106 | Custom 107 | INIntentVerb 108 | View 109 | 110 | 111 | INTypes 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /client/ios/Widget/Widget.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import AQI 4 | import Contacts 5 | import WidgetKit 6 | import MapKit 7 | import SwiftUI 8 | 9 | private let readingSize: CGFloat = 28 10 | 11 | @main 12 | struct AirBundle: WidgetBundle { 13 | @WidgetBundleBuilder 14 | var body: some Widget { 15 | MapWidget() 16 | } 17 | } 18 | 19 | struct MapProvider : IntentTimelineProvider { 20 | typealias Entry = MapEntry 21 | typealias Intent = LocationSelectionIntent 22 | 23 | func placeholder(in context: Context) -> MapEntry { 24 | return MapEntry(date: Date(), mapImage: UIImage(named: "MapPlaceholder")!, placeholderReadings: false) 25 | } 26 | 27 | func getSnapshot(for intent: LocationSelectionIntent, in context: Context, completion: @escaping (MapEntry) -> Void) { 28 | var location = self._location(for: intent) 29 | if location == nil { 30 | let locationManager = CLLocationManager() 31 | locationManager.desiredAccuracy = kCLLocationAccuracyKilometer 32 | if (CLLocationManager.locationServicesEnabled()) { 33 | locationManager.requestAlwaysAuthorization() 34 | } 35 | location = locationManager.location 36 | 37 | // CLLocationManager is really unreliable in a widget, not properly calling delegates. To stabilize the widget, we save the last location we read to prevent it from regressing to `defaultLocation` when reading it fails 38 | let cacheKey = "lastWidgetLocation" 39 | if let activeLocation = location { 40 | UserDefaults.standard.set([ 41 | "latitude": activeLocation.coordinate.latitude, 42 | "longitude": activeLocation.coordinate.longitude, 43 | ], forKey: cacheKey) 44 | } else if let cachedLocation = UserDefaults.standard.object(forKey: cacheKey) as? [String: CLLocationDegrees] { 45 | if let latitude = cachedLocation["latitude"], 46 | let longitude = cachedLocation["longitude"] { 47 | location = CLLocation(latitude: latitude, longitude: longitude) 48 | } 49 | } 50 | } 51 | self._mapImage(location: location, size: context.displaySize) { (mapImage) in 52 | completion(MapEntry(date: Date(), mapImage: mapImage ?? UIImage(named: "MapPlaceholder")!)) 53 | } 54 | } 55 | 56 | func getTimeline(for intent: LocationSelectionIntent, in context: Context, completion: @escaping (Timeline) -> Void) { 57 | self.getSnapshot(for: intent, in: context) { (entry) in 58 | let refreshTime = Calendar.current.date(byAdding: .minute, value: 10, to: Date())! 59 | let timeline = Timeline(entries: [entry], policy: .after(refreshTime)) 60 | completion(timeline) 61 | } 62 | } 63 | 64 | private func _location(for intent: LocationSelectionIntent) -> CLLocation? { 65 | return intent.location?.location 66 | } 67 | 68 | private func _mapImage(location: CLLocation?, size: CGSize, callback: @escaping (UIImage?) -> Void) { 69 | let coordinate: CLLocationCoordinate2D 70 | if let location = location { 71 | coordinate = location.coordinate 72 | } else { 73 | coordinate = defaultLocation().coordinate 74 | } 75 | let options = MKMapSnapshotter.Options() 76 | options.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 4046, longitudinalMeters: 4046) 77 | options.size = size 78 | let snapshotter = MKMapSnapshotter(options: options) 79 | snapshotter.start { (snapshot, error) in 80 | guard let snapshot = snapshot else { 81 | callback(nil) 82 | return 83 | } 84 | AQI.downloadCompactReadings { (readings, error) in 85 | if let readings = readings { 86 | var region = options.region 87 | if size.width > size.height { 88 | region.span.longitudeDelta *= 3 89 | } 90 | AQI.calculateRenderOperation(readings: readings, region: region, currentAnnotations: [], maximumAnnotationsToDisplay: 100) { (addAnnotations, removeAnnotations, updatedAnnotations) in 91 | let image = UIGraphicsImageRenderer(size: options.size).image { context in 92 | snapshot.image.draw(at: .zero) 93 | for reading in addAnnotations { 94 | let point = snapshot.point(for: reading.coordinate) 95 | let rect = CGRect(x: point.x - readingSize / 2, y: point.y - readingSize / 2, width: readingSize, height: readingSize) 96 | context.cgContext.setFillColor(cgColor(AQI.color(aqi: reading.aqi))) 97 | context.cgContext.fillEllipse(in: rect) 98 | 99 | let aqi = String(reading.aqi) 100 | let style = NSMutableParagraphStyle() 101 | style.alignment = .center 102 | let attributes: [NSAttributedString.Key : Any] = [ 103 | NSAttributedString.Key.font: UIFont.systemFont(ofSize: 12), 104 | NSAttributedString.Key.paragraphStyle: style, 105 | NSAttributedString.Key.foregroundColor: uiColor(AQI.textColor(aqi: reading.aqi), 0.8), 106 | ] 107 | let textRect = aqi.boundingRect(with: rect.size, options: .usesLineFragmentOrigin, attributes: attributes, context: nil) 108 | String(reading.aqi).draw(in: CGRect(x: rect.minX, y: rect.minY + textRect.size.height / 2, width: rect.width, height: textRect.size.height), withAttributes: attributes) 109 | } 110 | } 111 | callback(image) 112 | } 113 | } else { 114 | callback(snapshot.image) 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | struct MapEntry: TimelineEntry { 122 | public let date: Date 123 | public let mapImage: UIImage 124 | public var placeholderReadings: Bool = false 125 | } 126 | 127 | struct MapWidgetEntryView: View { 128 | var entry: MapProvider.Entry 129 | 130 | var body: some View { 131 | ZStack { 132 | Image(uiImage: entry.mapImage) 133 | if entry.placeholderReadings { 134 | placeholderReading(aqi: 5, x: -100, y: -90) 135 | placeholderReading(aqi: 10, x: -50, y: -95) 136 | placeholderReading(aqi: 7, x: -40, y: -60) 137 | placeholderReading(aqi: 15, x: 10, y: -35) 138 | placeholderReading(aqi: 75, x: 20, y: 20) 139 | placeholderReading(aqi: 110, x: 80, y: -20) 140 | placeholderReading(aqi: 175, x: 110, y: -40) 141 | placeholderReading(aqi: 100, x: 40, y: 60) 142 | placeholderReading(aqi: 90, x: 90, y: 40) 143 | } 144 | } 145 | } 146 | } 147 | 148 | struct MapWidget: Widget { 149 | private let kind: String = "MapWidget" 150 | 151 | public var body: some WidgetConfiguration { 152 | IntentConfiguration(kind: kind, intent: LocationSelectionIntent.self, provider: MapProvider()) { entry in 153 | MapWidgetEntryView(entry: entry) 154 | } 155 | .configurationDisplayName(NSLocalizedString("Air Quality Map", comment: "Title of air quality map widget")) 156 | .description(NSLocalizedString("A map of air quality readings around your location", comment: "Description of air quality map widget")) 157 | .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) 158 | } 159 | } 160 | 161 | private func defaultLocation() -> CLLocation { 162 | // Apple Headquarters 163 | return CLLocation(latitude: 37.33468, longitude: -122.00898) 164 | } 165 | 166 | private func placeholderReading(aqi: UInt32, x: CGFloat, y: CGFloat) -> some View { 167 | return Text(String(aqi)) 168 | .font(Font.system(size: 12)) 169 | .foregroundColor(swiftColor(AQI.textColor(aqi: aqi))).opacity(/*@START_MENU_TOKEN@*/0.8/*@END_MENU_TOKEN@*/) 170 | .frame(width: readingSize, height: readingSize) 171 | .background(swiftColor(AQI.color(aqi: aqi))) 172 | .cornerRadius(readingSize / 2) 173 | .position(x: 208 + x, y: 208 + y) 174 | } 175 | 176 | private func cgColor(_ color: AQI.Color, _ alpha: CGFloat = 1.0) -> CGColor { 177 | return CGColor(red: CGFloat(color.r / 255.0), green: CGFloat(color.g / 255.0), blue: CGFloat(color.b / 255.0), alpha: alpha) 178 | } 179 | 180 | private func uiColor(_ color: AQI.Color, _ alpha: CGFloat = 1.0) -> UIColor { 181 | return UIColor(red: CGFloat(color.r / 255.0), green: CGFloat(color.g / 255.0), blue: CGFloat(color.b / 255.0), alpha: alpha) 182 | } 183 | 184 | private func swiftColor(_ color: AQI.Color) -> SwiftUI.Color { 185 | return SwiftUI.Color(red: Double(color.r / 255.0), green: Double(color.g / 255.0), blue: Double(color.b / 255.0)) 186 | } 187 | 188 | struct MapWidget_Previews: PreviewProvider { 189 | static var previews: some View { 190 | Group { 191 | MapWidgetEntryView(entry: MapEntry(date: Date(), mapImage: UIImage(named: "MapPlaceholder")!, placeholderReadings: true)).previewContext(WidgetPreviewContext(family: .systemSmall)) 192 | MapWidgetEntryView(entry: MapEntry(date: Date(), mapImage: UIImage(named: "MapPlaceholder")!, placeholderReadings: true)).previewContext(WidgetPreviewContext(family: .systemMedium)) 193 | MapWidgetEntryView(entry: MapEntry(date: Date(), mapImage: UIImage(named: "MapPlaceholder")!, placeholderReadings: true)).previewContext(WidgetPreviewContext(family: .systemLarge)) 194 | } 195 | } 196 | } 197 | 198 | -------------------------------------------------------------------------------- /client/macos/Air.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 87278FDB252A1346007C87D2 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87278FDA252A1346007C87D2 /* main.swift */; }; 11 | 875FAF49252A1F1C000042D8 /* Map.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876E10EC252A1ECA00C9F008 /* Map.swift */; }; 12 | 8779201E252A1FCB002F7DBE /* AQI in Frameworks */ = {isa = PBXBuildFile; productRef = 8779201D252A1FCB002F7DBE /* AQI */; }; 13 | 87792020252A1FD1002F7DBE /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8779201F252A1FD1002F7DBE /* MapKit.framework */; }; 14 | 87792022252A1FD8002F7DBE /* GameKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87792021252A1FD8002F7DBE /* GameKit.framework */; }; 15 | 8793BF3C25299B7D0030393E /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8793BF3B25299B7D0030393E /* Application.swift */; }; 16 | 8793BF4025299B7E0030393E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8793BF3F25299B7E0030393E /* Assets.xcassets */; }; 17 | 87D7294A253CE3C90081FD81 /* Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D72948253CE3820081FD81 /* Detail.swift */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXFileReference section */ 21 | 87278FDA252A1346007C87D2 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 22 | 876E10EC252A1ECA00C9F008 /* Map.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Map.swift; sourceTree = ""; }; 23 | 8779201A252A1FBC002F7DBE /* AQI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AQI; path = ../swift/AQI; sourceTree = ""; }; 24 | 8779201F252A1FD1002F7DBE /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = System/Library/Frameworks/MapKit.framework; sourceTree = SDKROOT; }; 25 | 87792021252A1FD8002F7DBE /* GameKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GameKit.framework; path = System/Library/Frameworks/GameKit.framework; sourceTree = SDKROOT; }; 26 | 8793BF3825299B7D0030393E /* Air Quality.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Air Quality.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 27 | 8793BF3B25299B7D0030393E /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 28 | 8793BF3F25299B7E0030393E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 29 | 8793BF4725299B7E0030393E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 30 | 8793BF4825299B7E0030393E /* Air.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Air.entitlements; sourceTree = ""; }; 31 | 87D72948253CE3820081FD81 /* Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Detail.swift; sourceTree = ""; }; 32 | /* End PBXFileReference section */ 33 | 34 | /* Begin PBXFrameworksBuildPhase section */ 35 | 8793BF3525299B7D0030393E /* Frameworks */ = { 36 | isa = PBXFrameworksBuildPhase; 37 | buildActionMask = 2147483647; 38 | files = ( 39 | 87792022252A1FD8002F7DBE /* GameKit.framework in Frameworks */, 40 | 87792020252A1FD1002F7DBE /* MapKit.framework in Frameworks */, 41 | 8779201E252A1FCB002F7DBE /* AQI in Frameworks */, 42 | ); 43 | runOnlyForDeploymentPostprocessing = 0; 44 | }; 45 | /* End PBXFrameworksBuildPhase section */ 46 | 47 | /* Begin PBXGroup section */ 48 | 8779201C252A1FCB002F7DBE /* Frameworks */ = { 49 | isa = PBXGroup; 50 | children = ( 51 | 87792021252A1FD8002F7DBE /* GameKit.framework */, 52 | 8779201F252A1FD1002F7DBE /* MapKit.framework */, 53 | ); 54 | name = Frameworks; 55 | sourceTree = ""; 56 | }; 57 | 8793BF2F25299B7D0030393E = { 58 | isa = PBXGroup; 59 | children = ( 60 | 87B20F3A252A597F0028DA82 /* Application */, 61 | 8779201A252A1FBC002F7DBE /* AQI */, 62 | 8793BF3925299B7D0030393E /* Products */, 63 | 8779201C252A1FCB002F7DBE /* Frameworks */, 64 | ); 65 | sourceTree = ""; 66 | }; 67 | 8793BF3925299B7D0030393E /* Products */ = { 68 | isa = PBXGroup; 69 | children = ( 70 | 8793BF3825299B7D0030393E /* Air Quality.app */, 71 | ); 72 | name = Products; 73 | sourceTree = ""; 74 | }; 75 | 87B20F3A252A597F0028DA82 /* Application */ = { 76 | isa = PBXGroup; 77 | children = ( 78 | 8793BF3B25299B7D0030393E /* Application.swift */, 79 | 87D72948253CE3820081FD81 /* Detail.swift */, 80 | 876E10EC252A1ECA00C9F008 /* Map.swift */, 81 | 87278FDA252A1346007C87D2 /* main.swift */, 82 | 8793BF3F25299B7E0030393E /* Assets.xcassets */, 83 | 8793BF4725299B7E0030393E /* Info.plist */, 84 | 8793BF4825299B7E0030393E /* Air.entitlements */, 85 | ); 86 | path = Application; 87 | sourceTree = ""; 88 | }; 89 | /* End PBXGroup section */ 90 | 91 | /* Begin PBXNativeTarget section */ 92 | 8793BF3725299B7D0030393E /* Air */ = { 93 | isa = PBXNativeTarget; 94 | buildConfigurationList = 8793BF4B25299B7E0030393E /* Build configuration list for PBXNativeTarget "Air" */; 95 | buildPhases = ( 96 | 8793BF3425299B7D0030393E /* Sources */, 97 | 8793BF3525299B7D0030393E /* Frameworks */, 98 | 8793BF3625299B7D0030393E /* Resources */, 99 | ); 100 | buildRules = ( 101 | ); 102 | dependencies = ( 103 | ); 104 | name = Air; 105 | packageProductDependencies = ( 106 | 8779201D252A1FCB002F7DBE /* AQI */, 107 | ); 108 | productName = Air; 109 | productReference = 8793BF3825299B7D0030393E /* Air Quality.app */; 110 | productType = "com.apple.product-type.application"; 111 | }; 112 | /* End PBXNativeTarget section */ 113 | 114 | /* Begin PBXProject section */ 115 | 8793BF3025299B7D0030393E /* Project object */ = { 116 | isa = PBXProject; 117 | attributes = { 118 | LastSwiftUpdateCheck = 1200; 119 | LastUpgradeCheck = 1220; 120 | TargetAttributes = { 121 | 8793BF3725299B7D0030393E = { 122 | CreatedOnToolsVersion = 12.0.1; 123 | }; 124 | }; 125 | }; 126 | buildConfigurationList = 8793BF3325299B7D0030393E /* Build configuration list for PBXProject "Air" */; 127 | compatibilityVersion = "Xcode 9.3"; 128 | developmentRegion = en; 129 | hasScannedForEncodings = 0; 130 | knownRegions = ( 131 | en, 132 | Base, 133 | ); 134 | mainGroup = 8793BF2F25299B7D0030393E; 135 | productRefGroup = 8793BF3925299B7D0030393E /* Products */; 136 | projectDirPath = ""; 137 | projectRoot = ""; 138 | targets = ( 139 | 8793BF3725299B7D0030393E /* Air */, 140 | ); 141 | }; 142 | /* End PBXProject section */ 143 | 144 | /* Begin PBXResourcesBuildPhase section */ 145 | 8793BF3625299B7D0030393E /* Resources */ = { 146 | isa = PBXResourcesBuildPhase; 147 | buildActionMask = 2147483647; 148 | files = ( 149 | 8793BF4025299B7E0030393E /* Assets.xcassets in Resources */, 150 | ); 151 | runOnlyForDeploymentPostprocessing = 0; 152 | }; 153 | /* End PBXResourcesBuildPhase section */ 154 | 155 | /* Begin PBXSourcesBuildPhase section */ 156 | 8793BF3425299B7D0030393E /* Sources */ = { 157 | isa = PBXSourcesBuildPhase; 158 | buildActionMask = 2147483647; 159 | files = ( 160 | 87D7294A253CE3C90081FD81 /* Detail.swift in Sources */, 161 | 8793BF3C25299B7D0030393E /* Application.swift in Sources */, 162 | 875FAF49252A1F1C000042D8 /* Map.swift in Sources */, 163 | 87278FDB252A1346007C87D2 /* main.swift in Sources */, 164 | ); 165 | runOnlyForDeploymentPostprocessing = 0; 166 | }; 167 | /* End PBXSourcesBuildPhase section */ 168 | 169 | /* Begin XCBuildConfiguration section */ 170 | 8793BF4925299B7E0030393E /* Debug */ = { 171 | isa = XCBuildConfiguration; 172 | buildSettings = { 173 | ALWAYS_SEARCH_USER_PATHS = NO; 174 | CLANG_ANALYZER_NONNULL = YES; 175 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 176 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 177 | CLANG_CXX_LIBRARY = "libc++"; 178 | CLANG_ENABLE_MODULES = YES; 179 | CLANG_ENABLE_OBJC_ARC = YES; 180 | CLANG_ENABLE_OBJC_WEAK = YES; 181 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 182 | CLANG_WARN_BOOL_CONVERSION = YES; 183 | CLANG_WARN_COMMA = YES; 184 | CLANG_WARN_CONSTANT_CONVERSION = YES; 185 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 186 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 187 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 188 | CLANG_WARN_EMPTY_BODY = YES; 189 | CLANG_WARN_ENUM_CONVERSION = YES; 190 | CLANG_WARN_INFINITE_RECURSION = YES; 191 | CLANG_WARN_INT_CONVERSION = YES; 192 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 193 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 194 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 195 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 196 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 197 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 198 | CLANG_WARN_STRICT_PROTOTYPES = YES; 199 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 200 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 201 | CLANG_WARN_UNREACHABLE_CODE = YES; 202 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 203 | COPY_PHASE_STRIP = NO; 204 | DEBUG_INFORMATION_FORMAT = dwarf; 205 | ENABLE_STRICT_OBJC_MSGSEND = YES; 206 | ENABLE_TESTABILITY = YES; 207 | GCC_C_LANGUAGE_STANDARD = gnu11; 208 | GCC_DYNAMIC_NO_PIC = NO; 209 | GCC_NO_COMMON_BLOCKS = YES; 210 | GCC_OPTIMIZATION_LEVEL = 0; 211 | GCC_PREPROCESSOR_DEFINITIONS = ( 212 | "DEBUG=1", 213 | "$(inherited)", 214 | ); 215 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 216 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 217 | GCC_WARN_UNDECLARED_SELECTOR = YES; 218 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 219 | GCC_WARN_UNUSED_FUNCTION = YES; 220 | GCC_WARN_UNUSED_VARIABLE = YES; 221 | MACOSX_DEPLOYMENT_TARGET = 10.15; 222 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 223 | MTL_FAST_MATH = YES; 224 | ONLY_ACTIVE_ARCH = YES; 225 | SDKROOT = macosx; 226 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 227 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 228 | }; 229 | name = Debug; 230 | }; 231 | 8793BF4A25299B7E0030393E /* Release */ = { 232 | isa = XCBuildConfiguration; 233 | buildSettings = { 234 | ALWAYS_SEARCH_USER_PATHS = NO; 235 | CLANG_ANALYZER_NONNULL = YES; 236 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 237 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 238 | CLANG_CXX_LIBRARY = "libc++"; 239 | CLANG_ENABLE_MODULES = YES; 240 | CLANG_ENABLE_OBJC_ARC = YES; 241 | CLANG_ENABLE_OBJC_WEAK = YES; 242 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 243 | CLANG_WARN_BOOL_CONVERSION = YES; 244 | CLANG_WARN_COMMA = YES; 245 | CLANG_WARN_CONSTANT_CONVERSION = YES; 246 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 247 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 248 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 249 | CLANG_WARN_EMPTY_BODY = YES; 250 | CLANG_WARN_ENUM_CONVERSION = YES; 251 | CLANG_WARN_INFINITE_RECURSION = YES; 252 | CLANG_WARN_INT_CONVERSION = YES; 253 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 254 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 255 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 256 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 257 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 258 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 259 | CLANG_WARN_STRICT_PROTOTYPES = YES; 260 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 261 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 262 | CLANG_WARN_UNREACHABLE_CODE = YES; 263 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 264 | COPY_PHASE_STRIP = NO; 265 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 266 | ENABLE_NS_ASSERTIONS = NO; 267 | ENABLE_STRICT_OBJC_MSGSEND = YES; 268 | GCC_C_LANGUAGE_STANDARD = gnu11; 269 | GCC_NO_COMMON_BLOCKS = YES; 270 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 271 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 272 | GCC_WARN_UNDECLARED_SELECTOR = YES; 273 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 274 | GCC_WARN_UNUSED_FUNCTION = YES; 275 | GCC_WARN_UNUSED_VARIABLE = YES; 276 | MACOSX_DEPLOYMENT_TARGET = 10.15; 277 | MTL_ENABLE_DEBUG_INFO = NO; 278 | MTL_FAST_MATH = YES; 279 | SDKROOT = macosx; 280 | SWIFT_COMPILATION_MODE = wholemodule; 281 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 282 | }; 283 | name = Release; 284 | }; 285 | 8793BF4C25299B7E0030393E /* Debug */ = { 286 | isa = XCBuildConfiguration; 287 | buildSettings = { 288 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 289 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 290 | CODE_SIGN_ENTITLEMENTS = Application/Air.entitlements; 291 | CODE_SIGN_IDENTITY = "Apple Development"; 292 | CODE_SIGN_STYLE = Automatic; 293 | COMBINE_HIDPI_IMAGES = YES; 294 | DEVELOPMENT_TEAM = 8UEGUK2NRD; 295 | ENABLE_HARDENED_RUNTIME = YES; 296 | ENABLE_PREVIEWS = YES; 297 | INFOPLIST_FILE = Application/Info.plist; 298 | LD_RUNPATH_SEARCH_PATHS = ( 299 | "$(inherited)", 300 | "@executable_path/../Frameworks", 301 | ); 302 | MACOSX_DEPLOYMENT_TARGET = 10.15; 303 | PRODUCT_BUNDLE_IDENTIFIER = org.backchannel.Air; 304 | PRODUCT_NAME = "Air Quality"; 305 | SWIFT_VERSION = 5.0; 306 | }; 307 | name = Debug; 308 | }; 309 | 8793BF4D25299B7E0030393E /* Release */ = { 310 | isa = XCBuildConfiguration; 311 | buildSettings = { 312 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 313 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 314 | CODE_SIGN_ENTITLEMENTS = Application/Air.entitlements; 315 | CODE_SIGN_IDENTITY = "Apple Development"; 316 | CODE_SIGN_STYLE = Automatic; 317 | COMBINE_HIDPI_IMAGES = YES; 318 | DEVELOPMENT_TEAM = 8UEGUK2NRD; 319 | ENABLE_HARDENED_RUNTIME = YES; 320 | ENABLE_PREVIEWS = YES; 321 | INFOPLIST_FILE = Application/Info.plist; 322 | LD_RUNPATH_SEARCH_PATHS = ( 323 | "$(inherited)", 324 | "@executable_path/../Frameworks", 325 | ); 326 | MACOSX_DEPLOYMENT_TARGET = 10.15; 327 | PRODUCT_BUNDLE_IDENTIFIER = org.backchannel.Air; 328 | PRODUCT_NAME = "Air Quality"; 329 | SWIFT_VERSION = 5.0; 330 | }; 331 | name = Release; 332 | }; 333 | /* End XCBuildConfiguration section */ 334 | 335 | /* Begin XCConfigurationList section */ 336 | 8793BF3325299B7D0030393E /* Build configuration list for PBXProject "Air" */ = { 337 | isa = XCConfigurationList; 338 | buildConfigurations = ( 339 | 8793BF4925299B7E0030393E /* Debug */, 340 | 8793BF4A25299B7E0030393E /* Release */, 341 | ); 342 | defaultConfigurationIsVisible = 0; 343 | defaultConfigurationName = Release; 344 | }; 345 | 8793BF4B25299B7E0030393E /* Build configuration list for PBXNativeTarget "Air" */ = { 346 | isa = XCConfigurationList; 347 | buildConfigurations = ( 348 | 8793BF4C25299B7E0030393E /* Debug */, 349 | 8793BF4D25299B7E0030393E /* Release */, 350 | ); 351 | defaultConfigurationIsVisible = 0; 352 | defaultConfigurationName = Release; 353 | }; 354 | /* End XCConfigurationList section */ 355 | 356 | /* Begin XCSwiftPackageProductDependency section */ 357 | 8779201D252A1FCB002F7DBE /* AQI */ = { 358 | isa = XCSwiftPackageProductDependency; 359 | productName = AQI; 360 | }; 361 | /* End XCSwiftPackageProductDependency section */ 362 | }; 363 | rootObject = 8793BF3025299B7D0030393E /* Project object */; 364 | } 365 | -------------------------------------------------------------------------------- /client/macos/Application/Air.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.personal-information.location 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/macos/Application/Application.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import Cocoa 4 | 5 | class ApplicationDelegate: NSObject, NSApplicationDelegate { 6 | var _controller: ApplicationWindowController 7 | 8 | override init() { 9 | _controller = ApplicationWindowController() 10 | super.init() 11 | self._createMenu() 12 | } 13 | 14 | func applicationDidFinishLaunching(_ notification: Notification) { 15 | NSWindow.allowsAutomaticWindowTabbing = false 16 | _controller.window?.setFrameAutosaveName("Air") 17 | _controller.window?.makeKeyAndOrderFront(nil) 18 | } 19 | 20 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 21 | return true 22 | } 23 | 24 | private func _createMenu() { 25 | let applicationName = Bundle.main.infoDictionary![kCFBundleNameKey! as String] as! String 26 | let mainMenu = NSMenu(title: applicationName) 27 | 28 | @discardableResult 29 | func addMenu(title: String, items: Array) -> NSMenu { 30 | let menu = NSMenu(title: title) 31 | for item in items { 32 | menu.addItem(item) 33 | } 34 | mainMenu.addItem(withTitle: title, action: nil, keyEquivalent: "").submenu = menu 35 | return menu 36 | } 37 | 38 | addMenu(title: applicationName, items: self._applicationMenu()) 39 | addMenu(title: NSLocalizedString("View", comment: "View menu"), items: self._viewMenu()) 40 | NSApplication.shared.windowsMenu = addMenu(title: NSLocalizedString("Window", comment: "Window menu"), items: self._windowMenu()) 41 | NSApplication.shared.helpMenu = addMenu(title: NSLocalizedString("Help", comment: "Help menu"), items: self._helpMenu()) 42 | NSApplication.shared.mainMenu = mainMenu 43 | } 44 | 45 | private func _applicationMenu() -> Array { 46 | let applicationName = Bundle.main.infoDictionary![kCFBundleNameKey! as String] as! String 47 | return [ 48 | NSMenuItem(title: String(format: NSLocalizedString("About %@", comment: "About menu item"), applicationName), action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: ""), 49 | NSMenuItem.separator(), 50 | {() -> NSMenuItem in 51 | let title = NSLocalizedString("Services", comment: "Services menu item") 52 | let menuItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") 53 | let servicesMenu = NSMenu(title: title) 54 | menuItem.submenu = servicesMenu 55 | NSApplication.shared.servicesMenu = servicesMenu 56 | return menuItem 57 | }(), 58 | NSMenuItem.separator(), 59 | NSMenuItem(title: String(format: NSLocalizedString("Hide %@", comment: "Hide menu item"), applicationName), action: #selector(NSApplication.hide(_:)), keyEquivalent: "h"), 60 | {() -> NSMenuItem in 61 | let menuItem = NSMenuItem(title: NSLocalizedString("Hide Others", comment: "Show All menu item"), action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") 62 | menuItem.keyEquivalentModifierMask = [.command, .option] 63 | return menuItem 64 | }(), 65 | NSMenuItem(title: NSLocalizedString("Show All", comment: "Show All menu item"), action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: ""), 66 | NSMenuItem.separator(), 67 | NSMenuItem(title: String(format: NSLocalizedString("Quit %@", comment: "Quit menu item"), applicationName), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"), 68 | ] 69 | } 70 | 71 | private func _viewMenu() -> Array { 72 | return [ 73 | NSMenuItem(title: NSLocalizedString("Enter Full Screen", comment: "Enter Full Screen menu item"), action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f"), 74 | ] 75 | } 76 | 77 | private func _windowMenu() -> Array { 78 | return [ 79 | NSMenuItem(title: NSLocalizedString("Minimize", comment: "Minimize menu item"), action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m"), 80 | NSMenuItem(title: NSLocalizedString("Zoom", comment: "Zoom menu item"), action: #selector(NSWindow.performZoom(_:)), keyEquivalent: ""), 81 | ] 82 | } 83 | 84 | private func _helpMenu() -> Array { 85 | let applicationName = Bundle.main.infoDictionary![kCFBundleNameKey! as String] as! String 86 | return [ 87 | NSMenuItem(title: String(format: NSLocalizedString("%@ Help", comment: " Help menu item"), applicationName), action: #selector(NSApplication.showHelp(_:)), keyEquivalent: "?"), 88 | ] 89 | } 90 | } 91 | 92 | class ApplicationWindowController: NSWindowController, NSToolbarDelegate, MapControllerDelegate { 93 | init() { 94 | let mapController = MapController() 95 | super.init(window: NSWindow(contentViewController: mapController)) 96 | self.window?.title = NSLocalizedString("Air Quality Index", comment: "Title of main map displaying AQI by location") 97 | let toolbar = NSToolbar(identifier: self.className) 98 | toolbar.delegate = self 99 | self.window?.toolbar = toolbar 100 | self.window?.titleVisibility = .hidden 101 | mapController.mapControllerDelagate = self 102 | 103 | // Auto-update every 10 minutes 104 | Timer.scheduledTimer(withTimeInterval: 600, repeats: true) { (timer) in 105 | if let map = self.window?.contentViewController as? MapController { 106 | map.downloadSensorData(interactive: false) 107 | } 108 | } 109 | } 110 | 111 | @available(*, unavailable) 112 | required init?(coder: NSCoder) { 113 | fatalError("init(coder:) has not been implemented") 114 | } 115 | 116 | func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 117 | return [ 118 | NSToolbarItem.Identifier("Location"), 119 | NSToolbarItem.Identifier("Search"), 120 | NSToolbarItem.Identifier("Refresh"), 121 | ] 122 | } 123 | 124 | func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 125 | return [ 126 | NSToolbarItem.Identifier.flexibleSpace, 127 | NSToolbarItem.Identifier.space, 128 | NSToolbarItem.Identifier("Location"), 129 | NSToolbarItem.Identifier("Search"), 130 | NSToolbarItem.Identifier("Refresh"), 131 | NSToolbarItem.Identifier("Progress"), 132 | NSToolbarItem.Identifier.flexibleSpace, 133 | ] 134 | } 135 | 136 | func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { 137 | switch itemIdentifier.rawValue { 138 | case "Search": 139 | let search = NSSearchField(frame: NSMakeRect(0, 0, 300, 10)) 140 | let item = NSToolbarItem(itemIdentifier: itemIdentifier) 141 | item.label = NSLocalizedString("Search", comment: "Search the map") 142 | item.view = search 143 | return item 144 | case "Refresh": 145 | let label = NSLocalizedString("Refresh", comment: "Refresh the sensor data on the map") 146 | let item = NSToolbarItem(itemIdentifier: itemIdentifier) 147 | item.label = label 148 | item.toolTip = label 149 | item.action = #selector(self._refresh) 150 | let button = NSButton(image: NSImage(named: NSImage.refreshTemplateName)!, target: nil, action: item.action) 151 | button.bezelStyle = .texturedRounded 152 | button.sizeToFit() 153 | item.view = button 154 | return item 155 | case "Progress": 156 | let label = NSLocalizedString("Download Progress", comment: "Progress of new sensor data download") 157 | let item = NSToolbarItem(itemIdentifier: itemIdentifier) 158 | item.label = label 159 | item.toolTip = label 160 | let progress = NSProgressIndicator() 161 | progress.wantsLayer = true 162 | progress.style = .spinning 163 | progress.isIndeterminate = false 164 | progress.minValue = 0 165 | progress.maxValue = 1 166 | progress.doubleValue = 0 167 | progress.controlSize = .small 168 | progress.isDisplayedWhenStopped = true 169 | progress.isHidden = true 170 | item.view = progress 171 | return item 172 | case "Location": 173 | let label = NSLocalizedString("Current Location", comment: "Center the map on the user's current location") 174 | let item = NSToolbarItem(itemIdentifier: itemIdentifier) 175 | item.label = label 176 | item.toolTip = label 177 | item.action = #selector(self._currentLocation) 178 | let image = NSImage(imageLiteralResourceName: "Location") 179 | image.isTemplate = true 180 | let button = NSButton(image: image, target: nil, action: item.action) 181 | button.bezelStyle = .texturedRounded 182 | button.sizeToFit() 183 | item.view = button 184 | return item 185 | default: 186 | return nil 187 | } 188 | } 189 | 190 | func mapControllerDownloadStarted(_ mapController: MapController, interactive: Bool) { 191 | if let indicator = self._progressIndicator() { 192 | indicator.doubleValue = 0 193 | indicator.isHidden = !interactive 194 | } 195 | } 196 | 197 | func mapControllerDownloading(_ mapController: MapController, interactive: Bool, progess: Float) { 198 | if let indicator = self._progressIndicator() { 199 | indicator.doubleValue = Double(progess) 200 | } 201 | } 202 | 203 | func mapControllerDownloadCompleted(_ mapController: MapController, interactive: Bool, withError error: Error?) { 204 | if let indicator = self._progressIndicator() { 205 | indicator.doubleValue = 1 206 | indicator.isHidden = true 207 | } 208 | if error != nil, 209 | let window = self.window, 210 | interactive { 211 | let alert = NSAlert() 212 | alert.alertStyle = .critical 213 | alert.messageText = NSLocalizedString("Unable to download new air quality data", comment: "Error message when new map data cannot be downloaded") 214 | alert.informativeText = NSLocalizedString("There may be a problem with your internet connection or a temporary problem with the air quality server.", comment: "Error message description when new map data cannot be downloaded") 215 | alert.beginSheetModal(for: window, completionHandler: nil) 216 | } 217 | } 218 | 219 | @objc private func _refresh(_ button: NSButton?) { 220 | if let map = self.window?.contentViewController as? MapController { 221 | map.downloadSensorData(interactive: true) 222 | } 223 | } 224 | 225 | @objc private func _currentLocation(_ button: NSButton?) { 226 | if let map = self.window?.contentViewController as? MapController { 227 | if !map.centerMapOnCurrentLocation(), 228 | let window = self.window { 229 | let alert = NSAlert() 230 | alert.alertStyle = .warning 231 | alert.messageText = NSLocalizedString("Cannot determine current location", comment: "Error message when current location cannot be detected") 232 | alert.informativeText = NSLocalizedString("Ensure WiFi is enabled. If you continue to observe this problem, ensure this application has access to Location Services in your Security & Privacy system preferences.", comment: "Error message description when current location cannot be detected") 233 | alert.beginSheetModal(for: window, completionHandler: nil) 234 | } 235 | } 236 | } 237 | 238 | private func _toolbarItem(_ identifier: String) -> NSToolbarItem? { 239 | if let toolbar = self.window?.toolbar { 240 | for item in toolbar.items { 241 | if item.itemIdentifier.rawValue == identifier { 242 | return item 243 | } 244 | } 245 | } 246 | return nil 247 | } 248 | 249 | private func _progressIndicator() -> NSProgressIndicator? { 250 | if let item = self._toolbarItem("Progress") { 251 | return item.view as? NSProgressIndicator 252 | } 253 | return nil 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "osx", 6 | "reference" : "systemPurpleColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-1.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-2.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-3.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-4.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-5.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-6.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-7.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-8.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon-9.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/client/macos/Application/Assets.xcassets/AppIcon.appiconset/AppIcon.png -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon-9.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "AppIcon-8.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "AppIcon-7.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "AppIcon-6.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "AppIcon-5.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "AppIcon-4.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "AppIcon-3.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "AppIcon-2.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "AppIcon-1.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "AppIcon.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/Location.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Location.pdf", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "preserves-vector-representation" : true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/macos/Application/Assets.xcassets/Location.imageset/Location.pdf: -------------------------------------------------------------------------------- 1 | %PDF-1.7 2 | 3 | 1 0 obj 4 | << >> 5 | endobj 6 | 7 | 2 0 obj 8 | << /Length 3 0 R >> 9 | stream 10 | /DeviceRGB CS 11 | /DeviceRGB cs 12 | q 13 | 1.000000 0.000000 -0.000000 1.000000 1.199951 0.939301 cm 14 | 0.000000 0.000000 0.000000 scn 15 | 0.000000 7.727386 m 16 | -0.214043 8.179255 l 17 | -0.404901 8.088849 -0.518377 7.888277 -0.497567 7.678117 c 18 | -0.476757 7.467958 -0.326149 7.293535 -0.121268 7.242315 c 19 | 0.000000 7.727386 l 20 | h 21 | 12.666667 13.727386 m 22 | 13.118536 13.513343 l 23 | 13.208994 13.704309 13.169637 13.931523 13.020221 14.080940 c 24 | 12.870804 14.230356 12.643590 14.269713 12.452624 14.179255 c 25 | 12.666667 13.727386 l 26 | h 27 | 6.666667 1.060719 m 28 | 6.181596 0.939451 l 29 | 6.232816 0.734570 6.407238 0.583962 6.617398 0.563152 c 30 | 6.827558 0.542343 7.028130 0.655818 7.118536 0.846676 c 31 | 6.666667 1.060719 l 32 | h 33 | 5.333333 6.394053 m 34 | 5.818405 6.515321 l 35 | 5.773619 6.694463 5.633744 6.834339 5.454601 6.879124 c 36 | 5.333333 6.394053 l 37 | h 38 | 0.214043 7.275517 m 39 | 12.880711 13.275517 l 40 | 12.452624 14.179255 l 41 | -0.214043 8.179255 l 42 | 0.214043 7.275517 l 43 | h 44 | 12.214798 13.941430 m 45 | 6.214798 1.274762 l 46 | 7.118536 0.846676 l 47 | 13.118536 13.513343 l 48 | 12.214798 13.941430 l 49 | h 50 | 7.151738 1.181987 m 51 | 5.818405 6.515321 l 52 | 4.848262 6.272785 l 53 | 6.181596 0.939451 l 54 | 7.151738 1.181987 l 55 | h 56 | 5.454601 6.879124 m 57 | 0.121268 8.212458 l 58 | -0.121268 7.242315 l 59 | 5.212066 5.908982 l 60 | 5.454601 6.879124 l 61 | h 62 | f 63 | n 64 | Q 65 | 66 | endstream 67 | endobj 68 | 69 | 3 0 obj 70 | 1210 71 | endobj 72 | 73 | 4 0 obj 74 | << /Annots [] 75 | /Type /Page 76 | /MediaBox [ 0.000000 0.000000 16.000000 16.000000 ] 77 | /Resources 1 0 R 78 | /Contents 2 0 R 79 | /Parent 5 0 R 80 | >> 81 | endobj 82 | 83 | 5 0 obj 84 | << /Kids [ 4 0 R ] 85 | /Count 1 86 | /Type /Pages 87 | >> 88 | endobj 89 | 90 | 6 0 obj 91 | << /Type /Catalog 92 | /Pages 5 0 R 93 | >> 94 | endobj 95 | 96 | xref 97 | 0 7 98 | 0000000000 65535 f 99 | 0000000010 00000 n 100 | 0000000034 00000 n 101 | 0000001300 00000 n 102 | 0000001323 00000 n 103 | 0000001496 00000 n 104 | 0000001570 00000 n 105 | trailer 106 | << /ID [ (some) (id) ] 107 | /Root 6 0 R 108 | /Size 7 109 | >> 110 | startxref 111 | 1629 112 | %%EOF -------------------------------------------------------------------------------- /client/macos/Application/Detail.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import AQI 4 | import Cocoa 5 | 6 | class SensorDetailView: NSView { 7 | var reading: AQI.Reading? { 8 | didSet { 9 | if let reading = self.reading { 10 | self._currentReading.aqi = reading.aqi 11 | self._aqi10M.aqi = reading.aqi10M 12 | self._aqi30M.aqi = reading.aqi30M 13 | self._aqi1H.aqi = reading.aqi1H 14 | self._aqi6H.aqi = reading.aqi6H 15 | self._aqi24H.aqi = reading.aqi24H 16 | let formatter = RelativeDateTimeFormatter() 17 | formatter.unitsStyle = .full 18 | formatter.dateTimeStyle = .named 19 | self._aqiHeading.stringValue = "Updated " + formatter.localizedString(for: reading.lastUpdated, relativeTo: Date()) 20 | } 21 | } 22 | } 23 | 24 | private lazy var _aqiHeading: NSTextField = { 25 | return self._heading(NSLocalizedString("AQI", comment: "Title of air quality index detail")) 26 | }() 27 | 28 | private lazy var _currentReading: CurrentReadingView = { 29 | return CurrentReadingView(); 30 | }() 31 | 32 | private lazy var _aqi10M: HistoricalReadingView = { 33 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("10m", comment: "Very short abbreviation of 10 minutes")) 34 | }() 35 | 36 | private lazy var _aqi30M: HistoricalReadingView = { 37 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("30m", comment: "Very short abbreviation of 30 minutes")) 38 | }() 39 | 40 | private lazy var _aqi1H: HistoricalReadingView = { 41 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("1h", comment: "Very short abbreviation of 1 hour")) 42 | }() 43 | 44 | private lazy var _aqi6H: HistoricalReadingView = { 45 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("6h", comment: "Very short abbreviation of 6 hours")) 46 | }() 47 | 48 | private lazy var _aqi24H: HistoricalReadingView = { 49 | return HistoricalReadingView(timeDescriptor: NSLocalizedString("24h", comment: "Very short abbreviation of 24 hours")) 50 | }() 51 | 52 | init() { 53 | super.init(frame: .zero) 54 | 55 | let historicalReadings = NSStackView(views: [ 56 | self._aqi10M, 57 | self._aqi30M, 58 | self._aqi1H, 59 | self._aqi6H, 60 | self._aqi24H, 61 | ]) 62 | historicalReadings.orientation = .horizontal 63 | historicalReadings.spacing = 1 64 | 65 | let stack = NSStackView(views: [ 66 | self._aqiHeading, 67 | self._currentReading, 68 | self._heading(NSLocalizedString("Air Quality Average", comment: "Title of table of average AQI from last 24 hours")), 69 | historicalReadings, 70 | ]) 71 | stack.translatesAutoresizingMaskIntoConstraints = false 72 | stack.spacing = 5 73 | stack.orientation = .vertical 74 | stack.alignment = .leading 75 | stack.setCustomSpacing(20, after: self._currentReading) 76 | self.addSubview(stack) 77 | 78 | NSLayoutConstraint.activate([ 79 | stack.leftAnchor.constraint(equalTo: self.leftAnchor), 80 | stack.rightAnchor.constraint(equalTo: self.rightAnchor), 81 | stack.topAnchor.constraint(equalTo: self.topAnchor, constant: 15), 82 | stack.bottomAnchor.constraint(equalTo: self.bottomAnchor), 83 | ]) 84 | self.translatesAutoresizingMaskIntoConstraints = false 85 | } 86 | 87 | private func _heading(_ title: String) -> NSTextField { 88 | let field = NSTextField() 89 | field.isBezeled = false 90 | field.isEditable = false 91 | field.isSelectable = false 92 | field.usesSingleLineMode = true 93 | field.maximumNumberOfLines = 1 94 | field.font = NSFont.systemFont(ofSize: 12, weight: .medium) 95 | field.backgroundColor = .clear 96 | field.textColor = NSColor.labelColor 97 | field.stringValue = title 98 | return field 99 | } 100 | 101 | @available(*, unavailable) 102 | required init?(coder aDecoder: NSCoder) { 103 | fatalError("init(coder:) has not been implemented") 104 | } 105 | } 106 | 107 | private class CurrentReadingView: NSView { 108 | let _circleSize: CGFloat = 50 109 | 110 | var aqi: UInt32 = 0 { 111 | didSet { 112 | self._reading.stringValue = String(aqi) 113 | self._circle.setColor(color: AQI.color(aqi: aqi)) 114 | self._reading.textColor = nsColor(AQI.textColor(aqi: aqi)).withAlphaComponent(0.8) 115 | if aqi <= 50 { 116 | self._description.stringValue = NSLocalizedString("Good", comment: "AQI risk description") 117 | } else if aqi <= 100 { 118 | self._description.stringValue = NSLocalizedString("Moderate", comment: "AQI risk description") 119 | } else if aqi <= 150 { 120 | self._description.stringValue = NSLocalizedString("Unhealthy for Sensitive Groups", comment: "AQI risk description") 121 | } else if aqi <= 200 { 122 | self._description.stringValue = NSLocalizedString("Unhealthy", comment: "AQI risk description") 123 | } else if aqi <= 300 { 124 | self._description.stringValue = NSLocalizedString("Very Unhealthy", comment: "AQI risk description") 125 | } else { 126 | self._description.stringValue = NSLocalizedString("Hazardous", comment: "AQI risk description") 127 | } 128 | } 129 | } 130 | 131 | private lazy var _circle: ReadingCircle = { 132 | let circle = ReadingCircle() 133 | circle.translatesAutoresizingMaskIntoConstraints = false 134 | return circle 135 | }() 136 | 137 | private lazy var _reading: NSTextField = { 138 | let field = NSTextField() 139 | field.isBezeled = false 140 | field.isEditable = false 141 | field.isSelectable = false 142 | field.usesSingleLineMode = true 143 | field.maximumNumberOfLines = 1 144 | field.alignment = .center 145 | field.font = NSFont.systemFont(ofSize: 20, weight: .semibold) 146 | field.backgroundColor = .clear 147 | field.alignment = .center 148 | field.translatesAutoresizingMaskIntoConstraints = false 149 | return field 150 | }() 151 | 152 | private lazy var _description: NSTextField = { 153 | let field = NSTextField() 154 | field.isBezeled = false 155 | field.isEditable = false 156 | field.isSelectable = false 157 | field.usesSingleLineMode = true 158 | field.maximumNumberOfLines = 1 159 | field.lineBreakMode = .byTruncatingTail 160 | field.alignment = .center 161 | field.font = NSFont.systemFont(ofSize: 20, weight: .semibold) 162 | field.backgroundColor = .clear 163 | field.translatesAutoresizingMaskIntoConstraints = false 164 | return field 165 | }() 166 | 167 | init() { 168 | super.init(frame: .zero) 169 | self.addSubview(self._circle) 170 | self.addSubview(self._reading) 171 | self.addSubview(self._description) 172 | 173 | let spacing: CGFloat = 8 174 | NSLayoutConstraint.activate([ 175 | self._circle.widthAnchor.constraint(equalToConstant: self._circleSize), 176 | self._circle.heightAnchor.constraint(equalToConstant: self._circleSize), 177 | self._circle.topAnchor.constraint(equalTo: self.topAnchor), 178 | self._circle.leftAnchor.constraint(equalTo: self.leftAnchor), 179 | self._circle.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor), 180 | self._reading.leftAnchor.constraint(equalTo: self._circle.leftAnchor), 181 | self._reading.widthAnchor.constraint(equalTo: self._circle.widthAnchor), 182 | self._reading.centerYAnchor.constraint(equalTo: self._circle.centerYAnchor), 183 | self._description.leftAnchor.constraint(equalTo: self._circle.rightAnchor, constant: spacing), 184 | self._description.centerYAnchor.constraint(equalTo: self._circle.centerYAnchor), 185 | self._description.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor), 186 | self._description.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor), 187 | ]) 188 | self.translatesAutoresizingMaskIntoConstraints = false 189 | } 190 | 191 | @available(*, unavailable) 192 | required init?(coder aDecoder: NSCoder) { 193 | fatalError("init(coder:) has not been implemented") 194 | } 195 | } 196 | 197 | private class HistoricalReadingView: NSView { 198 | var aqi: UInt32 = 0 { 199 | didSet { 200 | self._reading.stringValue = String(aqi) 201 | self.layer?.backgroundColor = nsColor(AQI.color(aqi: aqi)).cgColor 202 | self._reading.textColor = nsColor(AQI.textColor(aqi: aqi)).withAlphaComponent(0.8) 203 | self._descriptor.textColor = self._reading.textColor 204 | } 205 | } 206 | 207 | private lazy var _descriptor: NSTextField = { 208 | let field = NSTextField() 209 | field.isBezeled = false 210 | field.isEditable = false 211 | field.isSelectable = false 212 | field.usesSingleLineMode = true 213 | field.maximumNumberOfLines = 1 214 | field.alignment = .center 215 | field.font = NSFont.systemFont(ofSize: 12) 216 | field.backgroundColor = .clear 217 | field.translatesAutoresizingMaskIntoConstraints = false 218 | field.alignment = .center 219 | return field 220 | }() 221 | 222 | private lazy var _reading: NSTextField = { 223 | let field = NSTextField() 224 | field.isBezeled = false 225 | field.isEditable = false 226 | field.isSelectable = false 227 | field.usesSingleLineMode = true 228 | field.maximumNumberOfLines = 1 229 | field.alignment = .center 230 | field.font = NSFont.systemFont(ofSize: 20, weight: .semibold) 231 | field.backgroundColor = .clear 232 | field.translatesAutoresizingMaskIntoConstraints = false 233 | field.alignment = .center 234 | return field 235 | }() 236 | 237 | init(timeDescriptor: String) { 238 | super.init(frame: .zero) 239 | self._descriptor.stringValue = timeDescriptor 240 | self.addSubview(self._reading) 241 | self.addSubview(self._descriptor) 242 | self.wantsLayer = true 243 | 244 | let verticalPadding: CGFloat = 8 245 | NSLayoutConstraint.activate([ 246 | self.widthAnchor.constraint(equalToConstant: 44), 247 | self._reading.topAnchor.constraint(equalTo: self.topAnchor, constant: verticalPadding), 248 | self._reading.leftAnchor.constraint(equalTo: self.leftAnchor), 249 | self._reading.rightAnchor.constraint(equalTo: self.rightAnchor), 250 | self._descriptor.topAnchor.constraint(equalTo: self._reading.bottomAnchor, constant: 2), 251 | self._descriptor.leftAnchor.constraint(equalTo: self.leftAnchor), 252 | self._descriptor.rightAnchor.constraint(equalTo: self.rightAnchor), 253 | self._descriptor.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -verticalPadding), 254 | ]) 255 | self.translatesAutoresizingMaskIntoConstraints = false 256 | } 257 | 258 | @available(*, unavailable) 259 | required init?(coder aDecoder: NSCoder) { 260 | fatalError("init(coder:) has not been implemented") 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /client/macos/Application/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSAppTransportSecurity 26 | 27 | NSAllowsLocalNetworking 28 | 29 | 30 | NSPrincipalClass 31 | NSApplication 32 | 33 | 34 | -------------------------------------------------------------------------------- /client/macos/Application/Map.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import AQI 4 | import Cocoa 5 | import GameKit 6 | import MapKit 7 | import os.log 8 | 9 | protocol MapControllerDelegate: AnyObject { 10 | func mapControllerDownloadStarted(_ mapController: MapController, interactive: Bool) 11 | func mapControllerDownloading(_ mapController: MapController, interactive: Bool, progess: Float) 12 | func mapControllerDownloadCompleted(_ mapController: MapController, interactive: Bool, withError error: Error?) 13 | } 14 | 15 | class MapController: NSViewController, MKMapViewDelegate, CLLocationManagerDelegate { 16 | weak var mapControllerDelagate: MapControllerDelegate? 17 | private var _readings = GKRTree(maxNumberOfChildren: 2) 18 | private var _redrawing = false 19 | private var _needsRedraw = false 20 | private var _downloading = false 21 | private var _downloadStartTime: CFAbsoluteTime = 0 22 | private let _maximumAnnoationsToDisplay = 750 23 | private var _locationLoadTime: CFAbsoluteTime = 0 24 | private var _firstLoad = true 25 | 26 | private lazy var _mapView: MKMapView = { 27 | let mapView = MKMapView(frame: self.view.bounds) 28 | mapView.translatesAutoresizingMaskIntoConstraints = false 29 | mapView.delegate = self 30 | mapView.register(ReadingView.self, forAnnotationViewWithReuseIdentifier: NSStringFromClass(AQI.Reading.self)) 31 | mapView.showsUserLocation = true 32 | mapView.showsZoomControls = true 33 | mapView.showsCompass = true 34 | mapView.showsScale = true 35 | mapView.showsBuildings = true 36 | mapView.isZoomEnabled = true 37 | return mapView 38 | }() 39 | 40 | private lazy var _locationManager: CLLocationManager = { 41 | let locationManager = CLLocationManager() 42 | locationManager.delegate = self 43 | locationManager.desiredAccuracy = kCLLocationAccuracyKilometer 44 | if (CLLocationManager.locationServicesEnabled()) { 45 | locationManager.requestAlwaysAuthorization() 46 | } 47 | locationManager.startUpdatingLocation() 48 | return locationManager 49 | }() 50 | 51 | override func loadView() { 52 | self.view = NSView() 53 | self.view.wantsLayer = true 54 | self.view.autoresizingMask = [.width, .height] 55 | self.view.addSubview(self._mapView) 56 | if let region = self._preferredZoomRegion() { 57 | self._mapView.region = region 58 | } else { 59 | self._locationLoadTime = CFAbsoluteTimeGetCurrent() 60 | } 61 | 62 | NSLayoutConstraint.activate([ 63 | self.view.widthAnchor.constraint(greaterThanOrEqualToConstant: 500), 64 | self.view.heightAnchor.constraint(greaterThanOrEqualToConstant: 300), 65 | self._mapView.topAnchor.constraint(equalTo: self.view.topAnchor), 66 | self._mapView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), 67 | self._mapView.leftAnchor.constraint(equalTo: self.view.leftAnchor), 68 | self._mapView.rightAnchor.constraint(equalTo: self.view.rightAnchor), 69 | ]) 70 | } 71 | 72 | override func viewDidAppear() { 73 | super.viewDidAppear() 74 | if self._firstLoad { 75 | self._firstLoad = false 76 | self.downloadSensorData(interactive: true) 77 | } else { 78 | self.refreshStaleSensorData() 79 | } 80 | } 81 | 82 | func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { 83 | if let region = self._preferredZoomRegion() { 84 | self._mapView.setRegion(region, animated: true) 85 | } 86 | } 87 | 88 | func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 89 | // If not available in loadView(), we have to handle setting it asynchronously here 90 | if self._locationLoadTime > 0 { 91 | let time = CFAbsoluteTimeGetCurrent() - self._locationLoadTime 92 | if time < 10 { 93 | if let region = self._preferredZoomRegion() { 94 | self._mapView.setRegion(region, animated: time > 0.5) 95 | } 96 | } 97 | self._locationLoadTime = 0 98 | } 99 | } 100 | 101 | func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { 102 | guard annotation is AQI.Reading else { return nil } 103 | return mapView.dequeueReusableAnnotationView(withIdentifier: NSStringFromClass(AQI.Reading.self), for: annotation) 104 | } 105 | 106 | func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { 107 | _redrawAnnotations(onComplete: nil) 108 | } 109 | 110 | /// Centers the map on the user's location. We return false eif we could not determine the user's location. 111 | func centerMapOnCurrentLocation() -> Bool { 112 | if let region = self._preferredZoomRegion() { 113 | self._mapView.setRegion(region, animated: true) 114 | return true 115 | } else { 116 | return false 117 | } 118 | } 119 | 120 | /// If sensor data is stale (older than 15 minutes), re-download it from the server. 121 | func refreshStaleSensorData() { 122 | let dataAge = CFAbsoluteTimeGetCurrent() - self._downloadStartTime 123 | if dataAge > 900 { 124 | self.downloadSensorData(interactive: false) 125 | } 126 | } 127 | 128 | /// Downloads new sensor data from the server. 129 | func downloadSensorData(interactive: Bool) { 130 | if self._downloading { 131 | return 132 | } 133 | self._downloading = true 134 | self._downloadStartTime = CFAbsoluteTimeGetCurrent() 135 | self.mapControllerDelagate?.mapControllerDownloadStarted(self, interactive: interactive) 136 | AQI.downloadReadings { (percentage) in 137 | self.mapControllerDelagate?.mapControllerDownloading(self, interactive: interactive, progess: percentage) 138 | } onResponse: { (readings, error) in 139 | let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "map") 140 | if let error = error { 141 | os_log("🔴 Failed to update readings: %{public}s", log: log, type: .info, error.localizedDescription) 142 | self.mapControllerDelagate?.mapControllerDownloadCompleted(self, interactive: interactive, withError: error) 143 | } else if let readings = readings { 144 | os_log("🟢 Updated readings from server (%.3fs)", log: log, type: .info, CFAbsoluteTimeGetCurrent() - self._downloadStartTime) 145 | self._readings = readings 146 | self._redrawAnnotations { 147 | self.mapControllerDelagate?.mapControllerDownloadCompleted(self, interactive: interactive, withError: nil) 148 | } 149 | } 150 | self._downloading = false 151 | } 152 | } 153 | 154 | private func _preferredZoomRegion() -> MKCoordinateRegion? { 155 | if let location = self._locationManager.location { 156 | return MKCoordinateRegion(center: location.coordinate, latitudinalMeters: 8046, longitudinalMeters: 8046) 157 | } else { 158 | return nil 159 | } 160 | } 161 | 162 | private func _redrawAnnotations(onComplete: (() -> Void)?) { 163 | if _redrawing { 164 | _needsRedraw = true 165 | return 166 | } 167 | AQI.calculateRenderOperation(readings: self._readings, region: self._mapView.region, currentAnnotations: self._mapView.annotations, maximumAnnotationsToDisplay: self._maximumAnnoationsToDisplay) { (addAnnotations, removeAnnotations, updatedAnnotations) in 168 | self._mapView.removeAnnotations(removeAnnotations) 169 | self._mapView.addAnnotations(addAnnotations) 170 | for (existing, updated) in updatedAnnotations { 171 | if let readingView = self._mapView.view(for: existing) as? ReadingView { 172 | if let oldReading = readingView.annotation as? Reading { 173 | oldReading.update(updated) 174 | } 175 | readingView.prepareForDisplay() 176 | } 177 | } 178 | self._redrawing = false 179 | if let onComplete = onComplete { 180 | onComplete() 181 | } 182 | if self._needsRedraw { 183 | self._needsRedraw = false 184 | self._redrawAnnotations(onComplete: nil) 185 | } 186 | } 187 | } 188 | } 189 | 190 | private class ReadingView: MKAnnotationView { 191 | private let _size: CGFloat = 28 192 | private var _detailCalloutAccessoryView: SensorDetailView? 193 | 194 | private lazy var _field: NSTextField = { 195 | let field = NSTextField() 196 | field.isBezeled = false 197 | field.isEditable = false 198 | field.isSelectable = false 199 | field.usesSingleLineMode = true 200 | field.alphaValue = 0.8 201 | field.maximumNumberOfLines = 1 202 | field.lineBreakMode = .byTruncatingTail 203 | field.alignment = .center 204 | field.font = NSFont.systemFont(ofSize: 11, weight: .regular) 205 | field.backgroundColor = .clear 206 | field.wantsLayer = true 207 | field.translatesAutoresizingMaskIntoConstraints = false 208 | return field 209 | }() 210 | 211 | private lazy var _circle: ReadingCircle = { 212 | let circle = ReadingCircle() 213 | circle.translatesAutoresizingMaskIntoConstraints = false 214 | return circle 215 | }() 216 | 217 | override init(annotation: MKAnnotation?, reuseIdentifier: String?) { 218 | super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) 219 | self.addSubview(self._circle) 220 | self.addSubview(self._field) 221 | self.canShowCallout = true 222 | self.frame = NSRect(x: 0, y: 0, width: _size, height: _size) 223 | NSLayoutConstraint.activate([ 224 | self._circle.widthAnchor.constraint(equalToConstant: _size), 225 | self._circle.heightAnchor.constraint(equalToConstant: _size), 226 | self._circle.centerXAnchor.constraint(equalTo: self.centerXAnchor), 227 | self._circle.centerYAnchor.constraint(equalTo: self.centerYAnchor), 228 | self._field.widthAnchor.constraint(equalToConstant: _size), 229 | self._field.centerXAnchor.constraint(equalTo: self.centerXAnchor), 230 | self._field.centerYAnchor.constraint(equalTo: self.centerYAnchor), 231 | ]) 232 | } 233 | 234 | @available(*, unavailable) 235 | required init?(coder aDecoder: NSCoder) { 236 | fatalError("init(coder:) has not been implemented") 237 | } 238 | 239 | override func prepareForDisplay() { 240 | super.prepareForDisplay() 241 | if let reading = self.annotation as? AQI.Reading { 242 | self._field.stringValue = reading.aqiString 243 | self._circle.setColor(color: AQI.color(aqi: reading.aqi)) 244 | self._field.textColor = nsColor(AQI.textColor(aqi: reading.aqi)) 245 | self._detailCalloutAccessoryView?.reading = reading 246 | } 247 | } 248 | 249 | override var detailCalloutAccessoryView: NSView? { 250 | get { 251 | if self._detailCalloutAccessoryView == nil { 252 | self._detailCalloutAccessoryView = SensorDetailView() 253 | self._detailCalloutAccessoryView?.reading = self.annotation as? AQI.Reading 254 | } 255 | return self._detailCalloutAccessoryView 256 | } 257 | set { 258 | } 259 | } 260 | } 261 | 262 | class ReadingCircle: NSView { 263 | private var _color: CGColor = CGColor(red: 0, green: 0, blue: 0, alpha: 1) 264 | 265 | func setColor(color: AQI.Color) { 266 | self._color = nsColor(color).cgColor 267 | self.setNeedsDisplay(self.bounds) 268 | } 269 | 270 | override func draw(_ dirtyRect: NSRect) { 271 | super.draw(dirtyRect) 272 | let context = NSGraphicsContext.current!.cgContext 273 | context.saveGState() 274 | context.setFillColor(self._color) 275 | context.fillEllipse(in: dirtyRect) 276 | context.restoreGState() 277 | } 278 | } 279 | 280 | func nsColor(_ color: AQI.Color) -> NSColor { 281 | return NSColor(deviceRed: CGFloat(color.r / 255.0), green: CGFloat(color.g / 255.0), blue: CGFloat(color.b / 255.0), alpha: 1) 282 | } 283 | -------------------------------------------------------------------------------- /client/macos/Application/main.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Bret Taylor 2 | 3 | import Cocoa 4 | 5 | autoreleasepool {() -> () in 6 | let app = NSApplication.shared 7 | let delegate = ApplicationDelegate() 8 | app.delegate = delegate 9 | app.run() 10 | } 11 | -------------------------------------------------------------------------------- /client/swift/AQI/Color.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import Foundation 4 | 5 | /// The color-coding of an air quality reading. 6 | public struct Color { 7 | public let r: Float 8 | public let g: Float 9 | public let b: Float 10 | 11 | init(r: Float, g: Float, b: Float) { 12 | self.r = r 13 | self.g = g 14 | self.b = b 15 | } 16 | } 17 | 18 | 19 | /// Returns the color representing the given AQI reading. 20 | public func color(aqi: UInt32) -> Color { 21 | let color0 = Color(r: 139, g: 222, b: 92) 22 | let color50 = Color(r: 255, g: 254, b: 115) 23 | let color100 = Color(r: 223, g: 138, b: 70) 24 | let color150 = Color(r: 213, g: 69, b: 51) 25 | let color200 = Color(r: 127, g: 38, b: 74) 26 | let color250 = Color(r: 127, g: 38, b: 74) 27 | let color300 = Color(r: 104, g: 29, b: 39) 28 | if aqi < 50 { 29 | return interpolate(low: 0, high: 50, lowColor: color0, highColor: color50, value: aqi) 30 | } else if aqi < 100 { 31 | return interpolate(low: 50, high: 100, lowColor: color50, highColor: color100, value: aqi) 32 | } else if aqi < 150 { 33 | return interpolate(low: 100, high: 150, lowColor: color100, highColor: color150, value: aqi) 34 | } else if aqi < 200 { 35 | return interpolate(low: 150, high: 200, lowColor: color150, highColor: color200, value: aqi) 36 | } else if aqi < 250 { 37 | return interpolate(low: 200, high: 250, lowColor: color200, highColor: color250, value: aqi) 38 | } else if aqi < 300 { 39 | return interpolate(low: 250, high: 300, lowColor: color250, highColor: color300, value: aqi) 40 | } else { 41 | return color300 42 | } 43 | } 44 | 45 | 46 | /// Returns the color for text on top of the color representing the given AQI. 47 | public func textColor(aqi: UInt32) -> Color { 48 | return aqi <= 100 ? Color(r: 0, g: 0, b: 0) : Color(r: 255, g: 255, b: 255) 49 | } 50 | 51 | private func interpolate(low: UInt32, high: UInt32, lowColor: Color, highColor: Color, value: UInt32) -> Color { 52 | let percentage = max(min(Float(value - low) / Float(high - low), 1.0), 0.0) 53 | return Color(r: lowColor.r + (highColor.r - lowColor.r) * percentage, g: lowColor.g + (highColor.g - lowColor.g) * percentage, b: lowColor.b + (highColor.b - lowColor.b) * percentage) 54 | } 55 | -------------------------------------------------------------------------------- /client/swift/AQI/Download.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import Foundation 4 | import GameKit 5 | 6 | private let aqiDataURL = URL(string: "https://dfddnmlutocpt.cloudfront.net/sensors.pb")! 7 | private let particulateMatterDataURL = URL(string: "https://dfddnmlutocpt.cloudfront.net/sensors.raw.pb")! 8 | private let compactDataURL = URL(string: "https://dfddnmlutocpt.cloudfront.net/sensors.compact.pb")! 9 | 10 | /// The type of air quality measurement 11 | public enum ReadingType { 12 | /// Calculated EPA AQI 13 | case epaCorrected 14 | 15 | /// Raw PurpleAir PM2.5 AQI 16 | case rawPurpleAir 17 | } 18 | 19 | /// Downloads the most recent AQI readings from the server. 20 | /// 21 | /// Readings are delivered as an `GKRTree` to enable efficient querying of which readings are visible in a map view. 22 | /// - Parameters: 23 | /// - onProgess: The callback to which we report download progress as a percentage in the range `[0.0, 1.0]` 24 | /// - onResponse: The callback to which we report the parsed download response 25 | @available(iOS 13.0, *) 26 | @available(macOS 10.12, *) 27 | public func downloadReadings(type: ReadingType, onProgess: @escaping (Float) -> Void, onResponse: @escaping (GKRTree?, Error?) -> Void) { 28 | let client = DownloadClient(onProgess: onProgess, onResponse: onResponse) 29 | let session = URLSession(configuration: URLSessionConfiguration.default, delegate: client, delegateQueue: OperationQueue.main) 30 | session.downloadTask(with: type == .epaCorrected ? aqiDataURL : particulateMatterDataURL).resume() 31 | } 32 | 33 | /// Downloads the compact AQI readings from the server, which only includes one AQI reading in addition to the latitude and longitude. 34 | /// 35 | /// Readings are delivered as an `GKRTree` to enable efficient querying of which readings are visible in a map view. 36 | /// - Parameters: 37 | /// - onResponse: The callback to which we report the parsed download response 38 | @available(iOS 13.0, *) 39 | @available(macOS 10.12, *) 40 | public func downloadCompactReadings(onResponse: @escaping (GKRTree?, Error?) -> Void) { 41 | let client = DownloadClient(onProgess: { (percentage) in 42 | }, onResponse: onResponse) 43 | let session = URLSession(configuration: URLSessionConfiguration.default, delegate: client, delegateQueue: OperationQueue.main) 44 | session.downloadTask(with: compactDataURL).resume() 45 | } 46 | 47 | @available(iOS 13.0, *) 48 | @available(macOS 10.12, *) 49 | private class DownloadClient: NSObject, URLSessionDelegate, URLSessionDownloadDelegate { 50 | let _onProgess: (Float) -> Void 51 | let _onResponse: (GKRTree?, Error?) -> Void 52 | let _downloadStartTime: CFAbsoluteTime 53 | var _downloadCompleted: Bool = false 54 | let _maximumProgress: Float = 0.9 55 | 56 | init(onProgess: @escaping (Float) -> Void, onResponse: @escaping (GKRTree?, Error?) -> Void) { 57 | self._onProgess = onProgess 58 | self._onResponse = onResponse 59 | self._downloadStartTime = CFAbsoluteTimeGetCurrent() 60 | } 61 | 62 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { 63 | if totalBytesExpectedToWrite == NSURLSessionTransferSizeUnknown { 64 | self._onProgess(0.5 * self._maximumProgress) 65 | } else { 66 | self._onProgess(Float(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * self._maximumProgress) 67 | } 68 | } 69 | 70 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 71 | guard let data = try? Data(contentsOf: location) else { 72 | return 73 | } 74 | self._downloadCompleted = true 75 | self._onProgess(self._maximumProgress) 76 | DispatchQueue.global(qos: .background).async { 77 | if let httpResponse = downloadTask.response as? HTTPURLResponse, 78 | httpResponse.statusCode == 200, 79 | let sensorsResponse = try? Sensors(serializedData: data) { 80 | let sensors = GKRTree(maxNumberOfChildren: 6000) 81 | for data in sensorsResponse.sensors { 82 | let sensor = Reading(data) 83 | let point = vector_float2(data.latitude, data.longitude) 84 | sensors.addElement(sensor, boundingRectMin: point, boundingRectMax: point, splitStrategy: .reduceOverlap) 85 | } 86 | DispatchQueue.main.async { 87 | self._onProgess(1.0) 88 | self._onResponse(sensors, nil) 89 | } 90 | } else { 91 | DispatchQueue.main.async { 92 | self._onResponse(nil, DownloadError.invalidServerResponse) 93 | } 94 | } 95 | } 96 | } 97 | 98 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 99 | if let error = error, 100 | !self._downloadCompleted { 101 | self._onResponse(nil, error) 102 | } else if !self._downloadCompleted { 103 | self._onResponse(nil, DownloadError.serverError) 104 | } 105 | } 106 | } 107 | 108 | /// Represents an error in the AQI download process. 109 | public enum DownloadError: Error { 110 | /// The server returned an HTTP error status code. 111 | case serverError 112 | 113 | /// The server returned a response we could not recognize. 114 | case invalidServerResponse 115 | } 116 | -------------------------------------------------------------------------------- /client/swift/AQI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // Copyright 2020 Bret Taylor 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "AQI", 7 | products: [ 8 | .library(name: "AQI", targets: ["AQI"]), 9 | ], 10 | dependencies: [ 11 | .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.12.0"), 12 | ], 13 | targets: [ 14 | .target(name: "AQI", dependencies: ["SwiftProtobuf"], path: "."), 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /client/swift/AQI/Reading.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import Foundation 4 | import MapKit 5 | 6 | /// An AQI reading from a PurpleAir sensor. 7 | public class Reading: NSObject, MKAnnotation, Comparable { 8 | private var _sensor: Sensor 9 | 10 | init(_ sensor: Sensor) { 11 | self._sensor = sensor 12 | } 13 | 14 | public lazy var coordinate: CLLocationCoordinate2D = { 15 | return CLLocationCoordinate2D(latitude: Double(self._sensor.latitude), longitude: Double(self._sensor.longitude)) 16 | }() 17 | 18 | public let title: String? = NSLocalizedString("PurpleAir Sensor", comment: "Title of sensor reading callout") 19 | 20 | public func update(_ updated: Reading) { 21 | self._sensor = updated._sensor 22 | } 23 | 24 | public var id: UInt32 { 25 | return self._sensor.id 26 | } 27 | 28 | public var aqiString: String { 29 | return String(self._sensor.aqi10M) 30 | } 31 | 32 | public var aqi: UInt32 { 33 | return self._sensor.aqi10M 34 | } 35 | 36 | public var aqi10M: UInt32 { 37 | return self._sensor.aqi10M 38 | } 39 | 40 | public var aqi30M: UInt32 { 41 | return self._sensor.aqi30M 42 | } 43 | 44 | public var aqi1H: UInt32 { 45 | return self._sensor.aqi1H 46 | } 47 | 48 | public var aqi6H: UInt32 { 49 | return self._sensor.aqi6H 50 | } 51 | 52 | public var aqi24H: UInt32 { 53 | return self._sensor.aqi24H 54 | } 55 | 56 | public var lastUpdated: Date { 57 | return Date(timeIntervalSince1970: Double(self._sensor.lastUpdated) / 1000.0) 58 | } 59 | 60 | public var subtitle: String? { 61 | return nil 62 | } 63 | 64 | public override var hash: Int { 65 | return Int(self.id) 66 | } 67 | 68 | public override func isEqual(_ object: Any?) -> Bool { 69 | if let rhs = object as? Reading { 70 | return self._sensor.id == rhs._sensor.id 71 | } else { 72 | return false 73 | } 74 | } 75 | 76 | public static func <(lhs: Reading, rhs: Reading) -> Bool { 77 | return lhs._sensor.id < rhs._sensor.id 78 | } 79 | 80 | public static func ==(lhs: Reading, rhs: Reading) -> Bool { 81 | return lhs._sensor.id == rhs._sensor.id 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /client/swift/AQI/Render.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | import Foundation 4 | import GameKit 5 | import MapKit 6 | 7 | /// Determines which of the readings in `readings` to render in the given `region`, taking into account the currently rendered readings in `currentAnnotations`. `render` is called with the list of readings that should be added and removed from the `MKMapView`. 8 | /// 9 | /// Because `MKMapView` cannot render thousands of annotations, if we exceed our the limit specified by `maximumAnnotationsToDisplay`, we choose a random sample to display. To avoid our sampling resulting in exceessive movement on the map, we (slightly) prefer keeping existing annotations on the map when redrawing. 10 | /// 11 | /// - Parameters: 12 | /// - readings: The R-tree of AQI readings 13 | /// - region: The current `MKMapView` region 14 | /// - currentAnnotations: The return value from `MKMapView.annotations` 15 | /// - maximumAnnotationsToDisplay: The maximum number of readings we should display on the map at one time (e.g., `750`) 16 | /// - render: The callback that renders the readings to the `MKMapView` 17 | @available(iOS 10.0, *) 18 | @available(macOS 10.12, *) 19 | public func calculateRenderOperation(readings: GKRTree, region: MKCoordinateRegion, currentAnnotations: [MKAnnotation], maximumAnnotationsToDisplay: Int, render: @escaping (_ addAnnotations: [AQI.Reading], _ removeAnnotations: [AQI.Reading], _ updatedAnnotations: [AQI.Reading: AQI.Reading]) -> Void) { 20 | DispatchQueue.global(qos: .background).async { 21 | var existingReadings = Set() 22 | for annotation in currentAnnotations { 23 | if let sensor = annotation as? AQI.Reading { 24 | existingReadings.insert(sensor) 25 | } 26 | } 27 | 28 | // TODO: This breaks around the international date line, but 🤷 29 | let minLatitude = region.center.latitude - region.span.latitudeDelta / 2 30 | let maxLatitude = region.center.latitude + region.span.latitudeDelta / 2 31 | let minLongitude = region.center.longitude - region.span.longitudeDelta / 2 32 | let maxLongitude = region.center.longitude + region.span.longitudeDelta / 2 33 | var visibleReadings = readings.elements(inBoundingRectMin: vector_float2(Float(minLatitude), Float(minLongitude)), rectMax: vector_float2(Float(maxLatitude), Float(maxLongitude))) 34 | 35 | let drawSensors: ArraySlice 36 | if visibleReadings.count > maximumAnnotationsToDisplay { 37 | // Choose a sample if we have too many sensors. Slightly prefer sensors already on the map if they are still in the target region to make panning more stable. 38 | var remainingSensors = Set() 39 | for existing in existingReadings { 40 | if existing.coordinate.latitude >= minLatitude && existing.coordinate.latitude <= maxLatitude && existing.coordinate.longitude >= minLongitude && existing.coordinate.longitude <= maxLongitude { 41 | remainingSensors.insert(existing) 42 | } 43 | } 44 | visibleReadings.shuffle() 45 | for sensor in visibleReadings[..(remainingSensors)[..() 54 | var updatedAnnotations: [AQI.Reading: AQI.Reading] = [:] 55 | for sensor in drawSensors { 56 | if existingReadings.contains(sensor) { 57 | if let existing = existingReadings.remove(sensor) { 58 | if existing.aqi != sensor.aqi { 59 | updatedAnnotations[existing] = sensor 60 | } 61 | } 62 | } else { 63 | addReadings.insert(sensor) 64 | } 65 | } 66 | let addAnnotations = Array(addReadings) 67 | let removeAnnotations = Array(existingReadings) 68 | 69 | DispatchQueue.main.async { 70 | render(addAnnotations, removeAnnotations, updatedAnnotations) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /client/swift/AQI/model.pb.swift: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. 2 | // swift-format-ignore-file 3 | // 4 | // Generated by the Swift generator plugin for the protocol buffer compiler. 5 | // Source: model.proto 6 | // 7 | // For information on using the generated types, please see the documentation: 8 | // https://github.com/apple/swift-protobuf/ 9 | 10 | // Copyright 2020 Bret Taylor 11 | 12 | import Foundation 13 | import SwiftProtobuf 14 | 15 | // If the compiler emits an error on this type, it is because this file 16 | // was generated by a version of the `protoc` Swift plug-in that is 17 | // incompatible with the version of SwiftProtobuf to which you are linking. 18 | // Please ensure that you are building against the same version of the API 19 | // that was used to generate this file. 20 | fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { 21 | struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} 22 | typealias Version = _2 23 | } 24 | 25 | struct Sensor { 26 | // SwiftProtobuf.Message conformance is added in an extension below. See the 27 | // `Message` and `Message+*Additions` files in the SwiftProtobuf library for 28 | // methods supported on all messages. 29 | 30 | var id: UInt32 = 0 31 | 32 | var latitude: Float = 0 33 | 34 | var longitude: Float = 0 35 | 36 | var aqi10M: UInt32 = 0 37 | 38 | var aqi30M: UInt32 = 0 39 | 40 | var aqi1H: UInt32 = 0 41 | 42 | var aqi6H: UInt32 = 0 43 | 44 | var aqi24H: UInt32 = 0 45 | 46 | var lastUpdated: UInt64 = 0 47 | 48 | var unknownFields = SwiftProtobuf.UnknownStorage() 49 | 50 | init() {} 51 | } 52 | 53 | struct Sensors { 54 | // SwiftProtobuf.Message conformance is added in an extension below. See the 55 | // `Message` and `Message+*Additions` files in the SwiftProtobuf library for 56 | // methods supported on all messages. 57 | 58 | var sensors: [Sensor] = [] 59 | 60 | var unknownFields = SwiftProtobuf.UnknownStorage() 61 | 62 | init() {} 63 | } 64 | 65 | // MARK: - Code below here is support for the SwiftProtobuf runtime. 66 | 67 | fileprivate let _protobuf_package = "data" 68 | 69 | extension Sensor: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { 70 | static let protoMessageName: String = _protobuf_package + ".Sensor" 71 | static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 72 | 1: .same(proto: "id"), 73 | 2: .same(proto: "latitude"), 74 | 3: .same(proto: "longitude"), 75 | 4: .standard(proto: "aqi_10m"), 76 | 5: .standard(proto: "aqi_30m"), 77 | 6: .standard(proto: "aqi_1h"), 78 | 7: .standard(proto: "aqi_6h"), 79 | 8: .standard(proto: "aqi_24h"), 80 | 9: .standard(proto: "last_updated"), 81 | ] 82 | 83 | mutating func decodeMessage(decoder: inout D) throws { 84 | while let fieldNumber = try decoder.nextFieldNumber() { 85 | // The use of inline closures is to circumvent an issue where the compiler 86 | // allocates stack space for every case branch when no optimizations are 87 | // enabled. https://github.com/apple/swift-protobuf/issues/1034 88 | switch fieldNumber { 89 | case 1: try { try decoder.decodeSingularUInt32Field(value: &self.id) }() 90 | case 2: try { try decoder.decodeSingularFloatField(value: &self.latitude) }() 91 | case 3: try { try decoder.decodeSingularFloatField(value: &self.longitude) }() 92 | case 4: try { try decoder.decodeSingularUInt32Field(value: &self.aqi10M) }() 93 | case 5: try { try decoder.decodeSingularUInt32Field(value: &self.aqi30M) }() 94 | case 6: try { try decoder.decodeSingularUInt32Field(value: &self.aqi1H) }() 95 | case 7: try { try decoder.decodeSingularUInt32Field(value: &self.aqi6H) }() 96 | case 8: try { try decoder.decodeSingularUInt32Field(value: &self.aqi24H) }() 97 | case 9: try { try decoder.decodeSingularUInt64Field(value: &self.lastUpdated) }() 98 | default: break 99 | } 100 | } 101 | } 102 | 103 | func traverse(visitor: inout V) throws { 104 | if self.id != 0 { 105 | try visitor.visitSingularUInt32Field(value: self.id, fieldNumber: 1) 106 | } 107 | if self.latitude != 0 { 108 | try visitor.visitSingularFloatField(value: self.latitude, fieldNumber: 2) 109 | } 110 | if self.longitude != 0 { 111 | try visitor.visitSingularFloatField(value: self.longitude, fieldNumber: 3) 112 | } 113 | if self.aqi10M != 0 { 114 | try visitor.visitSingularUInt32Field(value: self.aqi10M, fieldNumber: 4) 115 | } 116 | if self.aqi30M != 0 { 117 | try visitor.visitSingularUInt32Field(value: self.aqi30M, fieldNumber: 5) 118 | } 119 | if self.aqi1H != 0 { 120 | try visitor.visitSingularUInt32Field(value: self.aqi1H, fieldNumber: 6) 121 | } 122 | if self.aqi6H != 0 { 123 | try visitor.visitSingularUInt32Field(value: self.aqi6H, fieldNumber: 7) 124 | } 125 | if self.aqi24H != 0 { 126 | try visitor.visitSingularUInt32Field(value: self.aqi24H, fieldNumber: 8) 127 | } 128 | if self.lastUpdated != 0 { 129 | try visitor.visitSingularUInt64Field(value: self.lastUpdated, fieldNumber: 9) 130 | } 131 | try unknownFields.traverse(visitor: &visitor) 132 | } 133 | 134 | static func ==(lhs: Sensor, rhs: Sensor) -> Bool { 135 | if lhs.id != rhs.id {return false} 136 | if lhs.latitude != rhs.latitude {return false} 137 | if lhs.longitude != rhs.longitude {return false} 138 | if lhs.aqi10M != rhs.aqi10M {return false} 139 | if lhs.aqi30M != rhs.aqi30M {return false} 140 | if lhs.aqi1H != rhs.aqi1H {return false} 141 | if lhs.aqi6H != rhs.aqi6H {return false} 142 | if lhs.aqi24H != rhs.aqi24H {return false} 143 | if lhs.lastUpdated != rhs.lastUpdated {return false} 144 | if lhs.unknownFields != rhs.unknownFields {return false} 145 | return true 146 | } 147 | } 148 | 149 | extension Sensors: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { 150 | static let protoMessageName: String = _protobuf_package + ".Sensors" 151 | static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 152 | 1: .same(proto: "sensors"), 153 | ] 154 | 155 | mutating func decodeMessage(decoder: inout D) throws { 156 | while let fieldNumber = try decoder.nextFieldNumber() { 157 | // The use of inline closures is to circumvent an issue where the compiler 158 | // allocates stack space for every case branch when no optimizations are 159 | // enabled. https://github.com/apple/swift-protobuf/issues/1034 160 | switch fieldNumber { 161 | case 1: try { try decoder.decodeRepeatedMessageField(value: &self.sensors) }() 162 | default: break 163 | } 164 | } 165 | } 166 | 167 | func traverse(visitor: inout V) throws { 168 | if !self.sensors.isEmpty { 169 | try visitor.visitRepeatedMessageField(value: self.sensors, fieldNumber: 1) 170 | } 171 | try unknownFields.traverse(visitor: &visitor) 172 | } 173 | 174 | static func ==(lhs: Sensors, rhs: Sensors) -> Bool { 175 | if lhs.sensors != rhs.sensors {return false} 176 | if lhs.unknownFields != rhs.unknownFields {return false} 177 | return true 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /model/model.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Bret Taylor 2 | 3 | syntax = "proto3"; 4 | option swift_prefix = ""; 5 | 6 | package data; 7 | 8 | message Sensor { 9 | uint32 id = 1; 10 | float latitude = 2; 11 | float longitude = 3; 12 | uint32 aqi_10m = 4; 13 | uint32 aqi_30m = 5; 14 | uint32 aqi_1h = 6; 15 | uint32 aqi_6h = 7; 16 | uint32 aqi_24h = 8; 17 | uint64 last_updated = 9; 18 | } 19 | 20 | message Sensors { 21 | repeated Sensor sensors = 1; 22 | } 23 | -------------------------------------------------------------------------------- /privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | No personally identifiable information is retained by the Air Quality 4 | application. 5 | 6 | Periodically, the client applications download new air quality sensor data. 7 | In the process of executing this request, technical Log Data may be saved by the 8 | server. Log Data may include information such as your computer's 9 | Internet Protocol (“IP”) address, the time of the request, and other statistics. 10 | -------------------------------------------------------------------------------- /server/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | 4 | Globals: 5 | Function: 6 | Timeout: 30 7 | 8 | Resources: 9 | # Stores the sensor data used by clients, delivered by CloudFront 10 | S3Bucket: 11 | Type: AWS::S3::Bucket 12 | Properties: 13 | BucketName: !Ref S3BucketName 14 | 15 | # Restrict access to CloudFront 16 | S3BucketPolicy: 17 | Type: AWS::S3::BucketPolicy 18 | Properties: 19 | Bucket: !Ref S3Bucket 20 | PolicyDocument: 21 | Statement: 22 | - 23 | Effect: Allow 24 | Action: 's3:GetObject' 25 | Resource: 26 | - !Sub "arn:aws:s3:::${S3BucketName}/*" 27 | Principal: 28 | AWS: !Sub "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}" 29 | 30 | # Serves content from the S3 bucket 31 | CloudFrontDistribution: 32 | Type: AWS::CloudFront::Distribution 33 | Properties: 34 | DistributionConfig: 35 | Enabled: true 36 | HttpVersion: http2 37 | Origins: 38 | - Id: s3-air 39 | DomainName: !Sub "${S3BucketName}.s3.amazonaws.com" 40 | S3OriginConfig: 41 | OriginAccessIdentity: 42 | Fn::Sub: "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}" 43 | DefaultCacheBehavior: 44 | Compress: true 45 | AllowedMethods: 46 | - GET 47 | - HEAD 48 | - OPTIONS 49 | ForwardedValues: 50 | QueryString: false 51 | TargetOriginId: s3-air 52 | ViewerProtocolPolicy : redirect-to-https 53 | 54 | CloudFrontOriginAccessIdentity: 55 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 56 | Properties: 57 | CloudFrontOriginAccessIdentityConfig: 58 | Comment: "CloudFront access to S3" 59 | 60 | # Downloads PurpleAir data, converts to Protocol Buffers, and uploads to S3 61 | UpdateDataFunction: 62 | Type: AWS::Serverless::Function 63 | Properties: 64 | CodeUri: update_data/ 65 | Handler: app.lambda_handler 66 | Runtime: python3.9 67 | Environment: 68 | Variables: 69 | PURPLEAIR_API_KEY: !Ref PurpleAirAPIKey 70 | AWS_S3_BUCKET: !Ref S3BucketName 71 | AWS_S3_OBJECT: !Ref S3Key 72 | AWS_S3_OBJECT_RAW: !Ref S3KeyRaw 73 | AWS_S3_OBJECT_COMPACT: !Ref S3KeyCompact 74 | Policies: 75 | - Statement: 76 | - Sid: UpdateDataPolicy 77 | Effect: Allow 78 | Action: 79 | - s3:PutObject 80 | - s3:PutObjectAcl 81 | Resource: !Sub "arn:aws:s3:::${S3BucketName}/*" 82 | Events: 83 | UpdateDataEvent: 84 | Type: Schedule 85 | Properties: 86 | Name: UpdateDataEvent 87 | Schedule: rate(5 minutes) 88 | Enabled: True 89 | 90 | Parameters: 91 | PurpleAirAPIKey: 92 | Type: String 93 | S3BucketName: 94 | Type: String 95 | S3Key: 96 | Type: String 97 | Default: sensors.pb 98 | S3KeyRaw: 99 | Type: String 100 | Default: sensors.raw.pb 101 | S3KeyCompact: 102 | Type: String 103 | Default: sensors.compact.pb 104 | -------------------------------------------------------------------------------- /server/tests/test_correction.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 2 | # 3 | # pytest file 4 | # Best invoked from the server/ directory as pytest tests/ 5 | 6 | import sys 7 | import pytest 8 | from pathlib import Path 9 | 10 | sys.path.append(str(Path(__file__).parents[1] / "update_data/")) 11 | from purpleair import _apply_epa_correction, aqi_from_pm 12 | 13 | def test_zero_pm25(): 14 | corrected = _apply_epa_correction(0, 80) 15 | assert corrected == 0 16 | raw_aqi = aqi_from_pm(corrected) 17 | assert raw_aqi == 0 18 | 19 | def test_basic_correction(): 20 | input = 16.76 21 | # no correction applied, since no humidity reading given 22 | raw_aqi = aqi_from_pm(input, rh=None) 23 | assert raw_aqi == 61 24 | output = _apply_epa_correction(input, rh=14) 25 | assert output == pytest.approx(13.37224) 26 | corrected_aqi = aqi_from_pm(output) 27 | assert corrected_aqi == 54 28 | # finally, test that passing in rh to the overall aqi_from_pm leads to same result 29 | corrected_from_fn = aqi_from_pm(input, rh=14) 30 | assert corrected_aqi == corrected_from_fn 31 | 32 | def test_zero_humidity(): 33 | input = 16.76 34 | output = _apply_epa_correction(input, 0) 35 | corrected_aqi = aqi_from_pm(output) 36 | assert corrected_aqi == 56 37 | -------------------------------------------------------------------------------- /server/tests/test_parse.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 2 | # 3 | # pytest file 4 | # Best invoked from the server/ directory as pytest tests/ 5 | 6 | import sys 7 | import pytest 8 | from pathlib import Path 9 | 10 | sys.path.append(str(Path(__file__).parents[1] / "update_data/")) 11 | import purpleair 12 | 13 | SAMPLE_DATA = """{ 14 | "api_version" : "V1.0.6-0.0.9", 15 | "time_stamp" : 1608141357, 16 | "data_time_stamp" : 1608141326, 17 | "location_type" : 1, 18 | "max_age" : 300, 19 | "fields" : [ 20 | "sensor_index", 21 | "latitude", 22 | "longitude", 23 | "last_seen", 24 | "pm2.5_10minute", 25 | "pm2.5_30minute", 26 | "pm2.5_60minute", 27 | "pm2.5_6hour", 28 | "pm2.5_24hour", 29 | "humidity" 30 | ], 31 | "data" : [ 32 | [65539,38.295,-122.4606,1608141289,2.3,2.3,2.3,1.7,1.1,31], 33 | [65543,37.9084,-122.5563,1608141295,4.5,4.7,4.8,4.4,3.1,25], 34 | [65545,37.8903,-122.1868,1608141288,4.3,4.2,4.0,2.4,1.1,31], 35 | [65557,37.7751,-122.4239,1608141279,4.5,3.9,3.2,9.4,8.6,33], 36 | [65559,37.7775,-122.4686,1608141220,0.2,0.2,0.4,0.7,0.5,33], 37 | [65561,37.7783,-122.3977,1608141224,0.1,0.1,0.2,8.6,21.1,30], 38 | [65563,38.3147,-122.3014,1608141144,8.4,9.4,9.7,8.8,7.7,30], 39 | [65569,37.7377,-122.253,1608141308,3.7,4.5,4.4,3.2,3.7,25], 40 | [65573,38.3147,-122.3015,1608141322,7.7,7.8,8.0,7.9,6.7,28], 41 | [65575,38.1623,-122.1787,1608141319,8.8,6.2,5.0,3.9,10.5,21], 42 | [65577,37.7508,-122.2386,1608141254,2.8,5.2,6.8,5.8,3.7,25], 43 | [65579,37.7775,-122.4685,1608141112,1.2,1.5,1.5,1.5,1.2,32], 44 | [65583,37.2928,-122.0053,1608141235,5.3,4.0,3.2,5.0,4.8,30], 45 | [65589,37.3546,-122.0808,1608141219,4.1,4.2,4.3,4.5,4.5,29], 46 | [65597,37.8867,-122.2952,1608141223,3.3,3.0,3.0,4.2,4.8,33], 47 | [65599,37.6903,-122.0425,1608141303,5.1,5.0,4.4,3.5,3.6,24], 48 | [65603,37.3425,-122.0763,1608141256,3.0,3.1,3.3,7.6,10.5,24], 49 | [71161,37.2573,-122.0174,1608141209,2.2,2.3,2.2,2.4,2.9,21], 50 | [71167,37.8959,-122.2961,1608141314,2.8,2.4,2.1,1.3,1.2,null], 51 | [74559,null,null,1608141265,4.1,3.7,3.3,2.8,5.7,32], 52 | [71166,37.8959,-122.2961,1608141314,2.8,2.4,null,1.3,1.2,null] 53 | ] 54 | }""" 55 | 56 | def test_parse(): 57 | purpleair.parse_api(SAMPLE_DATA) 58 | -------------------------------------------------------------------------------- /server/update_data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/finiteloop/air-quality/09fa25fb1c37b77d8ab7f0837f39b65bed932f5a/server/update_data/__init__.py -------------------------------------------------------------------------------- /server/update_data/app.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Bret Taylor 2 | 3 | """A Lambda function that runs periodically to update PurpleAir sensor data.""" 4 | 5 | import boto3 6 | import logging 7 | import os 8 | import purpleair 9 | import time 10 | import urllib.request 11 | 12 | 13 | def update_sensor_data( 14 | purpleair_api_key, s3_bucket, s3_object, raw_s3_object, compact_s3_object): 15 | """Uploads PurpleAir sensor data to the location used by our clients. 16 | 17 | We download JSON from PurpleAir and convert to our proprietary Protocol 18 | Buffer format, which is used by all clients. 19 | 20 | We upload three versions of the protocol buffer data: 21 | (1) The corrected EPA AQI readings 22 | (2) Raw PurpleAir PM2.5 AQI readings 23 | (3) A compact data, with just a single AQI reading, used by the widget 24 | """ 25 | start_time = time.time() 26 | raw = urllib.request.urlopen(purpleair.api_url(purpleair_api_key)).read() 27 | download_time = time.time() 28 | data = purpleair.parse_api(raw, epa_correction=True) 29 | raw_data = purpleair.parse_api(raw, epa_correction=False) 30 | compact_data = purpleair.compact_sensor_data(data) 31 | parse_time = time.time() 32 | s3 = boto3.client("s3") 33 | update(s3, s3_bucket, data=data, object_name=s3_object) 34 | update(s3, s3_bucket, data=compact_data, object_name=compact_s3_object) 35 | update(s3, s3_bucket, data=raw_data, object_name=raw_s3_object) 36 | s3_time = time.time() 37 | total_time = time.time() - start_time 38 | logging.info("Processed PurpleAir in %.1fs (download: %.1fs, parse: %.1fs, " 39 | "s3: %.1fs)", total_time, download_time - start_time, 40 | parse_time - download_time, s3_time - parse_time) 41 | 42 | 43 | def update(s3, bucket, data, object_name): 44 | s3.put_object( 45 | Bucket=bucket, 46 | Key=object_name, 47 | Body=data.SerializeToString(), 48 | ACL="public-read", 49 | ContentType="application/protobuf", 50 | CacheControl="max-age=60") 51 | 52 | 53 | def lambda_handler(event, context): 54 | update_sensor_data( 55 | purpleair_api_key=os.environ["PURPLEAIR_API_KEY"], 56 | s3_bucket=os.environ["AWS_S3_BUCKET"], 57 | s3_object=os.environ["AWS_S3_OBJECT"], 58 | raw_s3_object=os.environ["AWS_S3_OBJECT_RAW"], 59 | compact_s3_object=os.environ["AWS_S3_OBJECT_COMPACT"]) 60 | -------------------------------------------------------------------------------- /server/update_data/model_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: model.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import message as _message 7 | from google.protobuf import reflection as _reflection 8 | from google.protobuf import symbol_database as _symbol_database 9 | # @@protoc_insertion_point(imports) 10 | 11 | _sym_db = _symbol_database.Default() 12 | 13 | 14 | 15 | 16 | DESCRIPTOR = _descriptor.FileDescriptor( 17 | name='model.proto', 18 | package='data', 19 | syntax='proto3', 20 | serialized_options=b'\272\002\000', 21 | create_key=_descriptor._internal_create_key, 22 | serialized_pb=b'\n\x0bmodel.proto\x12\x04\x64\x61ta\"\xa2\x01\n\x06Sensor\x12\n\n\x02id\x18\x01 \x01(\r\x12\x10\n\x08latitude\x18\x02 \x01(\x02\x12\x11\n\tlongitude\x18\x03 \x01(\x02\x12\x0f\n\x07\x61qi_10m\x18\x04 \x01(\r\x12\x0f\n\x07\x61qi_30m\x18\x05 \x01(\r\x12\x0e\n\x06\x61qi_1h\x18\x06 \x01(\r\x12\x0e\n\x06\x61qi_6h\x18\x07 \x01(\r\x12\x0f\n\x07\x61qi_24h\x18\x08 \x01(\r\x12\x14\n\x0clast_updated\x18\t \x01(\x04\"(\n\x07Sensors\x12\x1d\n\x07sensors\x18\x01 \x03(\x0b\x32\x0c.data.SensorB\x03\xba\x02\x00\x62\x06proto3' 23 | ) 24 | 25 | 26 | 27 | 28 | _SENSOR = _descriptor.Descriptor( 29 | name='Sensor', 30 | full_name='data.Sensor', 31 | filename=None, 32 | file=DESCRIPTOR, 33 | containing_type=None, 34 | create_key=_descriptor._internal_create_key, 35 | fields=[ 36 | _descriptor.FieldDescriptor( 37 | name='id', full_name='data.Sensor.id', index=0, 38 | number=1, type=13, cpp_type=3, label=1, 39 | has_default_value=False, default_value=0, 40 | message_type=None, enum_type=None, containing_type=None, 41 | is_extension=False, extension_scope=None, 42 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 43 | _descriptor.FieldDescriptor( 44 | name='latitude', full_name='data.Sensor.latitude', index=1, 45 | number=2, type=2, cpp_type=6, label=1, 46 | has_default_value=False, default_value=float(0), 47 | message_type=None, enum_type=None, containing_type=None, 48 | is_extension=False, extension_scope=None, 49 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 50 | _descriptor.FieldDescriptor( 51 | name='longitude', full_name='data.Sensor.longitude', index=2, 52 | number=3, type=2, cpp_type=6, label=1, 53 | has_default_value=False, default_value=float(0), 54 | message_type=None, enum_type=None, containing_type=None, 55 | is_extension=False, extension_scope=None, 56 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 57 | _descriptor.FieldDescriptor( 58 | name='aqi_10m', full_name='data.Sensor.aqi_10m', index=3, 59 | number=4, type=13, cpp_type=3, label=1, 60 | has_default_value=False, default_value=0, 61 | message_type=None, enum_type=None, containing_type=None, 62 | is_extension=False, extension_scope=None, 63 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 64 | _descriptor.FieldDescriptor( 65 | name='aqi_30m', full_name='data.Sensor.aqi_30m', index=4, 66 | number=5, type=13, cpp_type=3, label=1, 67 | has_default_value=False, default_value=0, 68 | message_type=None, enum_type=None, containing_type=None, 69 | is_extension=False, extension_scope=None, 70 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 71 | _descriptor.FieldDescriptor( 72 | name='aqi_1h', full_name='data.Sensor.aqi_1h', index=5, 73 | number=6, type=13, cpp_type=3, label=1, 74 | has_default_value=False, default_value=0, 75 | message_type=None, enum_type=None, containing_type=None, 76 | is_extension=False, extension_scope=None, 77 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 78 | _descriptor.FieldDescriptor( 79 | name='aqi_6h', full_name='data.Sensor.aqi_6h', index=6, 80 | number=7, type=13, cpp_type=3, label=1, 81 | has_default_value=False, default_value=0, 82 | message_type=None, enum_type=None, containing_type=None, 83 | is_extension=False, extension_scope=None, 84 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 85 | _descriptor.FieldDescriptor( 86 | name='aqi_24h', full_name='data.Sensor.aqi_24h', index=7, 87 | number=8, type=13, cpp_type=3, label=1, 88 | has_default_value=False, default_value=0, 89 | message_type=None, enum_type=None, containing_type=None, 90 | is_extension=False, extension_scope=None, 91 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 92 | _descriptor.FieldDescriptor( 93 | name='last_updated', full_name='data.Sensor.last_updated', index=8, 94 | number=9, type=4, cpp_type=4, label=1, 95 | has_default_value=False, default_value=0, 96 | message_type=None, enum_type=None, containing_type=None, 97 | is_extension=False, extension_scope=None, 98 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 99 | ], 100 | extensions=[ 101 | ], 102 | nested_types=[], 103 | enum_types=[ 104 | ], 105 | serialized_options=None, 106 | is_extendable=False, 107 | syntax='proto3', 108 | extension_ranges=[], 109 | oneofs=[ 110 | ], 111 | serialized_start=22, 112 | serialized_end=184, 113 | ) 114 | 115 | 116 | _SENSORS = _descriptor.Descriptor( 117 | name='Sensors', 118 | full_name='data.Sensors', 119 | filename=None, 120 | file=DESCRIPTOR, 121 | containing_type=None, 122 | create_key=_descriptor._internal_create_key, 123 | fields=[ 124 | _descriptor.FieldDescriptor( 125 | name='sensors', full_name='data.Sensors.sensors', index=0, 126 | number=1, type=11, cpp_type=10, label=3, 127 | has_default_value=False, default_value=[], 128 | message_type=None, enum_type=None, containing_type=None, 129 | is_extension=False, extension_scope=None, 130 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 131 | ], 132 | extensions=[ 133 | ], 134 | nested_types=[], 135 | enum_types=[ 136 | ], 137 | serialized_options=None, 138 | is_extendable=False, 139 | syntax='proto3', 140 | extension_ranges=[], 141 | oneofs=[ 142 | ], 143 | serialized_start=186, 144 | serialized_end=226, 145 | ) 146 | 147 | _SENSORS.fields_by_name['sensors'].message_type = _SENSOR 148 | DESCRIPTOR.message_types_by_name['Sensor'] = _SENSOR 149 | DESCRIPTOR.message_types_by_name['Sensors'] = _SENSORS 150 | _sym_db.RegisterFileDescriptor(DESCRIPTOR) 151 | 152 | Sensor = _reflection.GeneratedProtocolMessageType('Sensor', (_message.Message,), { 153 | 'DESCRIPTOR' : _SENSOR, 154 | '__module__' : 'model_pb2' 155 | # @@protoc_insertion_point(class_scope:data.Sensor) 156 | }) 157 | _sym_db.RegisterMessage(Sensor) 158 | 159 | Sensors = _reflection.GeneratedProtocolMessageType('Sensors', (_message.Message,), { 160 | 'DESCRIPTOR' : _SENSORS, 161 | '__module__' : 'model_pb2' 162 | # @@protoc_insertion_point(class_scope:data.Sensors) 163 | }) 164 | _sym_db.RegisterMessage(Sensors) 165 | 166 | 167 | DESCRIPTOR._options = None 168 | # @@protoc_insertion_point(module_scope) 169 | -------------------------------------------------------------------------------- /server/update_data/purpleair.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Bret Taylor 2 | 3 | """Converts between PurpleAir JSON data and our proprietary protocol buffers.""" 4 | 5 | import json 6 | import model_pb2 7 | import urllib.parse 8 | 9 | JSON_URL = "https://www.purpleair.com/json" 10 | API_URL = "https://api.purpleair.com/v1/sensors" 11 | 12 | _API_FIELDS = [ 13 | "sensor_index", 14 | "latitude", 15 | "longitude", 16 | "humidity", 17 | "pm2.5_10minute", 18 | "pm2.5_30minute", 19 | "pm2.5_60minute", 20 | "pm2.5_6hour", 21 | "pm2.5_24hour", 22 | "last_seen", 23 | ] 24 | 25 | 26 | def api_url(api_key): 27 | """Returns the download URL for the PurpleAir API with the given API key.""" 28 | return API_URL + "?" + urllib.parse.urlencode({ 29 | "api_key": api_key, 30 | "max_age": 300, # Filter out sensors that have stopped updating 31 | "location_type": 0, # Outside sensors only 32 | "fields": ",".join(_API_FIELDS) 33 | }) 34 | 35 | 36 | def parse_api(data, epa_correction=True): 37 | """Parses the response from the PurpleAIR API (https://api.purpleair.com). 38 | 39 | We return a Sensors protobuf suitable for the Air Quality app clients. 40 | 41 | If epa_correction is True, we correct the readings to more closely match 42 | EPA AQI standards. 43 | """ 44 | response = json.loads(data) 45 | field_indexes = {n: response["fields"].index(n) for n in _API_FIELDS} 46 | pm_fields = [ 47 | "pm2.5_10minute", 48 | "pm2.5_30minute", 49 | "pm2.5_60minute", 50 | "pm2.5_6hour", 51 | "pm2.5_24hour", 52 | ] 53 | sensors = [] 54 | for item in response["data"]: 55 | humidity = item[field_indexes["humidity"]] 56 | latitude = item[field_indexes["latitude"]] 57 | longitude = item[field_indexes["longitude"]] 58 | if not latitude or not longitude: 59 | continue 60 | has_pm_data = True 61 | for pm_field in pm_fields: 62 | if not item[field_indexes[pm_field]]: 63 | has_pm_data = False 64 | break 65 | if not has_pm_data: 66 | continue 67 | sensors.append(model_pb2.Sensor( 68 | id=item[field_indexes["sensor_index"]], 69 | latitude=latitude, 70 | longitude=longitude, 71 | aqi_10m=aqi_from_pm(item[field_indexes["pm2.5_10minute"]], humidity, epa_correction), 72 | aqi_30m=aqi_from_pm(item[field_indexes["pm2.5_30minute"]], humidity, epa_correction), 73 | aqi_1h=aqi_from_pm(item[field_indexes["pm2.5_60minute"]], humidity, epa_correction), 74 | aqi_6h=aqi_from_pm(item[field_indexes["pm2.5_6hour"]], humidity, epa_correction), 75 | aqi_24h=aqi_from_pm(item[field_indexes["pm2.5_24hour"]], humidity, epa_correction), 76 | last_updated=item[field_indexes["last_seen"]] * 1000)) 77 | return model_pb2.Sensors(sensors=sensors) 78 | 79 | 80 | def compact_sensor_data(sensors): 81 | """Returns a new set of Sensors with minimal sensor data (lat, lng, aqi). 82 | 83 | We use this reduced payload for the Widget, which does not need details like 84 | the sensor ID or 24-hour AQI average. 85 | """ 86 | compact = [] 87 | for sensor in sensors.sensors: 88 | compact.append(model_pb2.Sensor( 89 | id=sensor.id, 90 | latitude=sensor.latitude, 91 | longitude=sensor.longitude, 92 | aqi_10m=sensor.aqi_10m)) 93 | return model_pb2.Sensors(sensors=compact) 94 | 95 | 96 | def aqi_from_pm(pm, rh=None, epa_correction=True): 97 | """Converts from PM2.5 to a standard AQI score. 98 | 99 | PM2.5 represents particulate matter <2.5 microns. We use the US standard 100 | for AQI. 101 | 102 | If a sensor isn't reporting humidity (rh), we can't apply the EPA correction; 103 | in that case, we use the uncorrected pm2.5. 104 | 105 | See https://cfpub.epa.gov/si/si_public_record_report.cfm?Lab=CEMM&dirEntryId=348236 106 | for details on the EPA correction formula. 107 | """ 108 | if rh is not None and epa_correction: 109 | corrected_pm = _apply_epa_correction(pm, rh) 110 | else: 111 | corrected_pm = pm 112 | if corrected_pm > 350.5: 113 | return _aqi(corrected_pm, 500, 401, 500, 350.5) 114 | elif corrected_pm > 250.5: 115 | return _aqi(corrected_pm, 400, 301, 350.4, 250.5) 116 | elif corrected_pm > 150.5: 117 | return _aqi(corrected_pm, 300, 201, 250.4, 150.5) 118 | elif corrected_pm > 55.5: 119 | return _aqi(corrected_pm, 200, 151, 150.4, 55.5) 120 | elif corrected_pm > 35.5: 121 | return _aqi(corrected_pm, 150, 101, 55.4, 35.5) 122 | elif corrected_pm > 12.1: 123 | return _aqi(corrected_pm, 100, 51, 35.4, 12.1) 124 | else: 125 | return _aqi(corrected_pm, 50, 0, 12, 0) 126 | 127 | 128 | def _apply_epa_correction(pm, rh): 129 | """Applies the EPA calibration to Purple's PM2.5 data. 130 | 131 | We floor it to 0 since the combination of very low pm2.5 concentration 132 | and very high humidity can lead to negative numbers. 133 | """ 134 | return max(0, 0.534 * pm - 0.0844 * rh + 5.604) 135 | 136 | 137 | def _aqi(pm, ih, il, bph, bpl): 138 | return round(((ih - il) / (bph - bpl)) * (pm - bpl) + il) 139 | -------------------------------------------------------------------------------- /server/update_data/requirements.txt: -------------------------------------------------------------------------------- 1 | protobuf 2 | --------------------------------------------------------------------------------