├── .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 |
--------------------------------------------------------------------------------