diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..35bfce07 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..8403b86c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE] " +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5c1c24d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +#Common +/build +/.idea +/.dart_tool +/.vscode + +#Flutter +.packages +.project +.flutter-plugins +.flutter-plugins-dependencies + +#Android +/android/local.properties +/android/.gradle +/android/.idea +/android/.settings +/android/android.iml +/android/illinois-client-android.iml +/android/app/app.iml +/android/app/android-app.iml +/android/app/.externalNativeBuild +/android/app/bin +/android/app/.idea +/android/app/.classpath +/android/app/.settings +/android/app/gradlew +/android/app/gradlew.bat +/android/app/local.properties +/android/app/gradle + +#iOS +DerivedData +xcuserdata +/ios/.symlinks +/ios/Pods +/ios/Podfile.lock +/ios/Flutter/App.framework +/ios/Flutter/Flutter.framework +/ios/Flutter/Generated.xcconfig +/ios/Flutter/flutter_assets/* +/ios/Flutter/flutter_export_environment.sh + +#Secret/Private files +/.travis.yml +/secrets.tar.enc + +/assets/configs.json.enc + +/ios/Runner/GoogleService-Info-Debug.plist +/ios/Runner/GoogleService-Info-Release.plist + +/android/keys.properties +/android/app/src/debug/google-services.json +/android/app/src/release/google-services.json +/android/app/src/profile/google-services.json + diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..033ad2af --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 7a4c33425ddd78c54aba07d86f3f9a4a0051769b + channel: stable + +project_type: app diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0429379e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased +### Added +- Latest content from the private repository. +- GitHub Issue templates. + +### Changed +- Update README and repository description. +- Clean up CHANGELOG. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..24140ca5 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# Default assignment +* @mihail-varbanov diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..7760cddd --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,34 @@ +# Rokwire Code of Conduct + +The Rokwire Code of Conduct outlines expectations of participant behaviors and responsibilities within the Rokwire open source community, as well as steps to reporting unacceptable behavior. This code is not exhaustive or complete. It distills our common understanding of a collaborative, shared environment, and our goals. We expect it to be followed in spirit as much as in the letter. + +Our code both protects the integrity of the Rokwire community and establishes boundaries to ensure an inclusive, ethical culture. We are committed to providing a welcoming and inspiring community for all. The expected behaviors described in this code of conduct are communicated, monitored, and administered by the Rokwire open source community, with escalation through the Rokwire Open Source Community Manager. + +The Rokwire open source community is: + +- **Welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, color, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. +- **Friendly and Patient.** You might not be communicating with someone in their primary spoken or programming language, and others may not have your level of understanding. +- **Considerate.** Your work is used by others, and you depend on the work of others. Your decisions affect users and colleagues, so be sure to take those consequences into account. +- **Careful and Kind in the Words We Choose.** We are a community of professionals and we conduct ourselves professionally. We do not insult or put down others. Harassment and other exclusionary behavior are not acceptable. This includes, but is not limited to: + - Violent threats or language + - Discriminatory or derogatory jokes and language + - Posting sexually explicit or violent material + - Posting, or threatening to post, people's personally identifying information ("doxing") + - Insults, especially those using discriminatory terms or slurs + - Behavior that could be perceived as sexual attention + - Advocating for or encouraging any of the above behaviors +- **Respectfully Curious about Why We Disagree.** Disagreements, both social and technical, happen all the time. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. We might all experience some frustration now and then, but we do not allow that frustration to turn into a personal attack. We resolve disagreements and differing views constructively instead of blaming each other. Our focus is learning from our mistakes and helping to resolve issues together. The strength of our community comes from its diversity, and a community cannot be productive when people feel uncomfortable or threatened. +## Reporting Code of Conduct Issues +If you experience or witness unacceptable behavior—or have any other concerns—please report it by contacting us via rokwire@illinois.edu. In your report please include: + + - Your contact information. + - Names (real, usernames or pseudonyms) of any individuals involved. If there are additional witnesses, please include them as well. + - Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly available record (e.g. a mailing list archive or a public chat log), please include a link or attachment. + - Any additional information that may be helpful. + +All reports will be reviewed by a multi-person team and will result in a response that is considered necessary and appropriate to the circumstances. Where additional perspectives are needed, the team may seek insight from others with relevant expertise or experience. The confidentiality of the person reporting the incident will be kept at all times. Involved parties are never part of the review team. Further details of specific policies regarding implementing the code of conduct may be posted separately. + +Project maintainers are expected to follow and administer the code of conduct in good faith. Those who refuse to do so may face temporary or permanent repercussions as determined by members of the project's leadership. Anyone asked to stop unacceptable behavior is expected to comply immediately. If an individual engages in unacceptable behavior, the project’s leadership may take any action they deem appropriate, up to and including a permanent ban from our community without warning. + +## Attribution & Acknowledgements +This code of conduct is based on the template established by the [TODO Group](https://todogroup.org/), the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/), and the [Twitter Open Source Code of Conduct](https://github.com/twitter/code-of-conduct/blob/a4d2e558dc730ba440b2ce95cf87f023c9f3fd3d/code-of-conduct.md). diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..7a4a3ea2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index d80dfe8a..1b402c5a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,78 @@ # Safer Illinois App -Source code repository of the "Safer Illinois" app - the official COVID-19 app of the University of Illinois. Powered by the Rokwire Platform. +The official COVID-19 app of the University of Illinois. Powered by the [Rokwire Platform](https://rokwire.org/). + +## Requirements + +### [Flutter](https://flutter.dev/docs/get-started/install) v1.17.5 + +### [Android Studio](https://developer.android.com/studio) 3.6+ + +### [xCode](https://apps.apple.com/us/app/xcode/id497799835) 11.5 + +### [CocoaPods](https://guides.cocoapods.org/using/getting-started.html) 1.9.3+ + + +## Build + + +### Clone this repo + +### Supply the following private configuration files: + +#### • /.travis.yml +[No description available] + + +#### • /secrets.tar.enc +[No description available] + +#### • /assets/configs.json.enc +1. JSON data with the following format: +``` +{ + "production": { + "config_url": "https://api.rokwire.illinois.edu/app/configs", + "api_key": "XXXXXXXX-XXXX-XXXX-XXXXXXXXXXXXXXXXX" + }, + "dev": { + "config_url": "https://api-dev.rokwire.illinois.edu/app/configs", + "api_key": "XXXXXXXX-XXXX-XXXX-XXXXXXXXXXXXXXXXX" + }, + "test": { + "config_url": "https://api-test.rokwire.illinois.edu/app/configs", + "api_key": "XXXXXXXX-XXXX-XXXX-XXXXXXXXXXXXXXXXX" + } +} +``` +2. Generate random 16-bytes AES128 key. +3. AES encrypt the JSON string, CBC mode, PKCS7 padding, using the AES. +4. Create a data blob contains the AES key at the beginning followed by the encrypted data. +5. Get a base64 encoded string of the data blob and save it as /assets/configs.json.enc. + +Alternatively, you can use AESCrypt.encode from /lib/utils/Crypt.dart to generate content of /assets/configs.json.enc. + +#### • /ios/Runner/GoogleService-Info-Debug.plist +#### • /ios/Runner/GoogleService-Info-Release.plist + +The Firebase configuration file for iOS generated from Google Firebase console. + +#### • /android/keys.properties +Contains a GoogleMaps and Android Backup API keys. +``` +googleMapsApiKey=XXXXXXXXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXXX +androidBackupApiKey=XXXXXXXXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXX +``` + +#### • /android/app/src/debug/google-services.json +#### • /android/app/src/release/google-services.json +#### • /android/app/src/profile/google-services.json +The Firebase configuration file for Android generated from Google Firebase console. + +### Build the project + +``` +$ flutter build apk +$ flutter build ios +``` +NB: You may need to update singing & capabilities content for Runner project by opening /ios/Runner.xcworkspace from xCode + diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..b37dffce --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,176 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.application' + id 'com.github.triplet.play' version '2.2.1' +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +def keysProperties = new Properties() +def keysPropertiesFile = rootProject.file('keys.properties') +if (keysPropertiesFile.exists()) { + keysPropertiesFile.withReader('UTF-8') { reader -> + keysProperties.load(reader) + } +} + +apply plugin: 'io.fabric' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +repositories{ + maven { url 'http://maven.mapsindoors.com/' } + mavenCentral() +} + +android { + compileSdkVersion 29 + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + applicationId "edu.illinois.covid" + minSdkVersion 23 + targetSdkVersion 29 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + + manifestPlaceholders = [ + mapsApiKey : "${keysProperties.getProperty('googleMapsApiKey')}", + backupApiKey: "${keysProperties.getProperty('androidBackupApiKey')}" + ] + } + + def isRunningOnTravis = System.getenv("CI") == "true" + if (isRunningOnTravis) { + // configure keystore + println 'travis-ci release build' + signingConfigs { + release { + storeFile file("../../android-releasekey.keystore") + storePassword System.getenv("androidkeystore_password") ?: "androidstore_passwd" + keyAlias System.getenv("androidkeystore_alias") ?: "androidkeystore_alias" + keyPassword System.getenv("androidkeystore_alias_password") ?: "androidkeystore_alias_password" + } + } + buildTypes { + release { + signingConfig signingConfigs.release + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a' + } + } + } + } else { + buildTypes { + release { + // Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + minifyEnabled true + shrinkResources true + + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a' + } + } + } + } +} + +play { + track = 'alpha' + serviceAccountCredentials = file("../../google-playstore-apikey.json") +} + +flutter { + source '../..' +} + +dependencies { + testImplementation 'junit:junit:4.12' + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' + + //Common dependencies + implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'com.google.android.gms:play-services-location:17.0.0' + implementation 'com.android.volley:volley:1.1.1' + //Firebase + implementation 'com.google.firebase:firebase-core:17.2.3' + implementation 'com.google.firebase:firebase-analytics:17.2.3' + implementation 'com.google.firebase:firebase-messaging:20.1.3' + //Crashlytics + implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' + //end Common + + //MapsIndoors + implementation 'com.mapspeople.mapsindoors:mapsindoorssdk:3.3.2@aar' + implementation 'com.android.support:support-v4:28.0.0' + implementation 'com.google.code.gson:gson:2.8.5' + implementation 'com.google.android.gms:play-services-maps:17.0.0' + //end MapsIndoors + + //Google Maps Utils + implementation 'com.google.maps.android:android-maps-utils:0.5' + //end Google Maps Utils + + //Zxing + implementation 'com.google.zxing:core:3.3.0' //Use zxing 3.3.0 because we have minSdk < 24 + implementation ('com.journeyapps:zxing-android-embedded:4.1.0@aar') { transitive = false } + + //BlinkID + implementation('com.microblink:blinkid:5.3.0@aar') { + transitive = true + } + + // BLESSED - BLE library used for Exposures + implementation 'com.github.weliem:blessed-android:1.19' +} + +apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..da542e10 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a3cc3016 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/at/favre/lib/crypto/HKDF.java b/android/app/src/main/java/at/favre/lib/crypto/HKDF.java new file mode 100644 index 00000000..5ddf1f50 --- /dev/null +++ b/android/app/src/main/java/at/favre/lib/crypto/HKDF.java @@ -0,0 +1,326 @@ +/* + * Copyright 2017 Patrick Favre-Bulle + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.favre.lib.crypto; + +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import java.nio.ByteBuffer; + +/** + * A standards-compliant implementation of RFC 5869 + * for HMAC-based Key Derivation Function. + *

+ * HKDF follows the "extract-then-expand" paradigm, where the KDF + * logically consists of two modules. The first stage takes the input + * keying material and "extracts" from it a fixed-length pseudorandom + * key K. The second stage "expands" the key K into several additional + * pseudorandom keys (the output of the KDF). + *

+ * HKDF was first described by Hugo Krawczyk. + *

+ * This implementation is thread safe without the need for synchronization. + *

+ * Simple Example: + *

+ *     byte[] pseudoRandomKey = HKDF.fromHmacSha256().extract(null, lowEntropyInput);
+ *     byte[] outputKeyingMaterial = HKDF.fromHmacSha256().expand(pseudoRandomKey, null, 64);
+ * 
+ * + * @see RFC 5869 + * @see Cryptographic Extraction and Key Derivation: + * The HKDF Scheme + * @see Wikipedia: HKDF + */ +@SuppressWarnings("WeakerAccess") +public final class HKDF { + /** + * Cache instances + */ + private static HKDF hkdfHmacSha256; + private static HKDF hkdfHmacSha512; + + private final HkdfMacFactory macFactory; + + private HKDF(HkdfMacFactory macFactory) { + this.macFactory = macFactory; + } + + /** + * Return a shared instance using HMAC with Sha256. + * Even though shared, this instance is thread-safe. + * + * @return HKDF instance + */ + public static HKDF fromHmacSha256() { + if (hkdfHmacSha256 == null) { + hkdfHmacSha256 = from(HkdfMacFactory.Default.hmacSha256()); + } + return hkdfHmacSha256; + } + + /** + * Return a shared instance using HMAC with Sha512. + * Even though shared, this instance is thread-safe. + * + * @return HKDF instance + */ + public static HKDF fromHmacSha512() { + if (hkdfHmacSha512 == null) { + hkdfHmacSha512 = from(HkdfMacFactory.Default.hmacSha512()); + } + return hkdfHmacSha512; + } + + /** + * Create a new HKDF instance for given macFactory. + * + * @param macFactory used for HKDF + * @return a new instance of HKDF + */ + public static HKDF from(HkdfMacFactory macFactory) { + return new HKDF(macFactory); + } + + /** + * Step 1 of RFC 5869 (Section 2.2) + *

+ * The first stage takes the input keying material and "extracts" from it a fixed-length pseudorandom + * key K. The goal of the "extract" stage is to "concentrate" and provide a more uniformly unbiased and higher entropy but smaller output. + * This is done by utilising the diffusion properties of cryptographic MACs. + *

+ * About Salts (from RFC 5869): + *

+ * HKDF is defined to operate with and without random salt. This is + * done to accommodate applications where a salt value is not available. + * We stress, however, that the use of salt adds significantly to the + * strength of HKDF, ensuring independence between different uses of the + * hash function, supporting "source-independent" extraction, and + * strengthening the analytical results that back the HKDF design. + *
+ * + * @param salt optional salt value (a non-secret random value) (can be null) + * if not provided, it is set to an array of hash length of zeros. + * @param inputKeyingMaterial data to be extracted (IKM) + * + * @return a new byte array pseudo random key (of hash length in bytes) (PRK) which can be used to expand + * @see RFC 5869 Section 2.2 + */ + public byte[] extract(byte[] salt, byte[] inputKeyingMaterial) { + return extract(macFactory.createSecretKey(salt), inputKeyingMaterial); + } + + /** + * Use this if you require {@link SecretKey} types by your security framework. See also + * {@link HkdfMacFactory#createSecretKey(byte[])}. + *

+ * See {@link #extract(byte[], byte[])} for description. + * + * @param salt optional salt value (a non-secret random value) (can be null) + * @param inputKeyingMaterial data to be extracted (IKM) + * @return a new byte array pseudo random key (of hash length in bytes) (PRK) which can be used to expand + */ + public byte[] extract(SecretKey salt, byte[] inputKeyingMaterial) { + return new Extractor(macFactory).execute(salt, inputKeyingMaterial); + } + + /** + * Step 2 of RFC 5869 (Section 2.3) + *

+ * To "expand" the generated output of an already reasonably random input such as an existing shared key into a larger + * cryptographically independent output, thereby producing multiple keys deterministically from that initial shared key, + * so that the same process may produce those same secret keys safely on multiple devices, as long as the same inputs + * are used. + *

+ * About Info (from RFC 5869): + *

+ * While the 'info' value is optional in the definition of HKDF, it is + * often of great importance in applications. Its main objective is to + * bind the derived key material to application- and context-specific + * information. For example, 'info' may contain a protocol number, + * algorithm identifiers, user identities, etc. In particular, it may + * prevent the derivation of the same keying material for different + * contexts (when the same input key material (IKM) is used in such + * different contexts). + *
+ * + * @param pseudoRandomKey a pseudo random key of at least hmac hash length in bytes (usually, the output from the extract step) + * @param info optional context and application specific information; may be null + * @param outLengthBytes length of output keying material in bytes + * @return new byte array of output keying material (OKM) + * @see RFC 5869 Section 2.3 + */ + public byte[] expand(byte[] pseudoRandomKey, byte[] info, int outLengthBytes) { + return expand(macFactory.createSecretKey(pseudoRandomKey), info, outLengthBytes); + } + + /** + * Use this if you require {@link SecretKey} types by your security framework. See also + * {@link HkdfMacFactory#createSecretKey(byte[])}. + *

+ * See {@link #expand(byte[], byte[], int)} for description. + * + * @param pseudoRandomKey a pseudo random key of at least hmac hash length in bytes (usually, the output from the extract step) + * @param info optional context and application specific information; may be null + * @param outLengthBytes length of output keying material in bytes + * @return new byte array of output keying material (OKM) + */ + public byte[] expand(SecretKey pseudoRandomKey, byte[] info, int outLengthBytes) { + return new Expander(macFactory).execute(pseudoRandomKey, info, outLengthBytes); + } + + /** + * Convenience method for extract & expand in a single method + * + * @param saltExtract optional salt value (a non-secret random value); + * @param inputKeyingMaterial data to be extracted (IKM) + * @param infoExpand optional context and application specific information; may be null + * @param outLengthByte length of output keying material in bytes + * @return new byte array of output keying material (OKM) + */ + public byte[] extractAndExpand(byte[] saltExtract, byte[] inputKeyingMaterial, byte[] infoExpand, int outLengthByte) { + return extractAndExpand(macFactory.createSecretKey(saltExtract), inputKeyingMaterial, infoExpand, outLengthByte); + } + + /** + * Convenience method for extract & expand in a single method + * + * @param saltExtract optional salt value (a non-secret random value); + * @param inputKeyingMaterial data to be extracted (IKM) + * @param infoExpand optional context and application specific information; may be null + * @param outLengthByte length of output keying material in bytes + * @return new byte array of output keying material (OKM) + */ + public byte[] extractAndExpand(SecretKey saltExtract, byte[] inputKeyingMaterial, byte[] infoExpand, int outLengthByte) { + return new Expander(macFactory).execute(macFactory.createSecretKey( + new Extractor(macFactory).execute(saltExtract, inputKeyingMaterial)), + infoExpand, outLengthByte); + } + + /** + * Get the used mac factory + * + * @return factory + */ + HkdfMacFactory getMacFactory() { + return macFactory; + } + + /* ************************************************************************** IMPL */ + + static final class Extractor { + private final HkdfMacFactory macFactory; + + Extractor(HkdfMacFactory macFactory) { + this.macFactory = macFactory; + } + + /** + * Step 1 of RFC 5869 + * + * @param salt optional salt value (a non-secret random value); + * if not provided, it is set to an array of hash length of zeros. + * @param inputKeyingMaterial data to be extracted (IKM) + * + * @return a new byte array pseudorandom key (of hash length in bytes) (PRK) which can be used to expand + */ + byte[] execute(SecretKey salt, byte[] inputKeyingMaterial) { + if (salt == null) { + salt = macFactory.createSecretKey(new byte[macFactory.getMacLengthBytes()]); + } + + if (inputKeyingMaterial == null || inputKeyingMaterial.length <= 0) { + throw new IllegalArgumentException("provided inputKeyingMaterial must be at least of size 1 and not null"); + } + + Mac mac = macFactory.createInstance(salt); + return mac.doFinal(inputKeyingMaterial); + } + } + + static final class Expander { + private final HkdfMacFactory macFactory; + + Expander(HkdfMacFactory macFactory) { + this.macFactory = macFactory; + } + + /** + * Step 2 of RFC 5869. + * + * @param pseudoRandomKey a pseudorandom key of at least hmac hash length in bytes (usually, the output from the extract step) + * @param info optional context and application specific information; may be null + * @param outLengthBytes length of output keying material in bytes (must be <= 255 * mac hash length) + * @return new byte array of output keying material (OKM) + */ + byte[] execute(SecretKey pseudoRandomKey, byte[] info, int outLengthBytes) { + + if (outLengthBytes <= 0) { + throw new IllegalArgumentException("out length bytes must be at least 1"); + } + + if (pseudoRandomKey == null) { + throw new IllegalArgumentException("provided pseudoRandomKey must not be null"); + } + + Mac hmacHasher = macFactory.createInstance(pseudoRandomKey); + + if (info == null) { + info = new byte[0]; + } + + /* + The output OKM is calculated as follows: + N = ceil(L/HashLen) + T = T(1) | T(2) | T(3) | ... | T(N) + OKM = first L bytes of T + where: + T(0) = empty string (zero length) + T(1) = HMAC-Hash(PRK, T(0) | info | 0x01) + T(2) = HMAC-Hash(PRK, T(1) | info | 0x02) + T(3) = HMAC-Hash(PRK, T(2) | info | 0x03) + ... + */ + + byte[] blockN = new byte[0]; + + int iterations = (int) Math.ceil(((double) outLengthBytes) / ((double) hmacHasher.getMacLength())); + + if (iterations > 255) { + throw new IllegalArgumentException("out length must be maximal 255 * hash-length; requested: " + outLengthBytes + " bytes"); + } + + ByteBuffer buffer = ByteBuffer.allocate(outLengthBytes); + int remainingBytes = outLengthBytes; + int stepSize; + + for (int i = 0; i < iterations; i++) { + hmacHasher.update(blockN); + hmacHasher.update(info); + hmacHasher.update((byte) (i + 1)); + + blockN = hmacHasher.doFinal(); + + stepSize = Math.min(remainingBytes, blockN.length); + + buffer.put(blockN, 0, stepSize); + remainingBytes -= stepSize; + } + + return buffer.array(); + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/at/favre/lib/crypto/HkdfMacFactory.java b/android/app/src/main/java/at/favre/lib/crypto/HkdfMacFactory.java new file mode 100644 index 00000000..29833713 --- /dev/null +++ b/android/app/src/main/java/at/favre/lib/crypto/HkdfMacFactory.java @@ -0,0 +1,154 @@ +/* + * Copyright 2017 Patrick Favre-Bulle + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package at.favre.lib.crypto; + +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; + +/** + * Factory class for creating {@link Mac} hashers + */ +public interface HkdfMacFactory { + + /** + * Creates a new instance of Hmac with given key, i.e. it must already be initialized + * with {@link Mac#init(Key)}. + * + * @param key the key used, must not be null + * @return a new mac instance + */ + Mac createInstance(SecretKey key); + + /** + * Get the length of the mac output in bytes + * + * @return the length of mac output in bytes + */ + int getMacLengthBytes(); + + /** + * Creates a secret key from a byte raw key material to be used with {@link #createInstance(SecretKey)} + * + * @param rawKeyMaterial the raw key + * @return wrapped as secret key instance or null if input is null or empty + */ + SecretKey createSecretKey(byte[] rawKeyMaterial); + + /** + * Default implementation + */ + @SuppressWarnings("WeakerAccess") + final class Default implements HkdfMacFactory { + private final String macAlgorithmName; + private final Provider provider; + + /** + * Creates a factory creating HMAC with SHA-256 + * + * @return factory + */ + public static HkdfMacFactory hmacSha256() { + return new Default("HmacSHA256", null); + } + + /** + * Creates a factory creating HMAC with SHA-512 + * + * @return factory + */ + public static HkdfMacFactory hmacSha512() { + return new Default("HmacSHA512", null); + } + + /** + * Creates a factory creating HMAC with SHA-1 + * + * @return factory + * @deprecated sha1 with HMAC should be fine, but not recommended for new protocols; see https://crypto.stackexchange.com/questions/26510/why-is-hmac-sha1-still-considered-secure + */ + @Deprecated + public static HkdfMacFactory hmacSha1() { + return new Default("HmacSHA1", null); + } + + /** + * Creates a mac factory + * + * @param macAlgorithmName as used by {@link Mac#getInstance(String)} + */ + public Default(String macAlgorithmName) { + this(macAlgorithmName, null); + } + + /** + * Creates a mac factory + * + * @param macAlgorithmName as used by {@link Mac#getInstance(String)} + * @param provider the security provider, see {@link Mac#getInstance(String, Provider)}; may be null to use default + */ + public Default(String macAlgorithmName, Provider provider) { + this.macAlgorithmName = macAlgorithmName; + this.provider = provider; + } + + @Override + public Mac createInstance(SecretKey key) { + try { + Mac mac = createMacInstance(); + mac.init(key); + return mac; + } catch (Exception e) { + throw new IllegalStateException("could not make hmac hasher in hkdf", e); + } + } + + private Mac createMacInstance() { + try { + Mac hmacInstance; + + if (provider == null) { + hmacInstance = Mac.getInstance(macAlgorithmName); + } else { + hmacInstance = Mac.getInstance(macAlgorithmName, provider); + } + + return hmacInstance; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("defined mac algorithm was not found", e); + } catch (Exception e) { + throw new IllegalStateException("could not create mac instance in hkdf", e); + } + } + + @Override + public int getMacLengthBytes() { + return createMacInstance().getMacLength(); + } + + @Override + public SecretKey createSecretKey(byte[] rawKeyMaterial) { + if (rawKeyMaterial == null || rawKeyMaterial.length <= 0) { + return null; + } + return new SecretKeySpec(rawKeyMaterial, macAlgorithmName); + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/edu/illinois/covid/App.java b/android/app/src/main/java/edu/illinois/covid/App.java new file mode 100644 index 00000000..8a281dcf --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/App.java @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Intent; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import io.flutter.app.FlutterApplication; +import io.flutter.plugin.common.PluginRegistry; +import io.flutter.plugins.GeneratedPluginRegistrant; +import io.flutter.plugins.firebasemessaging.FlutterFirebaseMessagingService; + +public class App extends FlutterApplication implements PluginRegistry.PluginRegistrantCallback { + + private static final String NOTIFICATIONS_CHANNEL_ID = "Notifications_Channel_ID"; + + @Override + public void onCreate() { + super.onCreate(); + init(); + } + + private void init() { + FlutterFirebaseMessagingService.setPluginRegistrant(this); + } + + @Override + public void registerWith(PluginRegistry pluginRegistry) { + GeneratedPluginRegistrant.registerWith(pluginRegistry); + } + + public void showNotification(String title, String contentText) { + Intent intent = new Intent(this, MainActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0); + + if (title == null) { + title = this.getString(R.string.app_name); + } + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NOTIFICATIONS_CHANNEL_ID) + .setSmallIcon(R.drawable.app_icon) + .setContentTitle(title) + .setContentText(contentText) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent); + Notification notification = builder.build(); + + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); + notificationManager.notify(4, notification); + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/AppBackupAgent.java b/android/app/src/main/java/edu/illinois/covid/AppBackupAgent.java new file mode 100644 index 00000000..78967f76 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/AppBackupAgent.java @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid; + +import android.app.backup.BackupAgentHelper; +import android.app.backup.BackupManager; +import android.app.backup.SharedPreferencesBackupHelper; +import android.content.Context; +import android.util.Log; + +public class AppBackupAgent extends BackupAgentHelper { + private static final String PREFS_BACKUP_KEY = "prefs"; + + @Override + public void onCreate() { + SharedPreferencesBackupHelper healthHelper = new SharedPreferencesBackupHelper(this, Constants.HEALTH_SHARED_PREFS_FILE_NAME); + addHelper(PREFS_BACKUP_KEY, healthHelper); + + SharedPreferencesBackupHelper exposureTeksHelper = new SharedPreferencesBackupHelper(this, Constants.EXPOSURE_TEKS_SHARED_PREFS_FILE_NAME); + addHelper(PREFS_BACKUP_KEY, exposureTeksHelper); + } + + public static void requestBackup(Context context) { + Log.i("AppBackupAgent", "requestBackup"); + BackupManager backupManager = new BackupManager(context); + backupManager.dataChanged(); + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/Constants.java b/android/app/src/main/java/edu/illinois/covid/Constants.java new file mode 100644 index 00000000..fd8d1773 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/Constants.java @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid; + +import android.os.ParcelUuid; + +import com.google.android.gms.maps.model.LatLng; + +import java.util.UUID; + +public class Constants { + + //Flutter communication methods + static final String APP_INIT_KEY = "init"; + static final String MAP_DIRECTIONS_KEY = "directions"; + static final String MAP_PICK_LOCATION_KEY = "pickLocation"; + static final String MAP_KEY = "map"; + static final String SHOW_NOTIFICATION_KEY = "showNotification"; + static final String APP_DISMISS_SAFARI_VC_KEY = "dismissSafariVC"; + static final String APP_DISMISS_LAUNCH_SCREEN_KEY = "dismissLaunchScreen"; + static final String APP_ADD_CARD_TO_WALLET_KEY = "addToWallet"; + static final String APP_MICRO_BLINK_SCAN_KEY = "microBlinkScan"; + static final String APP_ENABLED_ORIENTATIONS_KEY = "enabledOrientations"; + static final String APP_NOTIFICATIONS_AUTHORIZATION = "notifications_authorization"; + static final String APP_LOCATION_SERVICES_PERMISSION = "location_services_permission"; + static final String APP_BLUETOOTH_AUTHORIZATION = "bluetooth_authorization"; + static final String FIREBASE_INFO = "firebaseInfo"; + static final String DEVICE_ID_KEY = "deviceId"; + static final String HEALTH_RSI_PRIVATE_KEY = "healthRSAPrivateKey"; + static final String BARCODE_KEY = "barcode"; + + //Maps + public static final LatLng DEFAULT_INITIAL_CAMERA_POSITION = new LatLng(40.102116, -88.227129); //Illinois University: Center of Campus //(40.096230, -88.235899); // State Farm Center + public static final float DEFAULT_CAMERA_ZOOM = 17.0f; + static final float FIRST_THRESHOLD_MARKER_ZOOM = 16.0f; + static final float SECOND_THRESHOLD_MARKER_ZOOM = 16.89f; + static final int MARKER_TITLE_MAX_SYMBOLS_NUMBER = 15; + public static final double EXPLORE_LOCATION_THRESHOLD_DISTANCE = 200.0; //meters + static final int SELECT_LOCATION_ACTIVITY_RESULT_CODE = 2; + public static final String LOCATION_PICKER_DATA_FORMAT = "{\"location\":{\"latitude\":%f,\"longitude\":%f,\"floor\":%d,\"description\":\"%s\",\"location_id\":\"%s\",\"name\":\"%s\"}}"; + public static final float INDOORS_BUILDING_ZOOM = 17.0f; + public static final String ANALYTICS_ROUTE_LOCATION_FORMAT = "{\"latitude\":%f,\"longitude\":%f,\"floor\":%d}"; + public static final String ANALYTICS_USER_LOCATION_FORMAT = "{\"latitude\":%f,\"longitude\":%f,\"floor\":%d,\"timestamp\":%d}"; + + //Health + static final String HEALTH_SHARED_PREFS_FILE_NAME = "health_shared_prefs"; + + //Exposure + public static final String EXPOSURE_PLUGIN_METHOD_NAME_START = "start"; + public static final String EXPOSURE_PLUGIN_METHOD_NAME_STOP = "stop"; + public static final String EXPOSURE_PLUGIN_METHOD_NAME_TEKS = "TEKs"; + public static final String EXPOSURE_PLUGIN_METHOD_NAME_TEK_RPIS = "tekRPIs"; + public static final String EXPOSURE_PLUGIN_METHOD_NAME_RPI_LOG = "exposureRPILog"; + public static final String EXPOSURE_PLUGIN_METHOD_NAME_THICK = "exposureThick"; + public static final String EXPOSURE_PLUGIN_METHOD_NAME_RSSI_LOG = "exposureRSSILog"; + public static final String EXPOSURE_PLUGIN_METHOD_NAME_EXPIRE_TEK = "expireTEK"; + public static final String EXPOSURE_PLUGIN_SETTINGS_PARAM_NAME = "settings"; + public static final String EXPOSURE_PLUGIN_RPI_PARAM_NAME = "rpi"; + public static final String EXPOSURE_PLUGIN_TEK_METHOD_NAME = "tek"; + public static final String EXPOSURE_PLUGIN_TEK_PARAM_NAME = "tek"; + public static final String EXPOSURE_PLUGIN_TIMESTAMP_PARAM_NAME = "timestamp"; + public static final String EXPOSURE_PLUGIN_EXPOSURE_METHOD_NAME = "exposure"; + public static final String EXPOSURE_PLUGIN_DURATION_PARAM_NAME = "duration"; + public static final String EXPOSURE_PLUGIN_RSSI_PARAM_NAME = "rssi"; + public static final String EXPOSURE_PLUGIN_ADDRESS_PARAM_NAME = "address"; + public static final String EXPOSURE_PLUGIN_IOS_RECORD_PARAM_NAME = "isiOSRecord"; + public static final String EXPOSURE_PLUGIN_PERIPHERAL_UUID_PARAM_NAME = "peripheralUuid"; + public static final String EXPOSURE_PLUGIN_TEK_EXPIRE_PARAM_NAME = "expirestamp"; + public static final String EXPOSURE_BLE_DEVICE_FOUND = "edu.illinois.rokwire.exposure.ble.FOUND_DEVICE"; + public static final String EXPOSURE_BLE_ACTION_FOUND = "edu.illinois.rokwire.exposure.ble.scan.ACTION_FOUND"; + public static final int EXPOSURE_NO_RSSI_VALUE = 127; + public static final int EXPOSURE_MIN_RSSI_VALUE = -50; + public static final int EXPOSURE_MIN_DURATION_MILLIS = 2 * 60 * 1000; // 2 minutes + public static final UUID EXPOSURE_UUID_SERVICE = UUID.fromString("0000CD19-0000-1000-8000-00805F9B34FB"); + public static final ParcelUuid EXPOSURE_PARCEL_SERVICE_UUID = new ParcelUuid(EXPOSURE_UUID_SERVICE); + public static final UUID EXPOSURE_UUID_CHARACTERISTIC = UUID.fromString("1f5bb1de-cdf0-4424-9d43-d8cc81a7f207"); + public static final int EXPOSURE_CONTRACT_NUMBER_LENGTH = 20; + public static final String EXPOSURE_TEKS_SHARED_PREFS_FILE_NAME = "exposure_teks_shared_prefs"; + public static final String EXPOSURE_TEKS_SHARED_PREFS_KEY = "exposure_teks"; + public static final String EXPOSURE_TEK_VERSION = "tekDatabaseVersion"; + + //Gallery + public static final String GALLERY_PLUGIN_METHOD_NAME_STORE = "store"; + public static final String GALLERY_PLUGIN_PARAM_BYTES = "bytes"; + public static final String GALLERY_PLUGIN_PARAM_NAME = "name"; +} diff --git a/android/app/src/main/java/edu/illinois/covid/MainActivity.java b/android/app/src/main/java/edu/illinois/covid/MainActivity.java new file mode 100644 index 00000000..17fe7899 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/MainActivity.java @@ -0,0 +1,852 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid; + +import android.Manifest; +import android.app.Activity; +import android.app.Application; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.provider.Settings; +import android.util.Base64; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import com.google.firebase.FirebaseApp; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.journeyapps.barcodescanner.BarcodeEncoder; +import com.mapsindoors.mapssdk.MapsIndoors; +import com.microblink.MicroblinkSDK; +import com.microblink.entities.recognizers.Recognizer; +import com.microblink.entities.recognizers.RecognizerBundle; +import com.microblink.entities.recognizers.blinkid.generic.BlinkIdCombinedRecognizer; +import com.microblink.entities.recognizers.blinkid.generic.DriverLicenseDetailedInfo; +import com.microblink.entities.recognizers.blinkid.mrtd.MrzResult; +import com.microblink.entities.recognizers.blinkid.passport.PassportRecognizer; +import com.microblink.intent.IntentDataTransferMode; +import com.microblink.recognition.InvalidLicenceKeyException; +import com.microblink.results.date.Date; +import com.microblink.uisettings.ActivityRunner; +import com.microblink.uisettings.BlinkIdUISettings; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +import edu.illinois.covid.exposure.ExposurePlugin; +import edu.illinois.covid.gallery.GalleryPlugin; +import edu.illinois.covid.maps.MapActivity; +import edu.illinois.covid.maps.MapDirectionsActivity; +import edu.illinois.covid.maps.MapViewFactory; +import edu.illinois.covid.maps.MapPickLocationActivity; +import io.flutter.app.FlutterActivity; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.GeneratedPluginRegistrant; + +public class MainActivity extends FlutterActivity implements MethodChannel.MethodCallHandler { + + private static final String TAG = "MainActivity"; + + private final int REQUEST_LOCATION_PERMISSION_CODE = 1; + + private static MethodChannel METHOD_CHANNEL; + private static final String NATIVE_CHANNEL = "edu.illinois.covid/core"; + private static MainActivity instance = null; + + private ExposurePlugin exposurePlugin; + + private static MethodChannel.Result pickLocationResult; + + private HashMap keys; + + private int preferredScreenOrientation; + private Set supportedScreenOrientations; + + private RequestLocationCallback rlCallback; + + // BlinkId + private static final int BLINK_ID_REQUEST_CODE = 3; + private BlinkIdCombinedRecognizer blinkIdCombinedRecognizer; + private PassportRecognizer blinkIdPassportRecognizer; + private RecognizerBundle blinkIdRecognizerBundle; + private boolean scanning = false; + private boolean microblinkInitialized = false; + private MethodChannel.Result scanMethodChannelResult; + + // Gallery Plugin + private GalleryPlugin galleryPlugin; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + registerPlugins(); + instance = this; + initScreenOrientation(); + initMethodChannel(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + // when activity is killed by user through the app manager, stop all exposure-related services + if (exposurePlugin != null) { + exposurePlugin.handleStop(); + } + } + + public static MainActivity getInstance() { + return instance; + } + + public App getApp() { + Application application = getApplication(); + return (application instanceof App) ? (App) application : null; + } + + public static void invokeFlutterMethod(String methodName, Object arguments) { + if (METHOD_CHANNEL != null) { + getInstance().runOnUiThread(() -> METHOD_CHANNEL.invokeMethod(methodName, arguments)); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == REQUEST_LOCATION_PERMISSION_CODE) { + boolean granted; + if (grantResults.length > 1 && + grantResults[0] == PackageManager.PERMISSION_GRANTED && grantResults[1] == PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "granted"); + granted = true; + } else { + Log.d(TAG, "not granted"); + granted = false; + } + if (rlCallback != null) { + rlCallback.onResult(granted); + rlCallback = null; + } + } else if(requestCode == GalleryPlugin.STORAGE_PERMISSION_REQUEST_CODE){ + galleryPlugin.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } + + private void registerPlugins() { + GeneratedPluginRegistrant.registerWith(this); + + // MapView + Registrar registrar = registrarFor("MapPlugin"); + registrar.platformViewRegistry().registerViewFactory("mapview", new MapViewFactory(this, registrar)); + + // ExposureNotifications + Registrar exposureRegistrar = registrarFor("ExposurePlugin"); + exposurePlugin = ExposurePlugin.registerWith(exposureRegistrar); + + // GalleryPlugin + Registrar galleryRegistrar = registrarFor("GalleryPlugin"); + galleryPlugin = GalleryPlugin.registerWith(exposureRegistrar); + } + + private void initScreenOrientation() { + preferredScreenOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + supportedScreenOrientations = new HashSet<>(Collections.singletonList(preferredScreenOrientation)); + } + + private void initMethodChannel() { + METHOD_CHANNEL = new MethodChannel(getFlutterView(), NATIVE_CHANNEL); + METHOD_CHANNEL.setMethodCallHandler(this); + } + + private void initWithParams(Object keys) { + HashMap keysMap = null; + if (keys instanceof HashMap) { + keysMap = (HashMap) keys; + } + if (keysMap == null) { + return; + } + this.keys = keysMap; + + // Google Maps cannot be initialized dynamically. Its api key has to be in AndroidManifest.xml file. + // Read it from config for MapsIndoors. + String googleMapsApiKey = Utils.Map.getValueFromPath(keysMap, "google.maps.api_key", null); + + // MapsIndoors + String mapsIndoorsApiKey = Utils.Map.getValueFromPath(keysMap, "mapsindoors.api_key", null); + if (!Utils.Str.isEmpty(mapsIndoorsApiKey)) { + MapsIndoors.initialize( + getApplicationContext(), + mapsIndoorsApiKey + ); + } + if (!Utils.Str.isEmpty(googleMapsApiKey)) { + MapsIndoors.setGoogleAPIKey(googleMapsApiKey); + } + } + + private void launchMapsDirections(Object explore, Object options) { + Intent intent = new Intent(this, MapDirectionsActivity.class); + if (explore instanceof HashMap) { + HashMap singleExplore = (HashMap) explore; + intent.putExtra("explore", singleExplore); + } else if (explore instanceof ArrayList) { + ArrayList exploreList = (ArrayList) explore; + intent.putExtra("explore", exploreList); + } + HashMap optionsMap = (options instanceof HashMap) ? (HashMap) options : null; + if (optionsMap != null) { + intent.putExtra("options", optionsMap); + } + startActivity(intent); + } + + private void launchMap(Object target, Object options, Object markers) { + HashMap targetMap = (target instanceof HashMap) ? (HashMap) target : null; + HashMap optionsMap = (options instanceof HashMap) ? (HashMap) options : null; + ArrayList markersValues = (markers instanceof ArrayList) ? ( ArrayList) markers : null; + Intent intent = new Intent(this, MapActivity.class); + Bundle serializableExtras = new Bundle(); + serializableExtras.putSerializable("target", targetMap); + serializableExtras.putSerializable("options", optionsMap); + serializableExtras.putSerializable("markers", markersValues); + intent.putExtras(serializableExtras); + startActivity(intent); + } + + private void launchMapsLocationPick(Object exploreParam) { + HashMap explore = null; + if (exploreParam instanceof HashMap) { + explore = (HashMap) exploreParam; + } + Intent locationPickerIntent = new Intent(this, MapPickLocationActivity.class); + locationPickerIntent.putExtra("explore", explore); + startActivityForResult(locationPickerIntent, Constants.SELECT_LOCATION_ACTIVITY_RESULT_CODE); + } + + private void launchNotification(MethodCall methodCall) { + String title = methodCall.argument("title"); + String body = methodCall.argument("body"); + App app = getApp(); + if (app != null) { + app.showNotification(title, body); + } + } + + + private void requestLocationPermission(MethodChannel.Result result) { + //check if granted + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "request permission"); + + rlCallback = new RequestLocationCallback() { + @Override + public void onResult(boolean granted) { + if (granted) { + result.success("allowed"); + + if (exposurePlugin != null) { + exposurePlugin.onLocationPermissionGranted(); + } + } else { + result.success("denied"); + } + } + }; + + ActivityCompat.requestPermissions(MainActivity.this, + new String[]{Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION}, + REQUEST_LOCATION_PERMISSION_CODE); + } else { + Log.d(TAG, "already granted"); + result.success("allowed"); + } + } + + private List handleEnabledOrientations(Object orientations) { + List resultList = new ArrayList<>(); + if (preferredScreenOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + resultList.add(getScreenOrientationToString(preferredScreenOrientation)); + } + if (supportedScreenOrientations != null && !supportedScreenOrientations.isEmpty()) { + for (int supportedOrientation : supportedScreenOrientations) { + if (supportedOrientation != preferredScreenOrientation) { + resultList.add(getScreenOrientationToString(supportedOrientation)); + } + } + } + List orientationsList; + if (orientations instanceof List) { + orientationsList = (List) orientations; + int preferredOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + Set supportedOrientations = new HashSet<>(); + for (String orientationString : orientationsList) { + int orientation = getScreenOrientationFromString(orientationString); + if (orientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + supportedOrientations.add(orientation); + if (preferredOrientation == ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { + preferredOrientation = orientation; + } + } + } + if ((preferredOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) && (preferredScreenOrientation != preferredOrientation)) { + preferredScreenOrientation = preferredOrientation; + } + if ((supportedOrientations.size() > 0) && !supportedOrientations.equals(supportedScreenOrientations)) { + supportedScreenOrientations = supportedOrientations; + int currentOrientation = getRequestedOrientation(); + if (!supportedScreenOrientations.contains(currentOrientation)) { + setRequestedOrientation(preferredScreenOrientation); + } + } + } + return resultList; + } + + private String getScreenOrientationToString(int orientationValue) { + switch (orientationValue) { + case ActivityInfo.SCREEN_ORIENTATION_PORTRAIT: + return "portraitUp"; + case ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT: + return "portraitDown"; + case ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE: + return "landscapeLeft"; + case ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE: + return "landscapeRight"; + default: + return null; + } + } + + private String getDeviceId(){ + String deviceId = ""; + try + { + UUID uuid; + final String androidId = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID); + uuid = UUID.nameUUIDFromBytes(androidId.getBytes("utf8")); + deviceId = uuid.toString(); + } + catch (Exception e) + { + Log.d(TAG, "Failed to generate uuid"); + } + return deviceId; + } + + private int getScreenOrientationFromString(String orientationString) { + if (Utils.Str.isEmpty(orientationString)) { + return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + } + switch (orientationString) { + case "portraitUp": + return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + case "portraitDown": + return ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + case "landscapeLeft": + return ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + case "landscapeRight": + return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + default: + return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + } + } + + private String handleBarcode(Object params) { + String barcodeImageData = null; + String content = Utils.Map.getValueFromPath(params, "content", null); + String format = Utils.Map.getValueFromPath(params, "format", null); + int width = Utils.Map.getValueFromPath(params, "width", 0); + int height = Utils.Map.getValueFromPath(params, "height", 0); + BarcodeFormat barcodeFormat = null; + if (!Utils.Str.isEmpty(format)) { + switch (format) { + case "aztec": + barcodeFormat = BarcodeFormat.AZTEC; + break; + case "codabar": + barcodeFormat = BarcodeFormat.CODABAR; + break; + case "code39": + barcodeFormat = BarcodeFormat.CODE_39; + break; + case "code93": + barcodeFormat = BarcodeFormat.CODE_93; + break; + case "code128": + barcodeFormat = BarcodeFormat.CODE_128; + break; + case "dataMatrix": + barcodeFormat = BarcodeFormat.DATA_MATRIX; + break; + case "ean8": + barcodeFormat = BarcodeFormat.EAN_8; + break; + case "ean13": + barcodeFormat = BarcodeFormat.EAN_13; + break; + case "itf": + barcodeFormat = BarcodeFormat.ITF; + break; + case "maxiCode": + barcodeFormat = BarcodeFormat.MAXICODE; + break; + case "pdf417": + barcodeFormat = BarcodeFormat.PDF_417; + break; + case "qrCode": + barcodeFormat = BarcodeFormat.QR_CODE; + break; + case "rss14": + barcodeFormat = BarcodeFormat.RSS_14; + break; + case "rssExpanded": + barcodeFormat = BarcodeFormat.RSS_EXPANDED; + break; + case "upca": + barcodeFormat = BarcodeFormat.UPC_A; + break; + case "upce": + barcodeFormat = BarcodeFormat.UPC_E; + break; + case "upceanExtension": + barcodeFormat = BarcodeFormat.UPC_EAN_EXTENSION; + break; + default: + break; + } + } + + if (barcodeFormat != null) { + MultiFormatWriter multiFormatWriter = new MultiFormatWriter(); + Bitmap bitmap = null; + try { + BitMatrix bitMatrix = multiFormatWriter.encode(content, barcodeFormat, width, height); + BarcodeEncoder barcodeEncoder = new BarcodeEncoder(); + bitmap = barcodeEncoder.createBitmap(bitMatrix); + } catch (WriterException e) { + Log.e(TAG, "Failed to encode image:"); + e.printStackTrace(); + } + if (bitmap != null) { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream); + byte[] byteArray = byteArrayOutputStream.toByteArray(); + if (byteArray != null) { + barcodeImageData = Base64.encodeToString(byteArray, Base64.NO_WRAP); + } + } + } + return barcodeImageData; + } + + //region BlinkId + + private void handleMicroBlinkScan(Object params) { + if (!microblinkInitialized) { + initMicroblinkSdk(); + } + if (!microblinkInitialized) { + Log.i(TAG, "Cannot start scanning! Microblink has not been initialized!"); + if (scanMethodChannelResult != null) { + scanMethodChannelResult.success(null); + } + return; + } + if (scanning) { + Log.d(TAG, "Blink Id is currently scanning!"); + if (scanMethodChannelResult != null) { + scanMethodChannelResult.success(null); + } + } else { + scanning = true; + List recognizersList = Arrays.asList("combined", "passport"); // by default + if (params instanceof HashMap) { + HashMap paramsMap = (HashMap) params; + Object recognizersObject = paramsMap.get("recognizers"); + if (recognizersObject instanceof List) { + recognizersList = (List) recognizersObject; + } + } + List recognizers = new ArrayList<>(); + for (String recognizerParam : recognizersList) { + if ("combined".equals(recognizerParam)) { + blinkIdCombinedRecognizer = new BlinkIdCombinedRecognizer(); + blinkIdCombinedRecognizer.setEncodeFaceImage(true); + recognizers.add(blinkIdCombinedRecognizer); + } else if ("passport".equals(recognizerParam)) { + blinkIdPassportRecognizer = new PassportRecognizer(); + blinkIdPassportRecognizer.setEncodeFaceImage(true); + recognizers.add(blinkIdPassportRecognizer); + } + } + blinkIdRecognizerBundle = new RecognizerBundle(recognizers); + BlinkIdUISettings uiSettings = new BlinkIdUISettings(blinkIdRecognizerBundle); + ActivityRunner.startActivityForResult(this, BLINK_ID_REQUEST_CODE, uiSettings); + } + } + + private void initMicroblinkSdk() { + String blinkIdLicenseKey = Utils.Map.getValueFromPath(keys, "microblink.blink_id.license_key", null); + if (Utils.Str.isEmpty(blinkIdLicenseKey)) { + Log.e(TAG, "Microblink BlinkId license key is missing from config keys!"); + return; + } + try { + MicroblinkSDK.setLicenseKey(blinkIdLicenseKey, this); + MicroblinkSDK.setIntentDataTransferMode(IntentDataTransferMode.PERSISTED_OPTIMISED); + microblinkInitialized = true; + } catch (InvalidLicenceKeyException | NullPointerException e) { + Log.e(TAG, "Microblink failed to initialize:"); + e.printStackTrace(); + } + } + + private void onBlinkIdScanSuccess(Intent data) { + Log.d(TAG, "onBlinkIdScanSuccess"); + if (blinkIdRecognizerBundle != null) { + blinkIdRecognizerBundle.loadFromIntent(data); + } + if ((blinkIdCombinedRecognizer != null) && (blinkIdCombinedRecognizer.getResult().getResultState() == Recognizer.Result.State.Valid)) { + onCombinedRecognizerResult(blinkIdCombinedRecognizer.getResult()); + } else if ((blinkIdPassportRecognizer != null) && (blinkIdPassportRecognizer.getResult().getResultState() == Recognizer.Result.State.Valid)) { + onPassportRecognizerResult(blinkIdPassportRecognizer.getResult()); + } + } + + private void onBlinkIdScanCanceled() { + Log.d(TAG, "onBlinkIdScanCanceled"); + unInitBlinkId(); + if (scanMethodChannelResult != null) { + scanMethodChannelResult.success(null); + } + } + + private void onCombinedRecognizerResult(BlinkIdCombinedRecognizer.Result combinedRecognizerResult) { + Log.d(TAG, "onCombinedRecognizerResult"); + HashMap scanResult = null; + if (combinedRecognizerResult != null) { + scanResult = new HashMap<>(); + + String base64FaceImage = Base64.encodeToString(combinedRecognizerResult.getEncodedFaceImage(), Base64.NO_WRAP); + + scanResult.put("firstName", Utils.Str.nullIfEmpty(combinedRecognizerResult.getFirstName())); + scanResult.put("lastName", Utils.Str.nullIfEmpty(combinedRecognizerResult.getLastName())); + scanResult.put("fullName", Utils.Str.nullIfEmpty(combinedRecognizerResult.getFullName())); + scanResult.put("sex", Utils.Str.nullIfEmpty(combinedRecognizerResult.getSex())); + scanResult.put("address", Utils.Str.nullIfEmpty(combinedRecognizerResult.getAddress())); + + scanResult.put("dateOfBirth", Utils.Str.nullIfEmpty(formatBlinkIdDate(combinedRecognizerResult.getDateOfBirth().getDate()))); + scanResult.put("dateOfExpiry", Utils.Str.nullIfEmpty(formatBlinkIdDate(combinedRecognizerResult.getDateOfExpiry().getDate()))); + scanResult.put("dateOfIssue", Utils.Str.nullIfEmpty(formatBlinkIdDate(combinedRecognizerResult.getDateOfIssue().getDate()))); + + scanResult.put("documentNumber", Utils.Str.nullIfEmpty(combinedRecognizerResult.getDocumentNumber())); + + scanResult.put("placeOfBirth", Utils.Str.nullIfEmpty(combinedRecognizerResult.getPlaceOfBirth())); + scanResult.put("nationality", Utils.Str.nullIfEmpty(combinedRecognizerResult.getNationality())); + scanResult.put("race", Utils.Str.nullIfEmpty(combinedRecognizerResult.getRace())); + scanResult.put("religion", Utils.Str.nullIfEmpty(combinedRecognizerResult.getReligion())); + scanResult.put("profession", Utils.Str.nullIfEmpty(combinedRecognizerResult.getProfession())); + scanResult.put("maritalStatus", Utils.Str.nullIfEmpty(combinedRecognizerResult.getMaritalStatus())); + scanResult.put("residentialStatus", Utils.Str.nullIfEmpty(combinedRecognizerResult.getResidentialStatus())); + scanResult.put("employer", Utils.Str.nullIfEmpty(combinedRecognizerResult.getEmployer())); + scanResult.put("personalIdNumber", Utils.Str.nullIfEmpty(combinedRecognizerResult.getPersonalIdNumber())); + scanResult.put("documentAdditionalNumber", Utils.Str.nullIfEmpty(combinedRecognizerResult.getDocumentAdditionalNumber())); + scanResult.put("issuingAuthority", Utils.Str.nullIfEmpty(combinedRecognizerResult.getIssuingAuthority())); + + scanResult.put("mrz", getScanRezultFromMrz(combinedRecognizerResult.getMrzResult())); + scanResult.put("driverLicenseDetailedInfo", getScanResultFromDriverLicenseDetailedInfo(combinedRecognizerResult.getDriverLicenseDetailedInfo())); + + scanResult.put("base64FaceImage", Utils.Str.nullIfEmpty(base64FaceImage)); + } + unInitBlinkId(); + if (scanMethodChannelResult != null) { + scanMethodChannelResult.success(scanResult); + } + } + + private void onPassportRecognizerResult(PassportRecognizer.Result passportRecognizerResult) { + Log.d(TAG, "onPassportRecognizerResult"); + HashMap scanResult = null; + if (passportRecognizerResult != null) { + scanResult = new HashMap<>(); + + String base64FaceImage = Base64.encodeToString(passportRecognizerResult.getEncodedFaceImage(), Base64.NO_WRAP); + + scanResult.put("firstName", null); + scanResult.put("lastName", null); + scanResult.put("fullName", null); + scanResult.put("sex", null); + scanResult.put("address", null); + + scanResult.put("dateOfBirth", null); + scanResult.put("dateOfExpiry", null); + scanResult.put("dateOfIssue", null); + + scanResult.put("documentNumber", null); + + scanResult.put("placeOfBirth", null); + scanResult.put("nationality", null); + scanResult.put("race", null); + scanResult.put("religion", null); + scanResult.put("profession", null); + scanResult.put("maritalStatus", null); + scanResult.put("residentialStatus", null); + scanResult.put("employer", null); + scanResult.put("personalIdNumber", null); + scanResult.put("documentAdditionalNumber", null); + scanResult.put("issuingAuthority", null); + + scanResult.put("mrz", getScanRezultFromMrz(passportRecognizerResult.getMrzResult())); + scanResult.put("driverLicenseDetailedInfo", null); + + scanResult.put("base64FaceImage", Utils.Str.nullIfEmpty(base64FaceImage)); + } + unInitBlinkId(); + if (scanMethodChannelResult != null) { + scanMethodChannelResult.success(scanResult); + } + } + + private HashMap getScanResultFromDriverLicenseDetailedInfo(DriverLicenseDetailedInfo driverLicenseDetailedInfo) { + HashMap scanResult = null; + if (driverLicenseDetailedInfo != null) { + scanResult = new HashMap<>(); + + scanResult.put("restrictions", Utils.Str.nullIfEmpty(driverLicenseDetailedInfo.getRestrictions())); + scanResult.put("endorsements", Utils.Str.nullIfEmpty(driverLicenseDetailedInfo.getEndorsements())); + scanResult.put("vehicleClass", Utils.Str.nullIfEmpty(driverLicenseDetailedInfo.getVehicleClass())); + } + return scanResult; + } + + private HashMap getScanRezultFromMrz(MrzResult mrzRezult) { + HashMap scanResult = null; + if (mrzRezult != null) { + scanResult = new HashMap<>(); + + scanResult.put("primaryID", Utils.Str.nullIfEmpty(mrzRezult.getPrimaryId())); + scanResult.put("secondaryID", Utils.Str.nullIfEmpty(mrzRezult.getSecondaryId())); + scanResult.put("issuer", Utils.Str.nullIfEmpty(mrzRezult.getIssuer())); + scanResult.put("issuerName", Utils.Str.nullIfEmpty(mrzRezult.getIssuerName())); + scanResult.put("dateOfBirth", Utils.Str.nullIfEmpty(formatBlinkIdDate(mrzRezult.getDateOfBirth().getDate()))); + scanResult.put("dateOfExpiry", Utils.Str.nullIfEmpty(formatBlinkIdDate(mrzRezult.getDateOfExpiry().getDate()))); + scanResult.put("documentNumber", Utils.Str.nullIfEmpty(mrzRezult.getDocumentNumber())); + scanResult.put("nationality", Utils.Str.nullIfEmpty(mrzRezult.getNationality())); + scanResult.put("nationalityName", Utils.Str.nullIfEmpty(mrzRezult.getNationalityName())); + scanResult.put("gender", Utils.Str.nullIfEmpty(mrzRezult.getGender())); + scanResult.put("documentCode", Utils.Str.nullIfEmpty(mrzRezult.getDocumentCode())); + scanResult.put("alienNumber", Utils.Str.nullIfEmpty(mrzRezult.getAlienNumber())); + scanResult.put("applicationReceiptNumber", Utils.Str.nullIfEmpty(mrzRezult.getApplicationReceiptNumber())); + scanResult.put("immigrantCaseNumber", Utils.Str.nullIfEmpty(mrzRezult.getImmigrantCaseNumber())); + + scanResult.put("opt1", Utils.Str.nullIfEmpty(mrzRezult.getOpt1())); + scanResult.put("opt2", Utils.Str.nullIfEmpty(mrzRezult.getOpt2())); + scanResult.put("mrzText", Utils.Str.nullIfEmpty(mrzRezult.getMrzText())); + + scanResult.put("sanitizedOpt1", Utils.Str.nullIfEmpty(mrzRezult.getSanitizedOpt1())); + scanResult.put("sanitizedOpt2", Utils.Str.nullIfEmpty(mrzRezult.getSanitizedOpt2())); + scanResult.put("sanitizedNationality", Utils.Str.nullIfEmpty(mrzRezult.getSanitizedNationality())); + scanResult.put("sanitizedIssuer", Utils.Str.nullIfEmpty(mrzRezult.getSanitizedIssuer())); + scanResult.put("sanitizedDocumentCode", Utils.Str.nullIfEmpty(mrzRezult.getSanitizedDocumentCode())); + scanResult.put("sanitizedDocumentNumber", Utils.Str.nullIfEmpty(mrzRezult.getSanitizedDocumentNumber())); + } + return scanResult; + } + + private void unInitBlinkId() { + blinkIdRecognizerBundle = null; + blinkIdCombinedRecognizer = null; + blinkIdPassportRecognizer = null; + scanning = false; + } + + private String formatBlinkIdDate(Date date) { + if (date == null) { + return null; + } + return String.format(Locale.getDefault(), "%02d/%02d/%4d", date.getMonth(), date.getDay(), date.getYear()); + } + + //endregion + + private Object handleHealthRsiPrivateKey(Object params) { + String userId = null; + String value = null; + boolean remove = false; + if (params instanceof HashMap) { + HashMap paramsMap = (HashMap) params; + Object userIdObj = paramsMap.get("userId"); + if (userIdObj instanceof String) { + userId = (String) userIdObj; + } + Object valueObj = paramsMap.get("value"); + if (valueObj instanceof String) { + value = (String) valueObj; + } + Object removeObj = paramsMap.get("remove"); + if (removeObj instanceof Boolean) { + remove = (Boolean) removeObj; + } + } + if (Utils.Str.isEmpty(userId)) { + return null; + } + if (Utils.Str.isEmpty(value)) { + if (remove) { + Utils.BackupStorage.remove(this, Constants.HEALTH_SHARED_PREFS_FILE_NAME, userId); + return true; + } else { + return Utils.BackupStorage.getString(this, Constants.HEALTH_SHARED_PREFS_FILE_NAME, userId); + } + } else { + Utils.BackupStorage.saveString(this, Constants.HEALTH_SHARED_PREFS_FILE_NAME, userId, value); + return true; + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == Constants.SELECT_LOCATION_ACTIVITY_RESULT_CODE) { + if (resultCode == Activity.RESULT_OK) { + pickLocationResult.success(data != null ? data.getStringExtra("location") : null); + } else { + pickLocationResult.success(null); + } + } else if (requestCode == BLINK_ID_REQUEST_CODE) { + if (resultCode == Activity.RESULT_OK) { + onBlinkIdScanSuccess(data); + } else { + onBlinkIdScanCanceled(); + } + } + + super.onActivityResult(requestCode, resultCode, data); + } + + /** + * Overrides {@link io.flutter.plugin.common.MethodChannel.MethodCallHandler} onMethodCall() + */ + @Override + public void onMethodCall(MethodCall methodCall, @NonNull MethodChannel.Result result) { + String method = methodCall.method; + try { + switch (method) { + case Constants.APP_INIT_KEY: + Object keysObject = methodCall.argument("keys"); + initWithParams(keysObject); + result.success(true); + break; + case Constants.MAP_DIRECTIONS_KEY: + Object explore = methodCall.argument("explore"); + Object optionsObj = methodCall.argument("options"); + launchMapsDirections(explore, optionsObj); + result.success(true); + break; + case Constants.MAP_PICK_LOCATION_KEY: + pickLocationResult = result; + launchMapsLocationPick(methodCall.argument("explore")); + // Result is called on latter step + break; + case Constants.MAP_KEY: + Object target = methodCall.argument("target"); + Object options = methodCall.argument("options"); + Object markers = methodCall.argument("markers"); + launchMap(target, options,markers); + result.success(true); + break; + case Constants.SHOW_NOTIFICATION_KEY: + launchNotification(methodCall); + result.success(true); + break; + case Constants.APP_DISMISS_SAFARI_VC_KEY: + case Constants.APP_DISMISS_LAUNCH_SCREEN_KEY: + case Constants.APP_ADD_CARD_TO_WALLET_KEY: + result.success(false); + break; + case Constants.APP_MICRO_BLINK_SCAN_KEY: + scanMethodChannelResult = result; + handleMicroBlinkScan(methodCall.arguments); + // Result is called on latter step + break; + case Constants.APP_ENABLED_ORIENTATIONS_KEY: + Object orientations = methodCall.argument("orientations"); + List orientationsList = handleEnabledOrientations(orientations); + result.success(orientationsList); + break; + case Constants.APP_NOTIFICATIONS_AUTHORIZATION: + result.success(true); // notifications are allowed in Android by default + break; + case Constants.APP_LOCATION_SERVICES_PERMISSION: + requestLocationPermission(result); + break; + case Constants.APP_BLUETOOTH_AUTHORIZATION: + result.success("allowed"); // bluetooth is always enabled in Android by default + break; + case Constants.FIREBASE_INFO: + String projectId = FirebaseApp.getInstance().getOptions().getProjectId(); + result.success(projectId); + break; + case Constants.DEVICE_ID_KEY: + String deviceId = getDeviceId(); + result.success(deviceId); + break; + case Constants.HEALTH_RSI_PRIVATE_KEY: + Object healthRsiPrivateKeyResult = handleHealthRsiPrivateKey(methodCall.arguments); + result.success(healthRsiPrivateKeyResult); + break; + case Constants.BARCODE_KEY: + String barcodeImageData = handleBarcode(methodCall.arguments); + result.success(barcodeImageData); + break; + default: + result.notImplemented(); + break; + + } + } catch (IllegalStateException exception) { + String errorMsg = String.format("Ignoring exception '%s'. See https://github.com/flutter/flutter/issues/29092 for details.", exception.toString()); + Log.e(TAG, errorMsg); + exception.printStackTrace(); + } + } + + // RequestLocationCallback + + public static class RequestLocationCallback { + public void onResult(boolean granted) {} + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/Utils.java b/android/app/src/main/java/edu/illinois/covid/Utils.java new file mode 100644 index 00000000..bb6c3355 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/Utils.java @@ -0,0 +1,741 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid; + +import android.app.AlertDialog; +import android.bluetooth.BluetoothAdapter; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.text.format.DateUtils; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.maps.android.ui.IconGenerator; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.Formatter; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; + +import androidx.core.content.ContextCompat; +import edu.illinois.covid.maps.MapMarkerViewType; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +public class Utils { + + public static void showDialog(Context context, String title, String message, + DialogInterface.OnClickListener positiveListener, String positiveText, + DialogInterface.OnClickListener negativeListener, String negativeText, + boolean cancelable) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(title); + builder.setMessage(message); + builder.setCancelable(cancelable); + + if (positiveListener != null) + builder.setPositiveButton(positiveText, positiveListener); + + if (negativeListener != null) + builder.setNegativeButton(negativeText, negativeListener); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + + public static void enabledBluetooth() { + BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter != null) { + bluetoothAdapter.enable(); + } + } + + public static class DateTime { + + static Date getDateTime(String dateTimeString) { + if (dateTimeString == null || dateTimeString.isEmpty()) { + return null; + } + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()); + Date dateTime = null; + try { + dateTime = dateFormat.parse(dateTimeString); + } catch (ParseException e) { + Log.e("Error", "Failed to parse '" + dateTimeString + "' to date time"); + e.printStackTrace(); + } + return dateTime; + } + + static String formatEventTime(Context context, Date dateTime) { + if (dateTime == null) { + return null; + } + Calendar calendarDate = Calendar.getInstance(); + int minutes = calendarDate.get(Calendar.MINUTE); + Calendar today = Calendar.getInstance(); + calendarDate.setTime(dateTime); + boolean zeroMins = (minutes == 0); + boolean currentYear = calendarDate.get(Calendar.YEAR) == today.get(Calendar.YEAR); + final String defaultStringFormat = String.format("%sMMM dd h%s a", (currentYear ? "" : "yy, "), (zeroMins ? "" : ":mm")); + String defaultValue = new SimpleDateFormat(defaultStringFormat, Locale.getDefault()).format(dateTime); + SimpleDateFormat dateFormat; + String datePrefix; + String timeSuffix; + String format = zeroMins ? "h a" : "h:mm a"; + dateFormat = new SimpleDateFormat(format, Locale.getDefault()); + timeSuffix = dateFormat.format(dateTime); + boolean isToday = DateUtils.isToday(dateTime.getTime()); + if (isToday) { + datePrefix = context.getString(R.string.today) + " " + context.getString(R.string.at); + } else if (calendarDate.after(today)) { + int dateDayOfYear = calendarDate.get(Calendar.DAY_OF_YEAR); + int todayDateOfYear = today.get(Calendar.DAY_OF_YEAR); + int dateDiff = (dateDayOfYear - todayDateOfYear); + boolean sameYear = (today.get(Calendar.YEAR) == calendarDate.get(Calendar.YEAR)); + if ((dateDiff == 1) && sameYear) { + datePrefix = context.getString(R.string.tomorrow) + " " + context.getString(R.string.at); + } else if ((dateDiff < 7) && sameYear) { + dateFormat = new SimpleDateFormat("EEEE", Locale.getDefault()); + datePrefix = dateFormat.format(dateTime) + " " + context.getString(R.string.at); + } else { + return defaultValue; + } + } else { + return defaultValue; + } + return String.format("%s %s", datePrefix, timeSuffix); + } + + public static long getCurrentTimeMillisSince1970() { + return System.currentTimeMillis(); + } + } + + public static class Explore { + + public static HashMap optLocation(HashMap explore) { + if (explore == null) { + return null; + } + Object locationObj = explore.get("location"); + if (locationObj instanceof HashMap) { + return (HashMap) locationObj; + } + return null; + } + + public static Integer optLocationFloor(HashMap explore) { + if (explore == null) { + return null; + } + HashMap location = optLocation(explore); + return optFloor(location); + } + + public static Integer optFloor(HashMap location) { + if (location == null) { + return null; + } + Object floorObj = location.get("floor"); + if (floorObj instanceof Integer) { + return (Integer) floorObj; + } + return null; + } + + public static LatLng optLocationLatLng(HashMap explore) { + if (explore == null) { + return null; + } + ExploreType exploreType = getExploreType(explore); + if (exploreType == ExploreType.PARKING) { + // Json Example: + // {"lot_id":"647b7211-9cdf-412b-a682-1fdb68897f86","lot_name":"SFC - E-14 Lot - Illinois","lot_address1":"1800 S. First Street, Champaign, IL 61820","total_spots":"1710","entrance":{"latitude":40.096691,"longitude":-88.238179},"polygon":[{"latitude":40.097938,"longitude":-88.241409},{"latitude":40.09793,"longitude":-88.238657},{"latitude":40.094742,"longitude":-88.238651},{"latitude":40.094733,"longitude":-88.240223},{"latitude":40.095148,"longitude":-88.240245},{"latitude":40.095181,"longitude":-88.24113},{"latitude":40.095636,"longitude":-88.241135},{"latitude":40.095636,"longitude":-88.241393}],"spots_sold":0,"spots_pre_sold":0} + Object lotEntranceObj = explore.get("entrance"); + if (!(lotEntranceObj instanceof HashMap)) { + return null; + } + HashMap lotEntranceMap = (HashMap)lotEntranceObj; + return optLatLng(lotEntranceMap); + } else { + HashMap location = optLocation(explore); + return optLatLng(location); + } + } + + public static LatLng optLatLng(HashMap location) { + if (location == null) { + return null; + } + Object latObj = location.get("latitude"); + Object lngObj = location.get("longitude"); + if (!(latObj instanceof Double) || !(lngObj instanceof Double)) { + return null; + } + return new LatLng((Double) latObj, (Double) lngObj); + } + + public static Integer optMarkerLocationFloor(Marker marker) { + JSONObject tag = optMarkerTagJson(marker); + Object markerRawData = (tag != null) ? tag.opt("raw_data") : null; + HashMap exploreMap = null; + if (markerRawData instanceof HashMap) { + exploreMap = (HashMap) markerRawData; + } else if (markerRawData instanceof ArrayList) { + ArrayList explores = (ArrayList) markerRawData; + if (explores.size() > 0) { + Object exploreObj = explores.get(0); + if (exploreObj instanceof HashMap) { + exploreMap = (HashMap) exploreObj; + } + } + } + return optLocationFloor(exploreMap); + } + + public static boolean optSingleExploreMarker(Marker marker) { + JSONObject markerTagJson = optMarkerTagJson(marker); + if (markerTagJson == null) { + return false; + } + return markerTagJson.optBoolean("single_explore", false); + } + + public static MarkerOptions constructMarkerOptions(Context context, Object markerRawObject, View markerLayoutView, View markerGroupLayoutView, IconGenerator iconGenerator) { + if (markerRawObject == null || markerLayoutView == null || markerGroupLayoutView == null || iconGenerator == null) { + return null; + } + MapMarkerViewType mapMarkerViewType; + HashMap singleExploreMap = null; + ArrayList groupExploresJson = null; + if (markerRawObject instanceof HashMap) { + mapMarkerViewType = MapMarkerViewType.SINGLE; + singleExploreMap = (HashMap) markerRawObject; + } else if (markerRawObject instanceof ArrayList) { + mapMarkerViewType = MapMarkerViewType.GROUP; + groupExploresJson = (ArrayList) markerRawObject; + Object singleObject = groupExploresJson.get(0); + if (singleObject instanceof HashMap) { + singleExploreMap = (HashMap) singleObject; + } + } else { + mapMarkerViewType = MapMarkerViewType.UNKNOWN; + } + if (mapMarkerViewType == MapMarkerViewType.UNKNOWN) { + return null; + } + LatLng markerLocation = optLocationLatLng(singleExploreMap); + if (markerLocation == null) { + return null; + } + String markerTitle = getMarkerTitle(mapMarkerViewType, singleExploreMap, groupExploresJson); + MarkerOptions markerOptions = new MarkerOptions(); + markerOptions.position(markerLocation); + markerOptions.zIndex(1); + markerOptions.title(markerTitle); + ExploreType exploreType = getExploreType(markerRawObject); + Bitmap markerIcon; + if (mapMarkerViewType == MapMarkerViewType.SINGLE) { + String markerSnippet = getMarkerSnippet(context, singleExploreMap); + if (markerSnippet != null && !markerSnippet.isEmpty()) { + markerOptions.snippet(markerSnippet); + } + int iconResource = getSingleExploreIconResource(exploreType); + TextView markerTitleView = markerLayoutView.findViewById(R.id.markerTitleView); + markerTitleView.setText(markerTitle); + TextView markerSnippetView = markerLayoutView.findViewById(R.id.markerSnippetView); + markerSnippetView.setText(markerSnippet); + boolean snippetViewVisible = ((markerSnippet != null) && !markerSnippet.isEmpty()); + markerSnippetView.setVisibility(snippetViewVisible ? VISIBLE : GONE); + ImageView iconImageView = markerLayoutView.findViewById(R.id.markerIconView); + iconImageView.setImageResource(iconResource); + iconGenerator.setContentView(markerLayoutView); + markerIcon = iconGenerator.makeIcon(); + } else { + TextView markerTitleView = markerGroupLayoutView.findViewById(R.id.markerGroupTitleView); + markerTitleView.setText(markerTitle); + String descrLabel = getGroupExploresDescrLabel(context, markerTitle, exploreType); + TextView markerDescrView = markerGroupLayoutView.findViewById(R.id.markerGroupDescrView); + markerDescrView.setText(descrLabel); + ImageView markerCircleView = markerGroupLayoutView.findViewById(R.id.markerGroupCircleView); + Drawable circleViewBackground = markerCircleView.getBackground(); + if (circleViewBackground instanceof GradientDrawable) { + int exploreGroupColor = getExploreColorResource(exploreType); + GradientDrawable gradientDrawable = (GradientDrawable) circleViewBackground; + gradientDrawable.setColor(ContextCompat.getColor(context, exploreGroupColor)); + } + iconGenerator.setContentView(markerGroupLayoutView); + markerIcon = iconGenerator.makeIcon(); + } + if (markerIcon != null) { + markerOptions.icon(BitmapDescriptorFactory.fromBitmap(markerIcon)); + } + return markerOptions; + } + + public static void updateCustomMarkerAppearance(Context context, Marker marker, + boolean singleExploreMarker, float currentCameraZoom, float previousCameraZoom, + View markerLayoutView, View markerGroupLayoutView, IconGenerator iconGenerator) { + if (marker == null) { + return; + } + float minCurrentZoom = Math.min(currentCameraZoom, previousCameraZoom); + float maxCurrentZoom = Math.max(currentCameraZoom, previousCameraZoom); + boolean changeMarkerIcon = false; + //Check if title threshold passed + if ((minCurrentZoom <= Constants.FIRST_THRESHOLD_MARKER_ZOOM) && + (Constants.FIRST_THRESHOLD_MARKER_ZOOM < maxCurrentZoom)) { + boolean passedFirstThreshold = (currentCameraZoom >= Constants.FIRST_THRESHOLD_MARKER_ZOOM); + if (singleExploreMarker) { + int textVisibility = passedFirstThreshold ? View.VISIBLE : View.GONE; + View textFrameView = markerLayoutView.findViewById(R.id.markerTextFrame); + textFrameView.setVisibility(textVisibility); + if (passedFirstThreshold) { + String shortTitle = Utils.Explore.optExploreMarkerShortTitle(marker); + TextView markerTitleView = markerLayoutView.findViewById(R.id.markerTitleView); + String markerTitle = marker.getTitle(); + markerTitleView.setText(shortTitle != null ? shortTitle : markerTitle); + } + } else { + ImageView markerGroupCircleView = markerGroupLayoutView.findViewById(R.id.markerGroupCircleView); + int imageViewSize = context.getResources().getDimensionPixelSize(passedFirstThreshold ? R.dimen.group_marker_image_size_first : R.dimen.group_marker_image_size_zero); + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(imageViewSize, imageViewSize); + markerGroupCircleView.setLayoutParams(layoutParams); + TextView markerGroupTitleView = markerGroupLayoutView.findViewById(R.id.markerGroupTitleView); + String markerTitle = marker.getTitle(); + markerGroupTitleView.setText(markerTitle); + } + changeMarkerIcon = true; + } + //Check if snippet threshold passed + if ((minCurrentZoom <= Constants.SECOND_THRESHOLD_MARKER_ZOOM) && + (Constants.SECOND_THRESHOLD_MARKER_ZOOM < maxCurrentZoom)) { + boolean passedSecondThreshold = (currentCameraZoom > Constants.SECOND_THRESHOLD_MARKER_ZOOM); + if (singleExploreMarker) { + TextView markerTitleView = markerLayoutView.findViewById(R.id.markerTitleView); + String markerTitle = marker.getTitle(); + String shortTitle = Utils.Explore.optExploreMarkerShortTitle(marker); + markerTitleView.setText(passedSecondThreshold ? markerTitle : shortTitle); + } else { + ImageView markerGroupCircleView = markerGroupLayoutView.findViewById(R.id.markerGroupCircleView); + int imageViewSize = context.getResources().getDimensionPixelSize(passedSecondThreshold ? R.dimen.group_marker_image_size_second : R.dimen.group_marker_image_size_first); + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(imageViewSize, imageViewSize); + markerGroupCircleView.setLayoutParams(layoutParams); + TextView markerGroupTitleView = markerGroupLayoutView.findViewById(R.id.markerGroupTitleView); + String markerTitle = marker.getTitle(); + markerGroupTitleView.setText(markerTitle); + TextView groupDescrView = markerGroupLayoutView.findViewById(R.id.markerGroupDescrView); + String markerDescription = Utils.Explore.optExploreMarkerDescrLabel(marker); + groupDescrView.setText(markerDescription); + int descrVisibility = passedSecondThreshold ? View.VISIBLE : View.GONE; + groupDescrView.setVisibility(descrVisibility); + } + changeMarkerIcon = true; + } + //Change Marker icon only if needed + if (changeMarkerIcon) { + iconGenerator.setContentView(singleExploreMarker ? markerLayoutView : markerGroupLayoutView); + Bitmap icon = iconGenerator.makeIcon(); + marker.setIcon(BitmapDescriptorFactory.fromBitmap(icon)); + } + } + + public static JSONObject constructMarkerTagJson(Context context, String markerTitle, Object markerRawData) { + boolean singleExploreMarker = (markerRawData instanceof HashMap); + String shortTitle = (markerTitle != null && markerTitle.length() > Constants.MARKER_TITLE_MAX_SYMBOLS_NUMBER) ? + String.format("%s...", markerTitle.substring(0, 15)) : markerTitle; + JSONObject markerTagJson = new JSONObject(); + try { + markerTagJson.put("title", markerTitle); + markerTagJson.put("short_title", shortTitle); + markerTagJson.put("raw_data", markerRawData); + markerTagJson.put("single_explore", singleExploreMarker); + if (!singleExploreMarker) { + ExploreType exploreType = getExploreType(markerRawData); + markerTagJson.put("description", getGroupExploresDescrLabel(context, markerTitle, exploreType)); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return markerTagJson; + } + + public static Object optExploreMarkerRawData(Marker marker) { + JSONObject markerTagJson = optMarkerTagJson(marker); + return (markerTagJson != null) ? markerTagJson.opt("raw_data") : null; + } + + private static String optExploreMarkerShortTitle(Marker marker) { + JSONObject markerTagJson = optMarkerTagJson(marker); + return (markerTagJson != null) ? markerTagJson.optString("short_title", null) : null; + } + + private static String optExploreMarkerDescrLabel(Marker marker) { + JSONObject markerTagJson = optMarkerTagJson(marker); + return (markerTagJson != null) ? markerTagJson.optString("description", null) : null; + } + + public static HashMap createLocationMap(LatLng latLng) { + if (latLng == null) { + return null; + } + HashMap location = new HashMap<>(); + location.put("latitude", latLng.latitude); + location.put("longitude", latLng.longitude); + return location; + } + + public static ExploreType getExploreType(Object explore) { + HashMap singleExplore = null; + if (explore instanceof HashMap) { + singleExplore = (HashMap) explore; + } else if (explore instanceof ArrayList) { + ArrayList explores = (ArrayList) explore; + if (explores.size() > 0) { + Object firstExploreObj = explores.get(0); + if (firstExploreObj instanceof HashMap) { + singleExplore = (HashMap) firstExploreObj; + } + } + } + if (singleExplore == null) { + return ExploreType.UNKNOWN; + } + if (singleExplore.get("eventId") != null) { + return ExploreType.EVENT; + } else if (singleExplore.get("DiningOptionID") != null) { + return ExploreType.DINING; + } else if (singleExplore.get("campus_name") != null) { + return ExploreType.LAUNDRY; + } else if (singleExplore.get("lot_id") != null) { + return ExploreType.PARKING; + } else { + return ExploreType.UNKNOWN; + } + } + + public static List getExplorePolygon(Object explore) { + if (getExploreType(explore) != ExploreType.PARKING) { + return null; + } + HashMap parkingLot = (HashMap) explore; + Object polygonObject = parkingLot.get("polygon"); + if (!(polygonObject instanceof List)) { + return null; + } + List polygonMaps = (List) polygonObject; + if (polygonMaps.isEmpty()) { + return null; + } + List polygonPoints = new ArrayList<>(); + for (HashMap point : polygonMaps) { + double latitude = Utils.Map.getValueFromPath(point, "latitude", 0.0d); + double longitude = Utils.Map.getValueFromPath(point, "longitude", 0.0d); + LatLng latLng = new LatLng(latitude, longitude); + polygonPoints.add(latLng); + } + return polygonPoints; + } + + public static int getExploreColorResource(ExploreType exploreType) { + int colorResource; + switch (exploreType) { + case EVENT: + colorResource = R.color.illinois_orange; + break; + case DINING: + colorResource = R.color.mongo; + break; + default: + colorResource = R.color.teal; + break; + } + return colorResource; + } + + private static String getMarkerTitle(MapMarkerViewType mapMarkerViewType, HashMap singleExploreMap, ArrayList groupExploresList) { + String markerTitle; + if (mapMarkerViewType == MapMarkerViewType.SINGLE) { + ExploreType exporeType = getExploreType(singleExploreMap); + if (exporeType == ExploreType.PARKING) { + // Json Example: + // {"lot_id":"647b7211-9cdf-412b-a682-1fdb68897f86","lot_name":"SFC - E-14 Lot - Illinois","lot_address1":"1800 S. First Street, Champaign, IL 61820","total_spots":"1710","entrance":{"latitude":40.096691,"longitude":-88.238179},"polygon":[{"latitude":40.097938,"longitude":-88.241409},{"latitude":40.09793,"longitude":-88.238657},{"latitude":40.094742,"longitude":-88.238651},{"latitude":40.094733,"longitude":-88.240223},{"latitude":40.095148,"longitude":-88.240245},{"latitude":40.095181,"longitude":-88.24113},{"latitude":40.095636,"longitude":-88.241135},{"latitude":40.095636,"longitude":-88.241393}],"spots_sold":0,"spots_pre_sold":0} + markerTitle = (String) singleExploreMap.get("lot_name"); + } else { + markerTitle = (String) singleExploreMap.get("title"); + } + } else { + markerTitle = String.valueOf(groupExploresList.size()); + } + return markerTitle; + } + + private static String getMarkerSnippet(Context context, HashMap exploreMap) { + if (exploreMap == null) { + return null; + } + String markerSnippet; + String startDateToString = (String) exploreMap.get("startDateLocal"); + if (startDateToString != null && !startDateToString.isEmpty()) { + Date eventStartDate = DateTime.getDateTime(startDateToString); + markerSnippet = DateTime.formatEventTime(context, eventStartDate); + } else { + markerSnippet = (String) exploreMap.get("status"); + } + return markerSnippet; + } + + private static int getSingleExploreIconResource(ExploreType exploreType) { + int iconResource; + switch (exploreType) { + case EVENT: + iconResource = R.drawable.marker_event; + break; + case DINING: + iconResource = R.drawable.marker_dining; + break; + default: + iconResource = R.drawable.marker_default_teal; + break; + } + return iconResource; + } + + private static String getGroupExploresDescrLabel(Context context, String exploresCountString, ExploreType exploreType) { + String groupDescrLabel = ""; + if (exploresCountString != null) { + String typeSuffix; + switch (exploreType) { + case EVENT: + typeSuffix = context.getString(R.string.events); + break; + case DINING: + typeSuffix = context.getString(R.string.dinings); + break; + case LAUNDRY: + typeSuffix = context.getString(R.string.laundries); + break; + case PARKING: + typeSuffix = context.getString(R.string.parkings); + break; + default: + typeSuffix = context.getString(R.string.explores); + break; + } + groupDescrLabel = exploresCountString + " " + typeSuffix; + } + return groupDescrLabel; + } + + private static JSONObject optMarkerTagJson(Marker marker) { + if (marker == null) { + return null; + } + Object markerTag = marker.getTag(); + if (!(markerTag instanceof JSONObject)) { + return null; + } + return (JSONObject) markerTag; + } + } + + public static class Location { + + public static Double getDistanceBetween(LatLng firstLatLng, LatLng secondLatLng) { + if (firstLatLng == null || secondLatLng == null) { + return null; + } + android.location.Location firstLocation = new android.location.Location("firstLatLng"); + firstLocation.setLatitude(firstLatLng.latitude); + firstLocation.setLongitude(firstLatLng.longitude); + android.location.Location secondLocation = new android.location.Location("secondLatLng"); + secondLocation.setLatitude(secondLatLng.latitude); + secondLocation.setLongitude(secondLatLng.longitude); + float distance = firstLocation.distanceTo(secondLocation); + return (double) distance; + } + } + + public static class Map { + + public static String getValueFromPath(Object object, String path, String defaultValue) { + Object valueObject = getValueFromPath(object, path); + return (valueObject instanceof String) ? (String)valueObject : defaultValue; + } + + public static int getValueFromPath(Object object, String path, int defaultValue) { + Object valueObject = getValueFromPath(object, path); + return (valueObject instanceof Integer) ? (Integer) valueObject : defaultValue; + } + + public static long getValueFromPath(Object object, String path, long defaultValue) { + Object valueObject = getValueFromPath(object, path); + return (valueObject instanceof Long) ? (Long) valueObject : defaultValue; + } + + public static double getValueFromPath(Object object, String path, double defaultValue) { + Object valueObject = getValueFromPath(object, path); + return (valueObject instanceof Double) ? (Double) valueObject : defaultValue; + } + + public static boolean getValueFromPath(Object object, String path, boolean defaultValue) { + Object valueObject = getValueFromPath(object, path); + return (valueObject instanceof Boolean) ? (Boolean) valueObject : defaultValue; + } + + private static Object getValueFromPath(Object object, String path) { + if (!(object instanceof java.util.Map) || Str.isEmpty(path)) { + return null; + } + java.util.Map map = (java.util.Map) object; + int dotFirstIndex = path.indexOf("."); + while (dotFirstIndex != -1) { + String subPath = path.substring(0, dotFirstIndex); + path = path.substring(dotFirstIndex + 1); + Object innerObject = (map != null) ? map.get(subPath) : null; + map = (innerObject instanceof HashMap) ? (HashMap) innerObject : null; + dotFirstIndex = path.indexOf("."); + } + Object generalValue = (map != null) ? map.get(path) : null; + return getPlatformValue(generalValue); + } + + private static Object getPlatformValue(Object object) { + if (object instanceof HashMap) { + HashMap hashMap = (HashMap) object; + return hashMap.get("android"); + } else { + return object; + } + } + } + + public static class Str { + public static boolean isEmpty(String value) { + return (value == null) || value.isEmpty(); + } + + public static String nullIfEmpty(String value) { + if (isEmpty(value)) { + return null; + } + return value; + } + + public static byte[] hexStringToByteArray(String s) { + if(s != null) { + int len = s.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + + Character.digit(s.charAt(i + 1), 16)); + } + return data; + } + return null; + } + + public static String byteArrayToHexString(byte[] bytes){ + if(bytes != null) { + Formatter formatter = new Formatter(); + for (byte b : bytes) { + formatter.format("%02x", b); + } + return formatter.toString(); + } + return null; + } + + } + + public static class Base64 { + + public static byte[] decode(String value) { + if (value != null) { + return android.util.Base64.decode(value, android.util.Base64.NO_WRAP); + } else { + return null; + } + } + + public static String encode(byte[] bytes) { + if (bytes != null) { + return android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP); + } else { + return null; + } + } + } + + public static class BackupStorage { + + public static String getString(Context context, String fileName, String key) { + if ((context == null) || Str.isEmpty(fileName) || Str.isEmpty(key)) { + return null; + } + SharedPreferences sharedPreferences = context.getSharedPreferences(fileName, Context.MODE_PRIVATE); + return sharedPreferences.getString(key, null); + } + + public static void saveString(Context context, String fileName, String key, String value) { + if ((context == null) || Str.isEmpty(fileName) || Str.isEmpty(key)) { + return; + } + SharedPreferences sharedPreferences = context.getSharedPreferences(fileName, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(key, value); + editor.apply(); + AppBackupAgent.requestBackup(context); + } + + public static void remove(Context context, String fileName, String key) { + if ((context == null) || Str.isEmpty(fileName) || Str.isEmpty(key)) { + return; + } + SharedPreferences sharedPreferences = context.getSharedPreferences(fileName, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.remove(key); + editor.apply(); + AppBackupAgent.requestBackup(context); + } + } + + public enum ExploreType { + EVENT, DINING, LAUNDRY, PARKING, UNKNOWN + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/ExposurePlugin.java b/android/app/src/main/java/edu/illinois/covid/exposure/ExposurePlugin.java new file mode 100644 index 00000000..456764c0 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/ExposurePlugin.java @@ -0,0 +1,1335 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanRecord; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.ParcelUuid; +import android.util.Log; + +import com.welie.blessed.BluetoothCentral; +import com.welie.blessed.BluetoothPeripheral; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; +import java.util.UUID; + +import androidx.annotation.NonNull; +import at.favre.lib.crypto.HKDF; +import edu.illinois.covid.Constants; +import edu.illinois.covid.MainActivity; +import edu.illinois.covid.R; +import edu.illinois.covid.Utils; +import edu.illinois.covid.exposure.ble.ExposureClient; +import edu.illinois.covid.exposure.ble.ExposureServer; +import edu.illinois.covid.exposure.crypto.AES; +import edu.illinois.covid.exposure.crypto.AES_CTR; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry; + +public class ExposurePlugin implements MethodChannel.MethodCallHandler { + + private static final String TAG = "ExposurePlugin"; + + private static ExposurePlugin instance; + + private MainActivity activityContext; + private MethodChannel methodChannel; + + private MethodChannel.Result startedResult; + private Object settings; + + private ExposureServer exposureServer; + private boolean serverStarted; + private ExposureClient androidExposureClient; + private boolean clientStarted; + + // iOS specific scanner/client + private BluetoothCentral iosExposureCentral; + private Handler handler = new Handler(); + private Map peripherals; + private Map peripheralsRPIs; + private Map iosExposures; + + // iOS background variable scanner + private static final int iosbgManufacturerID = 76; + private static final byte[] manufacturerDataMask = Utils.Str.hexStringToByteArray("ff00000000000000000000000000002000"); + private static final byte[] manufacturerData = Utils.Str.hexStringToByteArray("0100000000000000000000000000002000"); + private BluetoothAdapter iosBgBluetoothAdapter; + private BluetoothLeScanner iosBgBluoetoothLeScanner; + private List iosBgScanFilters; + private ScanSettings iosBgScanSettings; + private Map peripherals_bg; + private Handler ios_bg_handler = new Handler(); + private Handler mainHandler = new Handler(Looper.getMainLooper()); + private Handler callbackHandler = new Handler(); + private static final String CCC_DESCRIPTOR_UUID = "00002902-0000-1000-8000-00805f9b34fb"; // characteristic notification + private Runnable iosBgScanTimeoutRunnable; // for restarting scan in android 9 + + // RPI + private byte[] rpi; + private Timer rpiTimer; + private static final int RPI_REFRESH_INTERVAL_SECS = 10 * 60; // 10 minutes + private static final int TEKRollingPeriod = 144; + + private Timer exposuresTimer; + private long lastNotifyExposureTickTimestamp; + private Map androidExposures; + + private Map> i_TEK_map; + + // Exposure Constants + private static final int EXPOSURE_TIMEOUT_INTERVAL_MILLIS = 2 * 60 * 1000; // 2 minutes + private static final int EXPOSURE_PING_INTERVAL_MILLIS = 60 * 1000; // 1 minute + private static final int EXPOSURE_PROCESS_INTERVAL_MILLIS = 10 * 1000; // 10 secs + private static final int EXPOSURE_NOTIFY_TICK_INTERVAL_MILLIS = 1000; // 1 secs + + // Exposure Settings + private int exposureTimeoutIntervalInMillis; + private int exposurePingIntervalInMillis; + private int exposureProcessIntervalInMillis; + private int exposureMinDurationInMillis; + private int exposureMinRssi; + + // Helper constants + private static final String TEK_MAP_KEY = "tek"; + private static final String RPI_MAP_KEY = "rpi"; + private static final String EN_INTERVAL_NUMBER_MAP_KEY = "ENIntervalNumber"; + private static final String I_MAP_KEY = "i"; + private static final String DATABASE_VERSION_KEY = "databaseversion"; + + public static ExposurePlugin registerWith(PluginRegistry.Registrar registrar) { + final MethodChannel channel = new MethodChannel(registrar.messenger(), "edu.illinois.covid/exposure"); + ExposurePlugin exposurePlugin = new ExposurePlugin((MainActivity) registrar.activity(), channel); + channel.setMethodCallHandler(exposurePlugin); + if (instance == null) { + instance = exposurePlugin; + } + return exposurePlugin; + } + + private ExposurePlugin(MainActivity activity, MethodChannel methodChannel) { + this.activityContext = activity; + this.methodChannel = methodChannel; + this.methodChannel.setMethodCallHandler(this); + + this.peripherals = new HashMap<>(); + this.peripheralsRPIs = new HashMap<>(); + this.iosExposures = new HashMap<>(); + this.androidExposures = new HashMap<>(); + + this.i_TEK_map = loadTeksFromStorage(); + this.peripherals_bg = new HashMap<>(); + } + + //region Public APIs Implementation + + private void handleStart(@NonNull MethodChannel.Result result, Object settings) { + Log.d(TAG, "handleStart: start plugin"); + startedResult = result; + initSettings(settings); + bindExposureServer(); + bindExposureClient(); + } + + public void handleStop() { + Log.d(TAG, "handleStop: stop plugin"); + startedResult = null; + stop(); + } + + private void start() { + Log.d(TAG, "start"); + refreshRpi(); + startAdvertise(); + startRpiTimer(); + startScan(); + } + + private void stop() { + Log.d(TAG, "stop"); + stopAdvertise(); + stopScan(); + clearRpi(); + clearExposures(); + stopRpiTimer(); + unBindExposureServer(); + unBindExposureClient(); + } + + private void refreshRpi() { + Log.d(TAG, "refreshRpi"); + Map retVal = generateRpi(); + rpi = (byte[]) retVal.get(RPI_MAP_KEY); + byte[] tek = (byte[]) retVal.get(TEK_MAP_KEY); + int i = (int) retVal.get(I_MAP_KEY); + int enIntervalNumber = (int) retVal.get(EN_INTERVAL_NUMBER_MAP_KEY); + Log.d(TAG, "Logged - tek: " + Utils.Base64.encode(tek) + ", i: " + i + ", enIntervalNumber: " + enIntervalNumber); + uploadRPIUpdate(rpi, tek, Utils.DateTime.getCurrentTimeMillisSince1970(), i, enIntervalNumber); + String rpiEncoded = Utils.Base64.encode(rpi); + Log.d(TAG, "final rpi = " + rpiEncoded + "size = " + (rpi != null ? rpi.length : "null")); + if (exposureServer != null) { + exposureServer.setRpi(rpi); + } + } + + private Map generateRpi() { + long currentTimestampInMillis = Utils.DateTime.getCurrentTimeMillisSince1970(); + long currentTimeStampInSecs = currentTimestampInMillis / 1000; + int timestamp = (int) currentTimeStampInSecs; + int ENIntervalNumber = timestamp / RPI_REFRESH_INTERVAL_SECS; + Log.d(TAG, "ENIntervalNumber = " + ENIntervalNumber); + int i = (ENIntervalNumber / TEKRollingPeriod) * TEKRollingPeriod; + int expireIntervalNumber = i + TEKRollingPeriod; + Log.d(TAG, "i = " + i); + + /* if new day, generate a new tek */ + /* if in the rest of the day, using last valid TEK */ + if ((i_TEK_map != null) && !i_TEK_map.isEmpty()) { + Integer lastTimestamp = Collections.max(i_TEK_map.keySet()); + Map lastTek = i_TEK_map.get(lastTimestamp); + Integer lastExpireTime = (lastTek != null) ? lastTek.get(lastTek.keySet().iterator().next()) : null; + if ((lastExpireTime != null) && (lastExpireTime == expireIntervalNumber)) { + i = lastTimestamp; + } else { + i = ENIntervalNumber; + } + } + + Map tek = new HashMap<>(); + if (i_TEK_map.isEmpty() || !i_TEK_map.containsKey(i)) { + byte[] bytes = new byte[16]; + SecureRandom rand = new SecureRandom(); // generating TEK with a cryptographic random number generator + rand.nextBytes(bytes); + tek.put(bytes, expireIntervalNumber); + i_TEK_map.put(i, tek); // putting the TEK map as a value for the current i + + // handling more than 14 i values in the map + if (i_TEK_map.size() >= 14) { + Iterator it = i_TEK_map.keySet().iterator(); + while (it.hasNext()) { + int key = it.next(); + if (key <= i - 14) + it.remove(); + } + } + + // Save TEKs to storage + saveTeksToStorage(i_TEK_map); + + // Notify TEK + long notifyTimestampInMillis = (long) i * RPI_REFRESH_INTERVAL_SECS * 1000; // in millis + long expireTime = (long) expireIntervalNumber * RPI_REFRESH_INTERVAL_SECS * 1000; + byte[] tekData = tek.keySet().iterator().next(); + notifyTek(tekData, notifyTimestampInMillis, expireTime); + } else { + tek = i_TEK_map.get(i); + } + byte[] rpiTek = (tek != null) ? tek.keySet().iterator().next() : null; + byte[] rpi = generateRpiForIntervalNumber(ENIntervalNumber, rpiTek); + Map retVal = new HashMap<>(); + retVal.put(RPI_MAP_KEY, rpi); + retVal.put(TEK_MAP_KEY, rpiTek); + retVal.put(EN_INTERVAL_NUMBER_MAP_KEY, ENIntervalNumber); + retVal.put(I_MAP_KEY, i); + return retVal; + } + + private byte[] generateRpiForIntervalNumber(int enIntervalNumber, byte[] tek) { + // generating RPIK with salt as null and passing in the correct parameters to the extractandexpand function of HKDF class + // in the file HKDF.java + byte[] salt = null; + byte[] info = "EN-RPIK".getBytes(); + byte[] RPIK = HKDF.fromHmacSha256().extractAndExpand(salt, tek, info, 16); + + // generating AEMK using the same method + info = "EN-AEMK".getBytes(); + byte[] AEMK = HKDF.fromHmacSha256().extractAndExpand(salt, tek, info, 16); + + // creating the padded data for AES encryption of RPIK to get RPI + byte[] padded_data = new byte[16]; + byte[] EN_RPI = "EN-RPI".getBytes(); + ByteBuffer bb = ByteBuffer.allocate(4); + bb.putInt(enIntervalNumber); + byte[] ENIN = bb.array(); + int j = 0; + for (byte b : EN_RPI) { + padded_data[j] = b; + j++; + } + for (j = 6; j <= 11; j++) { + padded_data[j] = 0; + } + for (byte b : ENIN) { + padded_data[j] = b; + j++; + } + + byte[] rpi_byte = AES.encrypt(RPIK, padded_data); + + if (rpi_byte == null) { + Log.w(TAG, "Newly generated rpi_byte is null"); + return null; + } + + byte[] metadata = new byte[4]; + byte[] AEM_byte = new byte[4]; + try { + AEM_byte = AES_CTR.encrypt(AEMK, rpi_byte, metadata); + } catch (Exception e) { + System.out.println("Error while encrypting: " + e.toString()); + } + + byte[] bluetoothpayload = new byte[20]; + System.arraycopy(rpi_byte, 0, bluetoothpayload, 0, rpi_byte.length); + System.arraycopy(AEM_byte, 0, bluetoothpayload, rpi_byte.length, AEM_byte.length); + + return bluetoothpayload; + } + + private void uploadRPIUpdate(byte[] rpi, byte[] parentTek, long updateTime, int i, int ENInvertalNumber) { + String tekString = Utils.Base64.encode(parentTek); + String rpiString = Utils.Base64.encode(rpi); + Map rpiParams = new HashMap<>(); + rpiParams.put(Constants.EXPOSURE_PLUGIN_TIMESTAMP_PARAM_NAME, updateTime); + rpiParams.put(Constants.EXPOSURE_PLUGIN_TEK_PARAM_NAME, tekString); + rpiParams.put(Constants.EXPOSURE_PLUGIN_RPI_PARAM_NAME, rpiString); + rpiParams.put("updateType", ""); + rpiParams.put("_i", i); + rpiParams.put("ENInvertalNumber", ENInvertalNumber); + invokeFlutterMethod(Constants.EXPOSURE_PLUGIN_METHOD_NAME_RPI_LOG, rpiParams); + } + + private void clearRpi() { + rpi = null; + } + + private void startAdvertise() { + if (exposureServer != null) { + exposureServer.start(); + } + } + + private void stopAdvertise() { + if (exposureServer != null) { + exposureServer.stop(); + } + } + + private void startScan() { + if (androidExposureClient != null) { + androidExposureClient.startScan(); + } + startIosScan(); + } + + private void stopScan() { + if (androidExposureClient != null) { + androidExposureClient.stopScan(); + } + stopIosScan(); + processExposures(); + } + + private void initSettings(Object settings) { + this.settings = settings; + + // Exposure Timeout Interval + int timeoutIntervalInSecs = Utils.Map.getValueFromPath(settings, "covid19ExposureServiceTimeoutInterval", (EXPOSURE_TIMEOUT_INTERVAL_MILLIS / 1000)); // in seconds + this.exposureTimeoutIntervalInMillis = timeoutIntervalInSecs * 1000; //in millis + + // Exposure Ping Interval + int pingIntervalInSecs = Utils.Map.getValueFromPath(settings, "covid19ExposureServicePingInterval", (EXPOSURE_PING_INTERVAL_MILLIS / 1000)); // in seconds + this.exposurePingIntervalInMillis = pingIntervalInSecs * 1000; //in millis + + // Exposure Process Interval + int processIntervalInSecs = Utils.Map.getValueFromPath(settings, "covid19ExposureServiceProcessInterval", (EXPOSURE_PROCESS_INTERVAL_MILLIS / 1000)); // in seconds + this.exposureProcessIntervalInMillis = processIntervalInSecs * 1000; //in millis + + // Exposure Min Duration Interval + int minDurationInSecs = Utils.Map.getValueFromPath(settings, "covid19ExposureServiceLogMinDuration", (Constants.EXPOSURE_MIN_DURATION_MILLIS / 1000)); // in seconds + this.exposureMinDurationInMillis = minDurationInSecs * 1000; //in millis + + // Exposure Min RSSI + this.exposureMinRssi = Utils.Map.getValueFromPath(settings, "covid19ExposureServiceMinRSSI", Constants.EXPOSURE_MIN_RSSI_VALUE); + } + + //endregion + + //region Single Instance + + public static ExposurePlugin getInstance() { + return instance; + } + + int getExposureMinRssi() { + return exposureMinRssi; + } + + //endregion + + //region External RPI implementation + + private void logAndroidExposure(String rpi, int rssi, String deviceAddress) { + long currentTimeStamp = Utils.DateTime.getCurrentTimeMillisSince1970(); + ExposureRecord record = androidExposures.get(rpi); + if (record == null) { + Log.d(TAG, "registered android rpi: " + rpi); + record = new ExposureRecord(currentTimeStamp, rssi); + androidExposures.put(rpi, record); + updateExposuresTimer(); + } else { + record.updateTimeStamp(currentTimeStamp, rssi); + } + notifyExposureTick(rpi, rssi); + notifyExposureRssiLog(rpi, currentTimeStamp, rssi, false, deviceAddress); + } + + private void logIosExposure(String peripheralAddress, int rssi) { + if (Utils.Str.isEmpty(peripheralAddress)) { + return; + } + long currentTimestamp = Utils.DateTime.getCurrentTimeMillisSince1970(); + ExposureRecord record = iosExposures.get(peripheralAddress); + if (record == null) { + // Create new + Log.d(TAG, "Registered ios peripheral: " + peripheralAddress); + record = new ExposureRecord(currentTimestamp, rssi); + iosExposures.put(peripheralAddress, record); + updateExposuresTimer(); + } else { + // Update existing + record.updateTimeStamp(currentTimestamp, rssi); + } + byte[] rpi = peripheralsRPIs.get(peripheralAddress); + String encodedRpi = ""; + if (rpi != null) { + encodedRpi = Utils.Base64.encode(rpi); + notifyExposureTick(encodedRpi, rssi); + } + notifyExposureRssiLog(encodedRpi, currentTimestamp, rssi, true, peripheralAddress); + } + + private void notifyExposureTick(String rpi, int rssi) { + if (Utils.Str.isEmpty(rpi)) { + return; + } + long currentTimestamp = Utils.DateTime.getCurrentTimeMillisSince1970(); + // Do not allow more than one notification per second + if (EXPOSURE_NOTIFY_TICK_INTERVAL_MILLIS <= (currentTimestamp - lastNotifyExposureTickTimestamp)) { + Map exposureTickParams = new HashMap<>(); + exposureTickParams.put(Constants.EXPOSURE_PLUGIN_TIMESTAMP_PARAM_NAME, currentTimestamp); + exposureTickParams.put(Constants.EXPOSURE_PLUGIN_RPI_PARAM_NAME, rpi); + exposureTickParams.put(Constants.EXPOSURE_PLUGIN_RSSI_PARAM_NAME, rssi); + invokeFlutterMethod(Constants.EXPOSURE_PLUGIN_METHOD_NAME_THICK, exposureTickParams); + lastNotifyExposureTickTimestamp = currentTimestamp; + } + } + + private void notifyExposureRssiLog(String encodedRpi, long currentTimeStamp, int rssi, boolean isiOS, String address) { + Map rssiParams = new HashMap<>(); + rssiParams.put(Constants.EXPOSURE_PLUGIN_RPI_PARAM_NAME, encodedRpi); + rssiParams.put(Constants.EXPOSURE_PLUGIN_TIMESTAMP_PARAM_NAME, currentTimeStamp); + rssiParams.put(Constants.EXPOSURE_PLUGIN_RSSI_PARAM_NAME, rssi); + rssiParams.put(Constants.EXPOSURE_PLUGIN_IOS_RECORD_PARAM_NAME, isiOS); + rssiParams.put(Constants.EXPOSURE_PLUGIN_ADDRESS_PARAM_NAME, address); + invokeFlutterMethod(Constants.EXPOSURE_PLUGIN_METHOD_NAME_RSSI_LOG, rssiParams); + } + + private void processExposures() { + Log.d(TAG, "Process Exposures"); + long currentTimestamp = Utils.DateTime.getCurrentTimeMillisSince1970(); + Set expiredPeripheralAddress = null; + + // 1. Collect all iOS expired records (not updated after exposureTimeoutIntervalInMillis) + if ((iosExposures != null) && !iosExposures.isEmpty()) { + for (String peripheralAddress : iosExposures.keySet()) { + ExposureRecord record = iosExposures.get(peripheralAddress); + if (record != null) { + long lastHeardInterval = currentTimestamp - record.getTimestampUpdated(); + if (exposureTimeoutIntervalInMillis <= lastHeardInterval) { + Log.d(TAG, "Expired ios exposure: " + peripheralAddress); + if (expiredPeripheralAddress == null) { + expiredPeripheralAddress = new HashSet<>(); + } + expiredPeripheralAddress.add(peripheralAddress); + } else if(exposurePingIntervalInMillis <= lastHeardInterval) { + Log.d(TAG, "ios exposure ping: " + peripheralAddress); + BluetoothPeripheral peripheral = (peripherals != null) ? peripherals.get(peripheralAddress) : null; + if(peripheral != null) { + peripheral.readRemoteRssi(); + } + } + } + } + } + + if ((expiredPeripheralAddress != null) && !expiredPeripheralAddress.isEmpty()) { + for (String address : expiredPeripheralAddress) { + // remove expired records from iosExposures + disconnectIosPeripheral(address); + } + } + + // 2. Collect all Android expired records (not updated after exposureTimeoutIntervalInMillis) + Set expiredRPIs = null; + if((androidExposures != null) && !androidExposures.isEmpty()) { + for(String encodedRpi : androidExposures.keySet()) { + ExposureRecord record = androidExposures.get(encodedRpi); + if(record != null) { + long lastHeardInterval = currentTimestamp - record.getTimestampUpdated(); + if(exposureTimeoutIntervalInMillis <= lastHeardInterval) { + Log.d(TAG, "Expired android exposure: " + encodedRpi); + if(expiredRPIs == null) { + expiredRPIs = new HashSet<>(); + } + expiredRPIs.add(encodedRpi); + } + } + } + } + + if (expiredRPIs != null) { + // remove expired records from androidExposures + for (String encodedRpi : expiredRPIs) { + removeAndroidRpi(encodedRpi); + } + } + } + + private void clearExposures() { + if ((iosExposures != null) && !iosExposures.isEmpty()) { + Map iosExposureCopy = new HashMap<>(iosExposures); + for (String address : iosExposureCopy.keySet()) { + disconnectIosPeripheral(address); + } + } + if ((androidExposures != null) && !androidExposures.isEmpty()) { + Map androidExposureCopy = new HashMap<>(androidExposures); + for (String encodedRpi : androidExposureCopy.keySet()) { + removeAndroidRpi(encodedRpi); + } + } + } + + private void disconnectIosPeripheral(String peripheralAddress) { + disconnectIosBgPeripheral(peripheralAddress); + } + + private void removeIosPeripheral(String address) { + if (Utils.Str.isEmpty(address) || (peripherals == null) || (peripherals.isEmpty())) { + return; + } + peripherals.remove(address); + byte[] rpi = (peripheralsRPIs != null) ? peripheralsRPIs.get(address) : null; + if (rpi != null) { + peripheralsRPIs.remove(address); + } + if ((iosExposures == null) || iosExposures.isEmpty()) { + return; + } + ExposureRecord record = iosExposures.get(address); + if (record != null) { + iosExposures.remove(address); + updateExposuresTimer(); + } + if ((rpi != null) && (record != null)) { + String encodedRpi = Utils.Base64.encode(rpi); + notifyExposure(record, encodedRpi, true, address); + } + } + + private void removeAndroidRpi(String rpi) { + if ((androidExposures == null) || androidExposures.isEmpty()) { + return; + } + ExposureRecord record = androidExposures.get(rpi); + if (record != null) { + androidExposures.remove(rpi); + updateExposuresTimer(); + } + + if ((rpi != null) && (record != null)) { + notifyExposure(record, rpi, false, ""); + } + } + + private void notifyExposure(ExposureRecord record, String rpi, boolean isiOS, String peripheralUuid) { + if ((record != null) && (exposureMinDurationInMillis <= record.getDuration())) { + Map exposureParams = new HashMap<>(); + exposureParams.put(Constants.EXPOSURE_PLUGIN_TIMESTAMP_PARAM_NAME, record.getTimestampCreated()); + exposureParams.put(Constants.EXPOSURE_PLUGIN_RPI_PARAM_NAME, rpi); + exposureParams.put(Constants.EXPOSURE_PLUGIN_DURATION_PARAM_NAME, record.getDuration()); + exposureParams.put(Constants.EXPOSURE_PLUGIN_IOS_RECORD_PARAM_NAME, isiOS); + exposureParams.put(Constants.EXPOSURE_PLUGIN_PERIPHERAL_UUID_PARAM_NAME, peripheralUuid); + invokeFlutterMethod(Constants.EXPOSURE_PLUGIN_EXPOSURE_METHOD_NAME, exposureParams); + } + } + + //endregion + + //region TEKs + + private void changeTekExpireTime() { + i_TEK_map = loadTeksFromStorage(); + if (i_TEK_map != null) { + Integer currentI = Collections.max(i_TEK_map.keySet()); + Map oldTEK = i_TEK_map.get(currentI); + byte[] tek = (oldTEK != null) ? oldTEK.keySet().iterator().next() : null; + + long currentTimestampInMillis = Utils.DateTime.getCurrentTimeMillisSince1970(); + long currentTimeStampInSecs = currentTimestampInMillis / 1000; + int timestamp = (int) currentTimeStampInSecs; + int ENIntervalNumber = timestamp / RPI_REFRESH_INTERVAL_SECS; + Map newTEK = new HashMap<>(); + newTEK.put(tek, (ENIntervalNumber + 1)); + + i_TEK_map.replace(currentI, newTEK); + saveTeksToStorage(i_TEK_map); + } + } + + private void saveTeksToStorage(Map> teks) { + if (teks != null) { + Map storageTeks = new HashMap<>(); + for (Integer key : teks.keySet()) { + String storageKey = key != null ? Integer.toString(key) : null; + Map value = teks.get(key); + byte[] tek = (value != null) ? value.keySet().iterator().next() : null; + Integer expire = (value != null) ? value.get(tek) : null; + Map tekAndExpireTime = new HashMap<>(); + tekAndExpireTime.put(Utils.Base64.encode(tek), (expire != null ? Integer.toString(expire) : null)); + JSONObject jsonTekAndExpireTime = new JSONObject(tekAndExpireTime); + String storageValue = jsonTekAndExpireTime.toString(); + if (storageKey != null) { + storageTeks.put(storageKey, storageValue); + } + } + JSONObject teksJson = new JSONObject(storageTeks); + String teksString = teksJson.toString(); + Utils.BackupStorage.saveString(activityContext, Constants.EXPOSURE_TEKS_SHARED_PREFS_FILE_NAME, Constants.EXPOSURE_TEKS_SHARED_PREFS_KEY, teksString); + } else { + Utils.BackupStorage.remove(activityContext, Constants.EXPOSURE_TEKS_SHARED_PREFS_FILE_NAME, Constants.EXPOSURE_TEKS_SHARED_PREFS_KEY); + } + } + + private Map> loadTeksFromStorage() { + Log.d(TAG, "entering loadTeksFromStorage function"); + + //checking database version + boolean dataBaseChangeVersion = false; + String databaseVersion = Utils.BackupStorage.getString(activityContext, Constants.EXPOSURE_TEKS_SHARED_PREFS_FILE_NAME, Constants.EXPOSURE_TEK_VERSION); + if (Utils.Str.isEmpty(databaseVersion)) { + Log.d(TAG, "no database version found"); + dataBaseChangeVersion = true; + } else { + JSONObject jsonDatabaseVersion = null; + try { + jsonDatabaseVersion = new JSONObject(databaseVersion); + } catch (JSONException e) { + Log.e(TAG, "Failed to parse database version string to json!"); + e.printStackTrace(); + } + if (jsonDatabaseVersion != null) { + String version = jsonDatabaseVersion.optString(DATABASE_VERSION_KEY); + Log.d(TAG, "current TEK database version is " + version); + if (Utils.Str.isEmpty(version) || Integer.parseInt(version) != 2) { + dataBaseChangeVersion = true; + } + } + } + + if (dataBaseChangeVersion) { + Utils.BackupStorage.remove(activityContext, Constants.EXPOSURE_TEKS_SHARED_PREFS_FILE_NAME, Constants.EXPOSURE_TEKS_SHARED_PREFS_KEY); + } + + Map> teks = new HashMap<>(); + String teksString = Utils.BackupStorage.getString(activityContext, Constants.EXPOSURE_TEKS_SHARED_PREFS_FILE_NAME, Constants.EXPOSURE_TEKS_SHARED_PREFS_KEY); + if (!Utils.Str.isEmpty(teksString)) { + JSONObject teksJson = null; + try { + teksJson = new JSONObject(teksString); + } catch (JSONException e) { + Log.e(TAG, "Failed to parse TEKs string to json!"); + e.printStackTrace(); + } + if (teksJson != null) { + Iterator iterator = teksJson.keys(); + while (iterator.hasNext()) { + String storageKey = iterator.next(); + String storageValue = teksJson.optString(storageKey); + Map tekAndExpireTime = new HashMap<>(); + if (!Utils.Str.isEmpty(storageValue)) { + JSONObject jsonTekAndExpireTime = null; + try { + jsonTekAndExpireTime = new JSONObject(storageValue); + } catch (JSONException e) { + Log.e(TAG, "Failed to parse TEK map string to json!"); + e.printStackTrace(); + } + if (jsonTekAndExpireTime != null) { + Log.d(TAG, "LoadTEK: Found Nested Map"); + Iterator tekIterator = jsonTekAndExpireTime.keys(); + if (tekIterator.hasNext()) { + String tekString = tekIterator.next(); + String expireString = jsonTekAndExpireTime.optString(tekString); + tekAndExpireTime.put(Utils.Base64.decode(tekString), Integer.parseInt(expireString)); + } + } + } + teks.put(Integer.parseInt(storageKey), tekAndExpireTime); + } + } + } + + // update database version + + if (dataBaseChangeVersion) { + Map databaseVersionToStore = new HashMap<>(); + databaseVersionToStore.put(DATABASE_VERSION_KEY, Integer.toString(2)); + JSONObject jsonDatabaseVersion = new JSONObject(databaseVersionToStore); + String databaseVersionString = jsonDatabaseVersion.toString(); + Utils.BackupStorage.saveString(activityContext, Constants.EXPOSURE_TEKS_SHARED_PREFS_FILE_NAME, Constants.EXPOSURE_TEK_VERSION, databaseVersionString); + } + return teks; + } + + private List> getTeksList() { + List> teksList = new ArrayList<>(); + if ((i_TEK_map != null) && !i_TEK_map.isEmpty()) { + for (Integer tekKey : i_TEK_map.keySet()) { + long timestamp = tekKey.longValue() * RPI_REFRESH_INTERVAL_SECS * 1000; //in millis + Map tek = i_TEK_map.get(tekKey); + byte[] tekData = (tek != null) ? tek.keySet().iterator().next() : null; + String tekString = Utils.Base64.encode(tekData); + Integer expireInteger = (tek != null) ? tek.get(tekData) : null; + long expireTime = (expireInteger != null) ? expireInteger.longValue() * RPI_REFRESH_INTERVAL_SECS * 1000 : 0; //in millis + Map tekMap = new HashMap<>(); + tekMap.put("timestamp", timestamp); + tekMap.put("tek", tekString); + tekMap.put(Constants.EXPOSURE_PLUGIN_TEK_EXPIRE_PARAM_NAME, expireTime); + teksList.add(tekMap); + } + } + return teksList; + } + + private Map getRpisForTek(byte[] tek, long timestampInMillis, long expireTime) { + long timestampInSecs = timestampInMillis / 1000; + + long expireTimeInSecs = expireTime / 1000; + + int startENIntervalNumber = (int) (timestampInSecs / RPI_REFRESH_INTERVAL_SECS); + int endENIntervalNumber = (int) (expireTimeInSecs / RPI_REFRESH_INTERVAL_SECS); + Map rpiList = new HashMap<>(); + for (int intervalIndex = startENIntervalNumber; intervalIndex <= endENIntervalNumber; intervalIndex++) { + byte[] rpi = generateRpiForIntervalNumber(intervalIndex, tek); + String rpiString = Utils.Base64.encode(rpi); + rpiList.put(rpiString, (long) intervalIndex * RPI_REFRESH_INTERVAL_SECS * 1000); + } + return rpiList; + } + + private void notifyTek(byte[] tek, long timestamp, long expireTime) { + String tekString = Utils.Base64.encode(tek); + Map tekParams = new HashMap<>(); + tekParams.put(Constants.EXPOSURE_PLUGIN_TIMESTAMP_PARAM_NAME, timestamp); + tekParams.put(Constants.EXPOSURE_PLUGIN_TEK_PARAM_NAME, tekString); + tekParams.put(Constants.EXPOSURE_PLUGIN_TEK_EXPIRE_PARAM_NAME, expireTime); + invokeFlutterMethod(Constants.EXPOSURE_PLUGIN_TEK_METHOD_NAME, tekParams); + } + + //endregion + + //region Exposure Server + + private void bindExposureServer() { + Intent intent = new Intent(activityContext, ExposureServer.class); + activityContext.bindService(intent, serverConnection, Context.BIND_AUTO_CREATE); + } + + private void unBindExposureServer() { + activityContext.unbindService(serverConnection); + serverStarted = false; + } + + private ServiceConnection serverConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder service) { + exposureServer = ((ExposureServer.LocalServerBinder)service).getService(); + if (exposureServer == null) { + return; + } + serverStarted = true; + exposureServer.setCallback(serverCallback); + checkStarted(); + } + public void onServiceDisconnected(ComponentName className) { + serverStarted = false; + exposureServer = null; + } + }; + + private ExposureServer.Callback serverCallback = new ExposureServer.Callback() { + @Override + public void onRequestBluetoothOn() { + ExposurePlugin.this.requestBluetoothOn(); + } + }; + + //endregion + + //region Exposure Client + + private void bindExposureClient() { + Intent intent = new Intent(activityContext, ExposureClient.class); + activityContext.bindService(intent, clientConnection, Context.BIND_AUTO_CREATE); + } + + private void unBindExposureClient() { + activityContext.unbindService(clientConnection); + clientStarted = false; + } + + private ServiceConnection clientConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder service) { + androidExposureClient = ((ExposureClient.LocalBinder)service).getService(); + if (androidExposureClient == null) { + return; + } + androidExposureClient.initSettings(settings); + clientStarted = true; + androidExposureClient.setRpiCallback(clientRpiCallback); + checkStarted(); + } + public void onServiceDisconnected(ComponentName className) { + clientStarted = false; + androidExposureClient = null; + } + }; + + private ExposureClient.RpiCallback clientRpiCallback = new ExposureClient.RpiCallback() { + @Override + public void onRpiFound(byte[] rpi, int rssi, String address) { + if ((rpi != null) && (rssi != Constants.EXPOSURE_NO_RSSI_VALUE)) { + String rpiEncoded = Utils.Base64.encode(rpi); + Log.d(TAG, String.format(Locale.getDefault(), "onRpiFound: '%s' / rssi: %d", rpiEncoded, rssi)); + logAndroidExposure(rpiEncoded, rssi, address); + } + } + + @Override + public void onIOSDeviceFound(ScanResult scanResult) { + BluetoothDevice device = (scanResult != null) ? scanResult.getDevice() : null; + String devAddress = (device != null) ? device.getAddress() : null; + if (!Utils.Str.isEmpty(devAddress)) { + if (peripherals_bg.get(devAddress) == null) { + Log.d(TAG, ": ios fg attempting to connect"); + // new device discovered. + peripherals_bg.put(devAddress, device.connectGatt(activityContext, false, iOSBackgroundBluetoothGattCallback)); + } + logIosExposure(devAddress, scanResult.getRssi()); + } + } + }; + + //endregion + + //region iOS specific scanner/client + + private void startIosScan() { + startIosBgScan(); + } + + private void stopIosScan() { + stopIosBgScan(); + } + + //endregion + + //region iOS background specific scanner/client + + private void startIosBgScan() { + Log.d(TAG, "startIosBgScan()"); + try { + if (isIosBgScanning()) { + stopIosBgScan(); + } + if (iosBgBluetoothAdapter == null) { + Object systemService = activityContext.getSystemService(Context.BLUETOOTH_SERVICE); + if ((systemService instanceof BluetoothManager)) { + iosBgBluetoothAdapter = ((BluetoothManager) systemService).getAdapter(); + } + } + if (iosBgBluetoothAdapter == null) { + Log.d(TAG, "ios bg scan bluetooth adapter init failure"); + return; + } + if (iosBgBluoetoothLeScanner == null) { + iosBgBluoetoothLeScanner = iosBgBluetoothAdapter.getBluetoothLeScanner(); + } + if (iosBgBluoetoothLeScanner == null) { + Log.d(TAG, "ios bg scan bluetooth scanner init failure"); + return; + } + startIosBgScanTimer(); + ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder() + .setManufacturerData(iosbgManufacturerID, manufacturerData, manufacturerDataMask); + iosBgScanFilters = Collections.singletonList(scanFilterBuilder.build()); + + ScanSettings.Builder scanSettingsBuilder = new ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) + .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT); + iosBgScanSettings = scanSettingsBuilder.build(); + + iosBgBluoetoothLeScanner.startScan(iosBgScanFilters, iosBgScanSettings, iOSBackgroundScanCallback); + Log.d(TAG, "Start ios bg scan success!"); + } catch (Exception ex) { + Log.e(TAG, ex.toString()); + ex.printStackTrace(); + // re-try + startIosBgScan(); + } + } + + private void stopIosBgScan() { + stopIosBgScanTimer(); + if (isIosBgScanning()) { + iosBgBluoetoothLeScanner.stopScan(iOSBackgroundScanCallback); + Log.d(TAG, "stopped ios bg scanning"); + } else { + Log.d(TAG, "no ios bg scanner is scanning"); + } + iosBgBluoetoothLeScanner = null; + iosBgBluetoothAdapter = null; + iosBgScanFilters = null; + iosBgScanSettings = null; + } + + private boolean isIosBgScanning() { + return ((iosBgBluetoothAdapter != null) && (iosBgBluoetoothLeScanner != null) && + (iosBgScanFilters != null) && (iosBgScanSettings != null)); + } + + private void startIosBgScanTimer() { + stopIosBgScanTimer(); + + iosBgScanTimeoutRunnable = () -> { + Log.d(TAG, "scanning timeout, restarting scan"); + stopIosBgScan(); + // Restart the scan and timer + callbackHandler.postDelayed(this::startIosBgScan, 1_000); + }; + + mainHandler.postDelayed(iosBgScanTimeoutRunnable, 180_000); + } + + private void stopIosBgScanTimer() { + if (iosBgScanTimeoutRunnable != null) { + mainHandler.removeCallbacks(iosBgScanTimeoutRunnable); + iosBgScanTimeoutRunnable = null; + } + } + + private void disconnectIosBgPeripheral(String peripheralAddress) { + if (Utils.Str.isEmpty(peripheralAddress) || (peripherals_bg == null)) { + return; + } + BluetoothGatt ble_gatt = peripherals_bg.get(peripheralAddress); + if (ble_gatt == null) { + Log.d(TAG, "gatt does not exist in bg"); + return; + } + // clean up connection + ble_gatt.close(); + ble_gatt.disconnect(); + removeIosBgPeripheral(peripheralAddress); + } + + private void removeIosBgPeripheral(String address) { + if (Utils.Str.isEmpty(address) || (peripherals_bg == null) || (peripherals_bg.isEmpty())) { + return; + } + peripherals_bg.remove(address); + byte[] rpi = (peripheralsRPIs != null) ? peripheralsRPIs.get(address) : null; + if (rpi != null) { + peripheralsRPIs.remove(address); + } + if ((iosExposures == null) || iosExposures.isEmpty()) { + return; + } + ExposureRecord record = iosExposures.get(address); + if (record != null) { + iosExposures.remove(address); + updateExposuresTimer(); + } + if ((rpi != null) && (record != null)) { + String encodedRpi = Utils.Base64.encode(rpi); + notifyExposure(record, encodedRpi, true, address); + } + } + + private BluetoothGattCallback iOSBackgroundBluetoothGattCallback = new BluetoothGattCallback() { + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + BluetoothGatt a = peripherals_bg.get(gatt.getDevice().getAddress()); + if (newState == 2 && a != null) { + // seems that a delay is needed + int delay = 600; + ios_bg_handler.postDelayed(new Runnable() { + @Override + public void run() { + boolean result = gatt.discoverServices(); + if (!result) { + Log.d(TAG, "DiscoverServices failed to start"); + } + } + }, delay); + + } else if (newState == 0) { + peripherals_bg.remove(gatt.getDevice().getAddress()); + gatt.close(); + } + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + if (status == 0) { + BluetoothGatt _local_gatt = peripherals_bg.get(gatt.getDevice().getAddress()); + BluetoothGattService _local_service = (_local_gatt != null) ? _local_gatt.getService(Constants.EXPOSURE_UUID_SERVICE) : null; + if (_local_service == null) { + // disconnect? remove from dictionary + peripherals_bg.remove(gatt.getDevice().getAddress()); + gatt.close(); + } else { + // read characteristics from the service + // log ios devices. + BluetoothGattCharacteristic _local_characeristics = _local_service.getCharacteristic(Constants.EXPOSURE_UUID_CHARACTERISTIC); + _local_gatt.readCharacteristic(_local_characeristics); + } + } else { + Log.d(TAG, "service discovery failed.: " + status); + } + } + + @Override + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, + int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.d(TAG, "reading characteristics failed"); + return; + } + if (characteristic.getUuid().equals(Constants.EXPOSURE_UUID_CHARACTERISTIC)) { + byte[] val = characteristic.getValue(); + peripheralsRPIs.put(gatt.getDevice().getAddress(), val); + // copied from blessed image and modified + BluetoothGattDescriptor descriptor = characteristic.getDescriptor(UUID.fromString(CCC_DESCRIPTOR_UUID)); + if (descriptor == null) { + peripherals_bg.remove(gatt.getDevice().getAddress()); + gatt.close(); + return; + } + byte[] value; + int properties = characteristic.getProperties(); + if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) > 0) { + value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) > 0) { + value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + peripherals_bg.remove(gatt.getDevice().getAddress()); + gatt.close(); + return; + } + final byte[] finalValue = value; + // First set notification for Gatt object + // turn on or off + if (!gatt.setCharacteristicNotification(characteristic, true)) { + Log.d(TAG, "setCharacteristicNotification failed for characteristic: " + + characteristic.getUuid()); + } + // Then write to descriptor + descriptor.setValue(finalValue); + boolean result; + result = gatt.writeDescriptor(descriptor); + if (!result) { + peripherals_bg.remove(gatt.getDevice().getAddress()); + gatt.close(); + } + else{ + Log.d(TAG, "descriptor written successfully"); + } + } + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + if (characteristic.getUuid().equals(Constants.EXPOSURE_UUID_CHARACTERISTIC)) { + byte[] val = characteristic.getValue(); + String encoded = Utils.Base64.encode(val); + Log.d(TAG, "onCharacteristicChange: value: " + encoded + ", device address: " + gatt.getDevice().getAddress()); + peripheralsRPIs.put(gatt.getDevice().getAddress(), val); + } + } + }; + + private final ScanCallback iOSBackgroundScanCallback = new ScanCallback() { + + @Override + public void onScanFailed(int errorCode) { + Log.d(TAG, "iOSBackgroundScanCallback: onScanFailed: " + errorCode); + } + + @Override + public void onBatchScanResults(List results) { + Log.d(TAG, "iOSBackgroundScanCallback: onBatchScanResults: " + ((results != null) ? results.size() : 0)); + } + + @Override + public void onScanResult(int callbackType, ScanResult result) { + super.onScanResult(callbackType, result); + ScanRecord scanrecord = result.getScanRecord(); + List parcelUuids = (scanrecord != null) ? scanrecord.getServiceUuids() : null; + List serviceList = new ArrayList<>(); + if (parcelUuids != null) { + for (int i = 0; i < parcelUuids.size(); i++) { + UUID serviceUUID = parcelUuids.get(i).getUuid(); + if (!serviceList.contains(serviceUUID)) + serviceList.add(serviceUUID); + } + } else { + Log.d(TAG, "parcel UUID is null"); + } + BluetoothDevice device = result.getDevice(); + byte[] manData = (scanrecord != null) ? scanrecord.getManufacturerSpecificData(iosbgManufacturerID) : null; + if (manData != null) { + // 01 + if (manData.length >= 17) { + if (manData[0] == 0x01) { + if (((manData[15] >> 5) & 0x01) == 1) { + String devAddress = device.getAddress(); + // bg device discovered + // equivalently ondiscoverperipherals + if (peripherals_bg.get(devAddress) == null) { + // new device discovered. + peripherals_bg.put(devAddress, + device.connectGatt(activityContext, false, iOSBackgroundBluetoothGattCallback)); + + } + // log ios exposure + logIosExposure(device.getAddress(), result.getRssi()); + } + } + } + } + } + }; + + //endregion + + //region Flutter Start Result + + private void checkStarted() { + if (serverStarted && clientStarted) { + start(); + if (startedResult != null) { + MethodChannel.Result result = startedResult; + startedResult = null; + result.success(true); + } + } + } + + //endregion + + //region RPI timer + + private void startRpiTimer() { + long refreshIntervalInMillis = RPI_REFRESH_INTERVAL_SECS * 1000; + stopRpiTimer(); + rpiTimer = new Timer(); + rpiTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + refreshRpi(); + } + }, refreshIntervalInMillis, refreshIntervalInMillis); + } + + private void stopRpiTimer() { + if (rpiTimer != null) { + rpiTimer.cancel(); + } + rpiTimer = null; + } + + //endregion + + //region Exposures timer + + private void updateExposuresTimer() { + int exposuresCount = androidExposures.size() + iosExposures.size(); + if ((exposuresCount > 0) && (exposuresTimer == null)) { + exposuresTimer = new Timer(); + exposuresTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + processExposures(); + } + }, exposureProcessIntervalInMillis, exposureProcessIntervalInMillis); + } else if ((exposuresCount == 0) && (exposuresTimer != null)) { + exposuresTimer.cancel(); + exposuresTimer = null; + } + } + + //endregion + + //region MethodCall + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + String method = call.method; + try { + switch (method) { + case Constants.EXPOSURE_PLUGIN_METHOD_NAME_START: + Object settings = call.argument(Constants.EXPOSURE_PLUGIN_SETTINGS_PARAM_NAME); + handleStart(result, settings); // Result is handled on a latter step + break; + case Constants.EXPOSURE_PLUGIN_METHOD_NAME_STOP: + handleStop(); + result.success(true); + break; + case Constants.EXPOSURE_PLUGIN_METHOD_NAME_TEKS: + boolean removeTeks = Utils.Map.getValueFromPath(call.arguments, "remove", false); + if (removeTeks) { + saveTeksToStorage(null); + result.success(null); + } else { + List> teksList = getTeksList(); + result.success(teksList); + } + break; + case Constants.EXPOSURE_PLUGIN_METHOD_NAME_TEK_RPIS: + Object parameters = call.arguments; + String tekString = Utils.Map.getValueFromPath(parameters, Constants.EXPOSURE_PLUGIN_TEK_PARAM_NAME, null); + byte[] tek = Utils.Base64.decode(tekString); + long timestamp = Utils.Map.getValueFromPath(parameters, Constants.EXPOSURE_PLUGIN_TIMESTAMP_PARAM_NAME, -1L); + long expireTime = Utils.Map.getValueFromPath(parameters, Constants.EXPOSURE_PLUGIN_TEK_EXPIRE_PARAM_NAME, -1L); + Map rpis = getRpisForTek(tek, timestamp, expireTime); + result.success(rpis); + break; + case Constants.EXPOSURE_PLUGIN_METHOD_NAME_EXPIRE_TEK: + changeTekExpireTime(); + result.success(null); + break; + default: + result.success(null); + break; + + } + } catch (IllegalStateException exception) { + String errorMsg = String.format("Ignoring exception '%s'. See https://github.com/flutter/flutter/issues/29092 for details.", exception.toString()); + Log.e(TAG, errorMsg); + exception.printStackTrace(); + } + } + + private void invokeFlutterMethod(String methodName, Object arguments) { + if (methodChannel != null) { + // Run on the ui thread + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(() -> methodChannel.invokeMethod(methodName, arguments)); + } + } + + //endregion + + // region Bluetooth + + public void onLocationPermissionGranted() { + Log.d(TAG, "onLocationPermissionGranted"); + if (androidExposureClient != null) { + androidExposureClient.onLocationPermissionGranted(); + } + } + + private void requestBluetoothOn() { + Log.d(TAG, "requestBluetoothOn"); + + Utils.showDialog(activityContext, activityContext.getString(R.string.app_name), + activityContext.getString(R.string.exposure_request_bluetooth_on_message), + (dialog, which) -> { + //Turn bluetooth on + Utils.enabledBluetooth(); + + }, "Yes", + (dialog, which) -> { + }, "No", + true); + } + + //endregion + + //region Helpers + + private StringBuilder byte_to_hex(byte[] byte_array) { + StringBuilder sb = new StringBuilder(); + if (byte_array != null) { + for (byte b : byte_array) { + sb.append(String.format("%02X ", b)); + } + } + return sb; + } + + //endregion +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/ExposureRecord.java b/android/app/src/main/java/edu/illinois/covid/exposure/ExposureRecord.java new file mode 100644 index 00000000..d94f5adf --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/ExposureRecord.java @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure; + +import edu.illinois.covid.Constants; + +class ExposureRecord { + private long timestampCreated; + private long timestampUpdated; + private int lastRssi; + private long durationInterval; + + ExposureRecord(long timestamp, int rssi) { + this.timestampCreated = timestamp; + this.timestampUpdated = timestamp; + this.lastRssi = rssi; + this.durationInterval = 0; + } + + void updateTimeStamp(long timestamp, int rssi) { + int rssiMinValue = Constants.EXPOSURE_MIN_RSSI_VALUE; + if (ExposurePlugin.getInstance() != null) { + rssiMinValue = ExposurePlugin.getInstance().getExposureMinRssi(); + } + if ((rssiMinValue <= lastRssi) && (lastRssi != Constants.EXPOSURE_NO_RSSI_VALUE)) { + durationInterval += (timestamp - timestampUpdated); + } + lastRssi = rssi; + timestampUpdated = timestamp; + } + + /** + * @return duration in milliseconds + */ + long getDuration() { + return durationInterval; + } + + long getTimestampCreated() { + return timestampCreated; + } + + long getTimestampUpdated() { + return timestampUpdated; + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/ble/ExposureClient.java b/android/app/src/main/java/edu/illinois/covid/exposure/ble/ExposureClient.java new file mode 100644 index 00000000..fa1098b0 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/ble/ExposureClient.java @@ -0,0 +1,324 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure.ble; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.ScanRecord; +import android.bluetooth.le.ScanResult; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.util.Log; + +import java.util.concurrent.atomic.AtomicBoolean; + +import androidx.core.content.ContextCompat; +import edu.illinois.covid.Constants; +import edu.illinois.covid.R; +import edu.illinois.covid.Utils; +import edu.illinois.covid.exposure.ble.scan.OreoScanner; +import edu.illinois.covid.exposure.ble.scan.PreOreoScanner; + +public class ExposureClient extends Service { + private static final String TAG = "ExposureClient"; + + public class LocalBinder extends Binder { + public ExposureClient getService() { + return ExposureClient.this; + } + } + private final IBinder mBinder = new LocalBinder(); + + private BluetoothAdapter mBluetoothAdapter; + + private PreOreoScanner preOreoScanner; + private OreoScanner oreoScanner; + + private RpiCallback rpiCallback; + + private AtomicBoolean waitBluetoothOn = new AtomicBoolean(false); + private AtomicBoolean waitLocationPermissionGranted = new AtomicBoolean(false); + + private AtomicBoolean isScanning = new AtomicBoolean(false); + + private Object settings; + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onCreate() { + Object systemService = getSystemService(Context.BLUETOOTH_SERVICE); + if (systemService instanceof BluetoothManager) { + mBluetoothAdapter = ((BluetoothManager) systemService).getAdapter(); + } + if (mBluetoothAdapter == null) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + oreoScanner = new OreoScanner(getApplicationContext(), mBluetoothAdapter); + } else { + preOreoScanner = new PreOreoScanner(mBluetoothAdapter); + } + startForegroundClientService(); + IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED); + registerReceiver(bluetoothReceiver, filter); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.d(TAG, "onStartCommand - " + startId); + + if (intent != null) { + Bundle extras = intent.getExtras(); + if (extras != null) { + Object found = extras.get(Constants.EXPOSURE_BLE_DEVICE_FOUND); + if (found != null) { + if (found instanceof ScanResult) { + ScanResult scanResult = ((ScanResult) found); + onScanResultFound(scanResult); + } else { + Log.d(TAG, "found is not ScanResult"); + } + } else { + Log.d(TAG, "found is null"); + } + } else { + Log.d(TAG, "extras are null"); + } + } else { + Log.d(TAG, "The intent is null"); + } + return START_STICKY; + } + + @Override + public void onDestroy() { + stopForeground(true); + stopSelf(); + unregisterReceiver(bluetoothReceiver); + } + + public void setRpiCallback(RpiCallback rpiCallback) { + this.rpiCallback = rpiCallback; + } + + @SuppressLint("NewApi") + public void startScan() { + Log.d(TAG, "startScan"); + + //check if bluetooth is on + boolean needsWaitBluetooth = needsWaitBluetooth(); + if (needsWaitBluetooth) { + waitBluetoothOn.set(true); + return; + } + waitBluetoothOn.set(false); + + //check if location permission is granted + boolean needsWaitLocationPermission = needsWaitLocationPermission(); + if (needsWaitLocationPermission) { + waitLocationPermissionGranted.set(true); + return; + } + waitLocationPermissionGranted.set(false); + + startForegroundClientService(); + isScanning.set(true); + + if (preOreoScanner != null) { + preOreoScanner.startScan(new PreOreoScanner.ScannerCallback() { + @Override + public void onDevice(ScanResult result) { + super.onDevice(result); + onScanResultFound(result); + } + }); + } + if (oreoScanner != null) { + oreoScanner.startScan(); + } + } + + private boolean needsWaitBluetooth() { + if ((mBluetoothAdapter == null) || !mBluetoothAdapter.isEnabled()) { + Log.d(TAG, "processBluetoothCheck needs to wait for bluetooth"); + return true; + } else { + Log.d(TAG, "processBluetoothCheck - bluetooth ready"); + } + return false; + } + + private boolean needsWaitLocationPermission() { + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "needsWaitLocationPermission - location is not set"); + return true; + } else { + Log.d(TAG, "needsWaitLocationPermission - location ready"); + } + return false; + } + + @SuppressLint("NewApi") + public void stopScan() { + Log.d(TAG, "stopScan"); + + if (preOreoScanner != null) { + preOreoScanner.stopScan(); + } + if (oreoScanner != null) { + oreoScanner.stopScan(); + } + waitBluetoothOn.set(false); + waitLocationPermissionGranted.set(false); + + stopForeground(true); + stopSelf(); + isScanning.set(false); + } + + public void onLocationPermissionGranted() { + Log.d(TAG, "onLocationPermissionGranted"); + + //start the advertising if it waits for location + Handler handler = new Handler(Looper.getMainLooper()); + Runnable runnable = () -> { + if (waitLocationPermissionGranted.get()) { + startScan(); + } + }; + handler.postDelayed(runnable, 2000); + } + + public void initSettings(Object settings) { + this.settings = settings; + } + + private void onScanResultFound(ScanResult scanResult) { + if (scanResult == null) { + Log.d(TAG, "onScanResultFound: result is null"); + return; + } + ScanRecord scanRecord = scanResult.getScanRecord(); + if (scanRecord == null) { + Log.d(TAG, "onScanResultFound: ScanRecord is null for ScanResult: " + scanResult.toString()); + return; + } + + // Android - check service data + byte[] possibleRpi = scanRecord.getServiceData(Constants.EXPOSURE_PARCEL_SERVICE_UUID); + + if ((possibleRpi != null) && possibleRpi.length == Constants.EXPOSURE_CONTRACT_NUMBER_LENGTH) { + Log.d(TAG, "onScanResultFound: Bytes are found in Android device!"); + String rpiEncoded = Utils.Base64.encode(possibleRpi); + String deviceAddress = (scanResult.getDevice() != null ? scanResult.getDevice().getAddress() : ""); + Log.d(TAG, "onScanResultFound: rpiFound: " + rpiEncoded + " from device address: " + deviceAddress); + if (rpiCallback != null) { + rpiCallback.onRpiFound(possibleRpi, scanResult.getRssi(), deviceAddress); + } + } else if (possibleRpi == null) { + // this could be an ios device + Log.d(TAG, "onScanResultFound: might be ios: " + scanResult.getDevice().getAddress()); + rpiCallback.onIOSDeviceFound(scanResult); + } + } + + //region BroadcastReceiver + + private final BroadcastReceiver bluetoothReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + + if ((action != null) && action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) { + final int bluetoothState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, + BluetoothAdapter.ERROR); + if (bluetoothState == BluetoothAdapter.STATE_ON) { + Log.d(TAG, "Bluetooth is on"); + //start the advertising if it waits for bluetooth + Handler handler = new Handler(Looper.getMainLooper()); + Runnable runnable = () -> { + if (waitBluetoothOn.get()) { + startScan(); + } + }; + handler.postDelayed(runnable, 2000); + } + } + } + }; + + //endregion + + //region Foreground service + + private void createNotificationChannelIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = getString(R.string.app_name); + String description = getString(R.string.exposure_notification_channel_description); + int importance = NotificationManager.IMPORTANCE_DEFAULT; + NotificationChannel channel = new NotificationChannel( + NotificationCreator.getChannelId(), name, importance); + channel.setDescription(description); + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + NotificationManager notificationManager = getSystemService(NotificationManager.class); + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } + } + } + + private void startForegroundClientService() { + boolean exposureServiceLocalNotificationEnabled = Utils.Map.getValueFromPath(settings, "covid19ExposureServiceLocalNotificationEnabledAndroid", false); + if (exposureServiceLocalNotificationEnabled) { + createNotificationChannelIfNeeded(); + startForeground(NotificationCreator.getOngoingNotificationId(), + NotificationCreator.getNotification(this)); + } + } + + + //endregion + + //region RpiCallback + + public static class RpiCallback { + public void onRpiFound(byte[] rpi, int rssi, String address) {} + public void onIOSDeviceFound(ScanResult scanResult){} + } + + //endregion +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/ble/ExposureServer.java b/android/app/src/main/java/edu/illinois/covid/exposure/ble/ExposureServer.java new file mode 100644 index 00000000..a06fa94e --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/ble/ExposureServer.java @@ -0,0 +1,270 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure.ble; + +import android.app.Service; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.AdvertiseCallback; +import android.bluetooth.le.AdvertiseData; +import android.bluetooth.le.AdvertiseSettings; +import android.bluetooth.le.BluetoothLeAdvertiser; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Binder; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.ParcelUuid; +import android.util.Log; +import android.widget.Toast; + +import java.util.concurrent.atomic.AtomicBoolean; + +import androidx.annotation.Nullable; +import edu.illinois.covid.BuildConfig; +import edu.illinois.covid.Constants; + +public class ExposureServer extends Service { + + //region Member fields + + private static final String TAG = ExposureServer.class.getSimpleName(); + + public class LocalServerBinder extends Binder { + public ExposureServer getService() { + return ExposureServer.this; + } + } + + private final IBinder binder = new LocalServerBinder(); + + private BluetoothAdapter bluetoothAdapter; + + private AtomicBoolean isAdvertising = new AtomicBoolean(false); + private AtomicBoolean waitBluetoothOn = new AtomicBoolean(false); + + private byte[] rpi; + + private Callback callback; + + //endregion + + //region Service implementation + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public void onCreate() { + Object systemService = getSystemService(Context.BLUETOOTH_SERVICE); + if (systemService instanceof BluetoothManager) { + bluetoothAdapter = ((BluetoothManager) systemService).getAdapter(); + } + if (bluetoothAdapter == null) { + Log.d(TAG, "onCreate: bluetoothAdapter is null"); + return; + } + IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED); + registerReceiver(bluetoothReceiver, filter); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { return START_STICKY; } + + @Override + public void onDestroy() { + stopAdvertising(); + unregisterReceiver(bluetoothReceiver); + } + + //endregion + + //region Public APIs + + public void start() { + Log.d(TAG, "start server"); + + if (bluetoothAdapter == null) { + Log.d(TAG, "start - bluetoothAdapter is null"); + return; + } + + // ask for bluetooth if not set + if (!bluetoothAdapter.isEnabled()) { + if (callback != null) + callback.onRequestBluetoothOn(); + } + startAdvertising(); + } + + public void stop() { + Log.d(TAG, "stop server"); + stopAdvertising(); + } + + public void setRpi(byte[] rpi) { + Log.d(TAG, "set rpi"); + this.rpi = rpi; + if (isAdvertising.get()) { + stopAdvertising(); + startAdvertising(); + } + } + + public void setCallback(Callback callback) { + this.callback = callback; + } + + //endregion + + //region Advertising + + private void startAdvertising() { + if ((rpi == null)) { + Log.d(TAG, "startAdvertising: rpi is null! Advertising not started!"); + return; + } + if (!bluetoothAdapter.isEnabled()) { + Log.d(TAG, "startAdvertising: wait for bluetooth to be enabled"); + waitBluetoothOn.set(true); + return; + } + waitBluetoothOn.set(false); + + BluetoothLeAdvertiser advertiser = bluetoothAdapter.getBluetoothLeAdvertiser(); + if (advertiser == null) { + Log.w(TAG, "Device does not support BLE advertisement"); + showToast("Exposure: This device does not support BLE advertisement"); + return; + } + showToast("Exposure: Start advertising"); + + // Use try catch to handle DeadObject exception + try { + AdvertiseSettings.Builder settingsBuilder = new AdvertiseSettings.Builder(); + settingsBuilder.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED); + settingsBuilder.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM); + settingsBuilder.setConnectable(true); + settingsBuilder.setTimeout(0); + + ParcelUuid parceluuid = new ParcelUuid(Constants.EXPOSURE_UUID_SERVICE); + AdvertiseData.Builder dataBuilder = new AdvertiseData.Builder(); + dataBuilder.setIncludeDeviceName(false); + dataBuilder.addServiceUuid(parceluuid); + dataBuilder.addServiceData(parceluuid, rpi); + + AdvertiseSettings advertiseSettings = settingsBuilder.build(); + AdvertiseData advertiseData = dataBuilder.build(); + advertiser.startAdvertising(advertiseSettings, advertiseData, advertiseCallback); + } catch (Exception ex) { + String errMsg = "Exposure: start advertising failed!"; + Log.e(TAG, errMsg); + ex.printStackTrace(); + showToast(errMsg); + // re-try + startAdvertising(); + } + } + + private void stopAdvertising() { + showToast("Exposure: Stop advertising"); + waitBluetoothOn.set(false); + if (bluetoothAdapter != null) { + BluetoothLeAdvertiser advertiser = bluetoothAdapter.getBluetoothLeAdvertiser(); + if (advertiser != null) { + advertiser.stopAdvertising(advertiseCallback); + } + } + stopForeground(true); + isAdvertising.set(false); + } + + private AdvertiseCallback advertiseCallback = new AdvertiseCallback() { + + @Override + public void onStartSuccess(AdvertiseSettings settingsInEffect) { + super.onStartSuccess(settingsInEffect); + Log.d(TAG, "AdvertiseCallback onStartSuccess "); + + showToast("Exposure: Start advertising Succeed"); + + isAdvertising.set(true); + } + + @Override + public void onStartFailure(int errorCode) { + super.onStartFailure(errorCode); + Log.e(TAG, "AdvertiseCallback onStartFailure " + errorCode); + + showToast("Exposure: Start advertising failed " + errorCode); + + isAdvertising.set(false); + } + }; + + /** + * Show toasts only for DEBUG builds + * @param message the message that has to be shown + */ + private void showToast(String message) { + if (BuildConfig.DEBUG) { + new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(getApplicationContext(), message, Toast.LENGTH_SHORT).show()); + } + } + + //endregion + + //region Bluetooth Receiver + + private final BroadcastReceiver bluetoothReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + + if ((action != null) && action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) { + final int bluetoothState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR); + if (bluetoothState == BluetoothAdapter.STATE_ON) { + Log.d(TAG, "Bluetooth is on"); + //start the advertising if it waits for bluetooth + Handler handler = new Handler(Looper.getMainLooper()); + Runnable runnable = () -> { + if (waitBluetoothOn.get()) { + startAdvertising(); + } + }; + handler.postDelayed(runnable, 2000); + } + } + } + }; + + //endregion + + //region Exposure Server Callback + + public static class Callback { + public void onRequestBluetoothOn() {} + } + + //endregion +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/ble/NotificationCreator.java b/android/app/src/main/java/edu/illinois/covid/exposure/ble/NotificationCreator.java new file mode 100644 index 00000000..ec77f62f --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/ble/NotificationCreator.java @@ -0,0 +1,47 @@ +package edu.illinois.covid.exposure.ble; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import androidx.core.app.NotificationCompat; +import edu.illinois.covid.MainActivity; +import edu.illinois.covid.R; + +class NotificationCreator { + private static final int ONGOING_NOTIFICATION_ID = 1; + private static final String CHANNEL_ID = "RokwireContactTracingNotificationChannel"; + private static Notification notification; + + static Notification getNotification(Context context) { + if (context != null) { + if (notification == null) { + Intent notificationIntent = new Intent(context, MainActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity( + context, + 0, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(context.getString(R.string.exposure_notification_title)) + .setContentText(context.getString(R.string.exposure_notification_message)) + .setSmallIcon(R.drawable.app_icon) + .setContentIntent(pendingIntent) + .setTicker(context.getString(R.string.exposure_notification_ticker)); + + notification = builder.build(); + } + } + return notification; + } + + static String getChannelId() { + return CHANNEL_ID; + } + + static int getOngoingNotificationId() { + return ONGOING_NOTIFICATION_ID; + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/ExposureBleReceiver.java b/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/ExposureBleReceiver.java new file mode 100644 index 00000000..c86fae37 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/ExposureBleReceiver.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure.ble.scan; + +import android.annotation.TargetApi; +import android.bluetooth.le.ScanResult; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +import java.util.ArrayList; + +import edu.illinois.covid.Constants; +import edu.illinois.covid.exposure.ble.ExposureClient; + +public class ExposureBleReceiver extends BroadcastReceiver { + + private static final String TAG = "ExposureBleReceiver"; + + @TargetApi(Build.VERSION_CODES.O) + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) { + Log.d(TAG, "onReceive - intent is null"); + return; + } + Log.d(TAG, "onReceive - " + intent.getAction()); + if (Constants.EXPOSURE_BLE_ACTION_FOUND.equals(intent.getAction())) { + ScanResult scanResult = extractData(intent.getExtras()); + if (scanResult == null) { + Log.d(TAG, "The scan result is null"); + } + Intent bleClientIntent = new Intent(context, ExposureClient.class); + bleClientIntent.putExtra(Constants.EXPOSURE_BLE_DEVICE_FOUND, scanResult); + context.startService(bleClientIntent); + } + } + + private ScanResult extractData(Bundle extras) { + if (extras != null) { + Object list = extras.get("android.bluetooth.le.extra.LIST_SCAN_RESULT"); + if (list != null) { + ArrayList l = (ArrayList) list; + if (l.size() > 0) { + Object firstItem = l.get(0); + if (firstItem instanceof ScanResult) { + return (ScanResult) firstItem; + } else { + Log.d(TAG, "first item is not ScanResult"); + } + } else { + Log.d(TAG, "list is empty"); + } + } else { + Log.d(TAG, "list is null"); + } + } else { + Log.d(TAG, "extras are null"); + } + return null; + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/OreoScanner.java b/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/OreoScanner.java new file mode 100644 index 00000000..cea2acae --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/OreoScanner.java @@ -0,0 +1,91 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure.ble.scan; + +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanSettings; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.ParcelUuid; +import android.util.Log; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import androidx.annotation.RequiresApi; +import edu.illinois.covid.Constants; + +public class OreoScanner { + + private static final String TAG = "OreoScanner"; + + private Context context; + private BluetoothAdapter bluetoothAdapter; + + private PendingIntent pendingIntent; + + public OreoScanner(Context context, BluetoothAdapter bluetoothAdapter) { + this.context = context; + this.bluetoothAdapter = bluetoothAdapter; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + public void startScan() { + Log.d(TAG, "Started scan"); + try { + ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder(); + scanFilterBuilder.setServiceUuid(new ParcelUuid(Constants.EXPOSURE_UUID_SERVICE)); + List scanFilters = Collections.singletonList(scanFilterBuilder.build()); + long reportDelay = ((bluetoothAdapter != null) && bluetoothAdapter.isOffloadedScanBatchingSupported()) ? 5 : 0; + + ScanSettings.Builder scanSettingsBuilder = new ScanSettings.Builder(). + setScanMode(ScanSettings.SCAN_MODE_LOW_POWER). + setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES). + setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE). + setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT). + setPhy(ScanSettings.PHY_LE_ALL_SUPPORTED). + setReportDelay(TimeUnit.SECONDS.toMillis(reportDelay)). + setLegacy(true); + + ScanSettings scanSettings = scanSettingsBuilder.build(); + + Intent intent = new Intent(context, ExposureBleReceiver.class); + intent.setAction(Constants.EXPOSURE_BLE_ACTION_FOUND); + pendingIntent = PendingIntent.getBroadcast(context, 2, intent, PendingIntent.FLAG_UPDATE_CURRENT); + if (bluetoothAdapter != null) { + bluetoothAdapter.getBluetoothLeScanner().startScan(scanFilters, scanSettings, pendingIntent); + } + } catch (Exception ex) { + Log.e(TAG, "Start scan failed:"); + ex.printStackTrace(); + //re-try + startScan(); + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + public void stopScan() { + if ((pendingIntent != null) && (bluetoothAdapter != null)) { + bluetoothAdapter.getBluetoothLeScanner().stopScan(pendingIntent); + pendingIntent = null; + } + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/PreOreoScanner.java b/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/PreOreoScanner.java new file mode 100644 index 00000000..6256c8ed --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/ble/scan/PreOreoScanner.java @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure.ble.scan; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.os.ParcelUuid; +import android.util.Log; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import edu.illinois.covid.Constants; + +public class PreOreoScanner { + + private static final String TAG = PreOreoScanner.class.getSimpleName(); + + private BluetoothAdapter bluetoothAdapter; + + private ScannerCallback discoverCallback; + + public PreOreoScanner(BluetoothAdapter bluetoothAdapter) { + this.bluetoothAdapter = bluetoothAdapter; + } + + public void startScan(ScannerCallback callback) { + discoverCallback = callback; + + // Use try catch to handle DeadObject exception + try { + ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder(); + scanFilterBuilder.setServiceUuid(new ParcelUuid(Constants.EXPOSURE_UUID_SERVICE)); + List scanFilters = Collections.singletonList(scanFilterBuilder.build()); + long reportDelay = ((bluetoothAdapter != null) && bluetoothAdapter.isOffloadedScanBatchingSupported()) ? 5 : 0; + + ScanSettings.Builder scanSettingsBuilder = new ScanSettings.Builder(). + setScanMode(ScanSettings.SCAN_MODE_LOW_POWER). + setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES). + setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE). + setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT). + setReportDelay(TimeUnit.SECONDS.toMillis(reportDelay)); + + ScanSettings scanSettings = scanSettingsBuilder.build(); + if (bluetoothAdapter != null) { + bluetoothAdapter.getBluetoothLeScanner().startScan(scanFilters, scanSettings, scanCallback); + } + Log.d(TAG, "Started scan"); + } catch (Exception ex) { + Log.e(TAG, "Start scan failed:"); + ex.printStackTrace(); + // re-try + startScan(callback); + } + } + + public void stopScan() { + if (discoverCallback != null) { + bluetoothAdapter.getBluetoothLeScanner().stopScan(scanCallback); + discoverCallback = null; + } + } + + private void onResult(final ScanResult result) { + if (discoverCallback != null) + discoverCallback.onDevice(result); + } + + private ScanCallback scanCallback = new ScanCallback() { + @Override + public void onScanResult(int callbackType, final ScanResult result) { + super.onScanResult(callbackType, result); + onResult(result); + } + + @Override + public void onBatchScanResults(List results) { + super.onBatchScanResults(results); + for (ScanResult result : results) { + onResult(result); + } + } + + @Override + public void onScanFailed(int errorCode) { + super.onScanFailed(errorCode); + Log.e(TAG, "onScanFailed errorCode = " + errorCode); + if (errorCode == SCAN_FAILED_APPLICATION_REGISTRATION_FAILED) { + // re-try + startScan(discoverCallback); + } + } + + }; + + //ScannerCallback + + public static abstract class ScannerCallback { + public void onDevice(ScanResult result) { + } + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/crypto/AES.java b/android/app/src/main/java/edu/illinois/covid/exposure/crypto/AES.java new file mode 100644 index 00000000..3ebf6eec --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/crypto/AES.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure.crypto; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +public class AES { + + private static SecretKeySpec secretKey; + private static byte[] key; + + public static void setKey(byte[] myKey) { + key = myKey; + secretKey = new SecretKeySpec(key, "AES"); + } + + public static byte[] encrypt(byte[] strToEncrypt, byte[] secret) { + try { + setKey(strToEncrypt); + Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + return (cipher.doFinal(secret)); + } catch (Exception e) { + System.out.println("Error while encrypting: " + e.toString()); + } + return null; + } + + public static byte[] decrypt(byte[] strToDecrypt, byte[] secret) { + try { + setKey(strToDecrypt); + Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + return (cipher.doFinal(secret)); + } catch (Exception e) { + System.out.println("Error while decrypting: " + e.toString()); + } + return null; + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/exposure/crypto/AES_CTR.java b/android/app/src/main/java/edu/illinois/covid/exposure/crypto/AES_CTR.java new file mode 100644 index 00000000..2d75dc9d --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/exposure/crypto/AES_CTR.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.exposure.crypto; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class AES_CTR { + public static String ALGORITHM = "AES"; + private static String AES_CBS_PADDING = "AES/CTR/NoPadding"; + + public static byte[] encrypt(final byte[] key, final byte[] IV, final byte[] message) throws Exception { + return AES_CTR.encryptDecrypt(Cipher.ENCRYPT_MODE, key, IV, message); + } + + public static byte[] decrypt(final byte[] key, final byte[] IV, final byte[] message) throws Exception { + return AES_CTR.encryptDecrypt(Cipher.DECRYPT_MODE, key, IV, message); + } + + private static byte[] encryptDecrypt(final int mode, final byte[] key, final byte[] IV, final byte[] message) + throws Exception { + final Cipher cipher = Cipher.getInstance(AES_CBS_PADDING); + final SecretKeySpec keySpec = new SecretKeySpec(key, ALGORITHM); + final IvParameterSpec ivSpec = new IvParameterSpec(IV); + cipher.init(mode, keySpec, ivSpec); + return cipher.doFinal(message); + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/gallery/GalleryPlugin.java b/android/app/src/main/java/edu/illinois/covid/gallery/GalleryPlugin.java new file mode 100644 index 00000000..d8b9de24 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/gallery/GalleryPlugin.java @@ -0,0 +1,203 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.gallery; + + +import android.content.ContentValues; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Environment; +import android.provider.MediaStore; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; + +import edu.illinois.covid.Constants; +import edu.illinois.covid.MainActivity; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry; + +public class GalleryPlugin implements MethodChannel.MethodCallHandler { + + private static final String TAG = "GalleryPlugin"; + public static int STORAGE_PERMISSION_REQUEST_CODE = 100; + + private static GalleryPlugin instance; + private final MainActivity activityContext; + private final MethodChannel methodChannel; + + // Temp storage between permission request + private byte[] bytes; + private String name; + private MethodChannel.Result channelResult; + + public static GalleryPlugin registerWith(PluginRegistry.Registrar registrar) { + final MethodChannel channel = new MethodChannel(registrar.messenger(), "edu.illinois.covid/gallery"); + GalleryPlugin galleryPlugin = new GalleryPlugin((MainActivity) registrar.activity(), channel); + channel.setMethodCallHandler(galleryPlugin); + if (instance == null) { + instance = galleryPlugin; + } + return galleryPlugin; + } + + private GalleryPlugin(MainActivity activity, MethodChannel methodChannel) { + this.activityContext = activity; + this.methodChannel = methodChannel; + this.methodChannel.setMethodCallHandler(this); + + } + + private void handleStore() { + try { + if (hasWriteStoragePermission()) { + handleStore(this.bytes, this.name, this.channelResult, false); + } + } finally { + clearCache(); + } + } + + private void handleStore(byte[] bytes, String name, @NonNull MethodChannel.Result result) { + handleStore(bytes, name, result, true); + } + + private void handleStore(byte[] bytes, String name, @NonNull MethodChannel.Result result, boolean performedPermissionCheck) { + if (performedPermissionCheck) { + if(!hasWriteStoragePermission()) { + this.bytes = bytes; + this.name = name; + this.channelResult = result; + requestWriteStoragePermission(); + return; + } + } + else { + if(!hasWriteStoragePermission()){ + result.success(Boolean.FALSE); + return; + } + } + + if (android.os.Build.VERSION.SDK_INT >= 29) { + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/"); + values.put(MediaStore.Images.Media.IS_PENDING, true); + Uri uri = activityContext.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + if (uri != null) { + saveBytesToUri(bytes, uri); + values.put(MediaStore.Images.Media.IS_PENDING, false); + activityContext.getContentResolver().update(uri, values, null, null); + result.success(Boolean.TRUE); + return; + } + } else { + String storagePath = Environment.getExternalStorageDirectory().toString(); + storagePath = storagePath.endsWith("/") ? storagePath : storagePath + "/"; + File directory = new File( storagePath + "Pictures/"); + if (!directory.exists()) { + directory.mkdirs(); + } + String fileName = System.currentTimeMillis() + ".png"; + File file = new File(directory, fileName); + saveBytesToUri(bytes, Uri.fromFile(file)); + if (file.getAbsolutePath() != null) { + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.DATA, file.getAbsolutePath()); + activityContext.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + result.success(Boolean.TRUE); + return; + } + } + result.success(Boolean.FALSE); + } + + private void saveBytesToUri(byte[] bytes, Uri uri){ + if(bytes != null && uri != null) { + OutputStream out = null; + try { + out = activityContext.getContentResolver().openOutputStream(uri); + if (out != null) { + out.write(bytes); + } + } catch (Exception e) { + Log.e(TAG, String.format("Error on write %s", uri.toString()), e); + } + finally { + if(out != null){ + try { + out.flush(); + } catch (IOException e) { + Log.e(TAG, String.format("Error on write %s", uri.toString()), e); + } + try { + out.close(); + } catch (IOException e) { + Log.e(TAG, String.format("Error on write %s", uri.toString()), e); + } + } + } + } + } + + private void clearCache(){ + this.bytes = null; + this.name = null; + this.channelResult = null; + } + + private boolean hasWriteStoragePermission() { + return activityContext.checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + } + + private void requestWriteStoragePermission() { + activityContext.requestPermissions(new String[] {android.Manifest.permission.WRITE_EXTERNAL_STORAGE}, STORAGE_PERMISSION_REQUEST_CODE); + } + + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if(requestCode == STORAGE_PERMISSION_REQUEST_CODE) { + for (int i = 0; i < permissions.length && i < grantResults.length; i++) { + String permission = permissions[i]; + int result = grantResults[i]; + if (android.Manifest.permission.WRITE_EXTERNAL_STORAGE.equals(permission)){ + handleStore(); + } + } + } + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + String method = call.method; + try { + switch (method) { + case Constants.GALLERY_PLUGIN_METHOD_NAME_STORE: + byte[] bytes = call.argument(Constants.GALLERY_PLUGIN_PARAM_BYTES); + String name = call.argument(Constants.GALLERY_PLUGIN_PARAM_NAME); + handleStore(bytes, name, result); // Result is handled on a latter step + break; + } + } catch (Exception e){ + Log.e(TAG, "Error on reading command", e); + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/edu/illinois/covid/maps/MapActivity.java b/android/app/src/main/java/edu/illinois/covid/maps/MapActivity.java new file mode 100644 index 00000000..4a1cd126 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/maps/MapActivity.java @@ -0,0 +1,516 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.maps; + +import android.graphics.Color; +import android.location.Location; +import android.os.Bundle; +import android.os.Looper; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationCallback; +import com.google.android.gms.location.LocationResult; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.model.CameraPosition; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.mapsindoors.mapssdk.MPPositionResult; +import com.mapsindoors.mapssdk.MapControl; +import com.mapsindoors.mapssdk.MapsIndoors; +import com.mapsindoors.mapssdk.OnPositionUpdateListener; +import com.mapsindoors.mapssdk.OnStateChangedListener; +import com.mapsindoors.mapssdk.PermissionsAndPSListener; +import com.mapsindoors.mapssdk.Point; +import com.mapsindoors.mapssdk.PositionProvider; +import com.mapsindoors.mapssdk.PositionResult; +import com.mapsindoors.mapssdk.errors.MIError; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Timer; +import java.util.TimerTask; + +import edu.illinois.covid.Constants; +import edu.illinois.covid.R; +import edu.illinois.covid.Utils; + +public class MapActivity extends AppCompatActivity implements PositionProvider { + //region Class fields + + //Google Maps + private SupportMapFragment mapFragment; + protected GoogleMap googleMap; + + //Android Location + private FusedLocationProviderClient fusedLocationClient; + protected Location coreLocation; + private com.google.android.gms.location.LocationRequest coreLocationRequest; + private LocationCallback coreLocationCallback; + + //Location timer + private Timer locationTimer; + protected long locationTimestamp; + + //MapsIndoors + protected MapControl mapControl; + protected MPPositionResult mpPositionResult; + private boolean isRunning; + private OnPositionUpdateListener mpPositionUpdateListener; + + private boolean firstLocationUpdatePassed; + private HashMap target; + private HashMap options; + private ArrayList markers; + private TextView debugStatusView; + private boolean showDebugLocation; + + //endregion + + //region Activity methods + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.map_layout); + + initHeaderBar(); + initParameters(); + initUiViews(); + initCoreLocation(); + initMap(); + } + + @Override + protected void onStart() { + super.onStart(); + if (mapControl != null) { + mapControl.onStart(); + } + startMonitor(); + } + + @Override + protected void onStop() { + super.onStop(); + if (mapControl != null) { + mapControl.onStop(); + } + stopMonitor(); + } + + @Override + protected void onResume() { + super.onResume(); + if (mapControl != null) { + mapControl.onResume(); + } + } + + @Override + protected void onPause() { + super.onPause(); + if (mapControl != null) { + mapControl.onPause(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mapControl != null) { + mapControl.onDestroy(); + } + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + if (mapControl != null) { + mapControl.onLowMemory(); + } + } + + /** + * Handle up (back) navigation button clicked + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + onBackPressed(); + return true; + } + + //endregion + + //region Common initialization + + private void initHeaderBar() { + setSupportActionBar(findViewById(R.id.toolbar)); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayShowTitleEnabled(false); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } + } + + private void initParameters() { + Serializable targetSerializable = getIntent().getSerializableExtra("target"); + if (targetSerializable instanceof HashMap) { + this.target = (HashMap) targetSerializable; + } + Serializable optionsSerializable = getIntent().getSerializableExtra("options"); + if (optionsSerializable instanceof HashMap) { + this.options = (HashMap) optionsSerializable; + } + Serializable markersSerializable = getIntent().getSerializableExtra("markers"); + if (markersSerializable instanceof List) { + this.markers = (ArrayList) markersSerializable; + } + } + + protected void initUiViews() { + showDebugLocation = Utils.Map.getValueFromPath(options, "showDebugLocation", false); + if (showDebugLocation) { + debugStatusView = findViewById(R.id.debugStatusTextView); + debugStatusView.setVisibility(View.VISIBLE); + } + } + + //endregion + + //region Map views initialization + + private void initMap() { + mapFragment = ((SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map_fragment)); + if (mapFragment != null) { + mapFragment.getMapAsync(this::didGetMapAsync); + } + } + + private void didGetMapAsync(GoogleMap map) { + googleMap = map; + double latitude = Utils.Map.getValueFromPath(target, "latitude", Constants.DEFAULT_INITIAL_CAMERA_POSITION.latitude); + double longitude = Utils.Map.getValueFromPath(target, "longitude", Constants.DEFAULT_INITIAL_CAMERA_POSITION.longitude); + double zoom = Utils.Map.getValueFromPath(target, "zoom", Constants.DEFAULT_CAMERA_ZOOM); + googleMap.moveCamera(CameraUpdateFactory.newCameraPosition(CameraPosition.fromLatLngZoom(new LatLng(latitude, longitude), (float)zoom))); + initMapControl(); + } + + private void initMapControl() { + mapControl = new MapControl(this); + mapControl.setGoogleMap(googleMap, mapFragment.getView()); + MapsIndoors.setPositionProvider(this); + mapControl.showUserPosition(true); + mapControl.setOnFloorUpdateListener((building, i) -> onFloorChanged(i)); + mapControl.setOnMarkerClickListener(this::onMarkerClicked); + mapControl.addOnCameraIdleListener(this::onCameraIdle); + mapControl.init(this::mapControlDidInit); + } + + private void mapControlDidInit(MIError error) { + Log.d(getLogTag(), "mapControlDidInit()"); + runOnUiThread(() -> { + if (error == null) { + afterMapControlInitialized(); + } else { + Log.d(getLogTag(), error.message); + } + }); + } + + protected void afterMapControlInitialized() { + mapControl.selectFloor(0); + boolean hideLevels = Utils.Map.getValueFromPath(options, "hideLevels", false); + mapControl.enableFloorSelector(!hideLevels); + startPositioning(null); + fillMarkers(); + } + + private void fillMarkers(){ + if(markers!=null && !markers.isEmpty()){ + for(HashMap markerData: markers){ + Object latVal = markerData.get("latitude"); + Object lngVal = markerData.get("longitude"); + + double lat = latVal instanceof Double? (double)latVal : + latVal instanceof Integer? Double.valueOf((int)latVal) : 0; + double lng = lngVal instanceof Double? (double)lngVal : + lngVal instanceof Integer? Double.valueOf((int)lngVal) : 0; + String name = markerData.containsKey("name")?(String) markerData.get("name") : ""; + String description = markerData.containsKey("description") && markerData.get("description")!=null?(String) markerData.get("description") : ""; + + googleMap.addMarker(new MarkerOptions() + .position(new LatLng(lat, lng)) + .title(name).snippet(description)).showInfoWindow(); + } + } + } + + //endregion + + //region MapsIndoors + + protected void onFloorChanged(int floor) { + Log.d(getLogTag(), "MapControl.onFloorUpdate: " + floor); + } + + protected void onCameraIdle() { + Log.d(getLogTag(), "MapControl.onCameraIdle"); + } + + protected boolean onMarkerClicked(Marker marker) { + Log.d(getLogTag(), "MapControl.onMarkerClicked"); + return false; + } + + /** + * PositionProvider interface + */ + + @NonNull + @Override + public String[] getRequiredPermissions() { + return new String[0]; + } + + @Override + public boolean isPSEnabled() { + return true; + } + + @Override + public void startPositioning(@Nullable String s) { + startMonitor(); + } + + @Override + public void stopPositioning(@Nullable String s) { + stopMonitor(); + } + + @Override + public boolean isRunning() { + return isRunning; + } + + @Override + public void addOnPositionUpdateListener(@Nullable OnPositionUpdateListener onPositionUpdateListener) { + this.mpPositionUpdateListener = onPositionUpdateListener; + } + + @Override + public void removeOnPositionUpdateListener(@Nullable OnPositionUpdateListener onPositionUpdateListener) { + this.mpPositionUpdateListener = null; + } + + @Override + public void setProviderId(@Nullable String s) { + Log.d(getLogTag(), "PositionProvider.setProviderId"); + } + + @Override + public void addOnStateChangedListener(@Nullable OnStateChangedListener onStateChangedListener) { + Log.d(getLogTag(), "PositionProvider.addOnStateChangedListener"); + } + + @Override + public void removeOnStateChangedListener(@Nullable OnStateChangedListener onStateChangedListener) { + Log.d(getLogTag(), "PositionProvider.removeOnStateChangedListener"); + } + + @Override + public void checkPermissionsAndPSEnabled(PermissionsAndPSListener permissionsAndPSListener) { + Log.d(getLogTag(), "PositionProvider.checkPermissionsAndPSEnabled"); + } + + @Nullable + @Override + public String getProviderId() { + Log.d(getLogTag(), "PositionProvider.getProviderId"); + return null; + } + + @Nullable + @Override + public PositionResult getLatestPosition() { + return mpPositionResult; + } + + @Override + public void startPositioningAfter(int i, @Nullable String s) { + new Timer().schedule(new TimerTask() { + @Override + public void run() { + startPositioning(s); + } + }, i); + } + + @Override + public void terminate() { + Log.d(getLogTag(), "PositionProvider.terminate"); + } + + //endregion + + //region Core Location + + private void initCoreLocation() { + fusedLocationClient = LocationServices.getFusedLocationProviderClient(this); + createCoreLocationCallback(); + createCoreLocationRequest(); + } + + private void notifyCoreLocationUpdate() { + if (coreLocation != null) { + Point coreLocationPoint = new Point(coreLocation.getLatitude(), coreLocation.getLongitude(), 0); + MPPositionResult positionResult = new MPPositionResult(coreLocationPoint, 0, 0, 0); + positionResult.setProvider(this); + notifyLocationUpdate(positionResult, coreLocation.getTime()); + } + } + + private void createCoreLocationRequest() { + coreLocationRequest = com.google.android.gms.location.LocationRequest.create(); + coreLocationRequest.setInterval(60000); //in millis + coreLocationRequest.setFastestInterval(30000); //in millis + coreLocationRequest.setPriority(com.google.android.gms.location.LocationRequest.PRIORITY_HIGH_ACCURACY); + } + + private void createCoreLocationCallback() { + coreLocationCallback = new LocationCallback() { + @Override + public void onLocationResult(LocationResult locationResult) { + if (locationResult == null) { + return; + } + for (Location location : locationResult.getLocations()) { + coreLocation = location; + } + notifyCoreLocationUpdate(); + } + }; + } + + //endregion + + //region Common Location + + protected void notifyLocationUpdate(MPPositionResult positionResult, long timestamp) { + if (positionResult != null) { + mpPositionResult = positionResult; + locationTimestamp = timestamp; + if (mpPositionUpdateListener != null) { + mpPositionUpdateListener.onPositionUpdate(positionResult); + } + if (!firstLocationUpdatePassed) { + firstLocationUpdatePassed = true; + handleFirstLocationUpdate(); + } + if ((debugStatusView != null) && showDebugLocation) { + String sourceAbbr = "CL"; + int sourceColor = Color.rgb(0, 126, 0); + double lat = 0.0d; + double lng = 0.0d; + int floor = 0; + if (mpPositionResult.getPoint() != null) { + lat = mpPositionResult.getPoint().getLat(); + lng = mpPositionResult.getPoint().getLng(); + floor = mpPositionResult.getFloor(); + } + debugStatusView.setText(String.format(Locale.getDefault(), "%s [%.6f, %.6f] @ %d", sourceAbbr, lat, lng, floor)); + debugStatusView.setTextColor(sourceColor); + } + } + } + + protected void notifyLocationFail() { + + } + + protected void handleFirstLocationUpdate() { + + } + + private void startMonitor() { + if (!isRunning) { + if (fusedLocationClient != null) { + fusedLocationClient.requestLocationUpdates(coreLocationRequest, coreLocationCallback, Looper.getMainLooper()); + } + isRunning = true; + startLocationTimer(); + } + } + + private void stopMonitor() { + if (isRunning) { + stopLocationTimer(); + if (fusedLocationClient != null) { + fusedLocationClient.removeLocationUpdates(coreLocationCallback); + } + isRunning = false; + } + } + + private void startLocationTimer() { + stopLocationTimer(); + locationTimer = new Timer(); + locationTimer.schedule(new TimerTask() { + @Override + public void run() { + onLocationTimerTimeout(); + } + }, (4000)); //4 secs + } + + private void stopLocationTimer() { + if (locationTimer != null) { + locationTimer.cancel(); + locationTimer = null; + } + } + + protected void onLocationTimerTimeout() { + stopLocationTimer(); + } + + //endregion + + //region Utilities + + protected String getLogTag() { + return MapActivity.class.getSimpleName(); + } + + //endregion +} diff --git a/android/app/src/main/java/edu/illinois/covid/maps/MapDirectionsActivity.java b/android/app/src/main/java/edu/illinois/covid/maps/MapDirectionsActivity.java new file mode 100644 index 00000000..464f4d80 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/maps/MapDirectionsActivity.java @@ -0,0 +1,958 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.maps; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.location.Location; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.text.Html; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.model.CameraPosition; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.android.gms.maps.model.PolygonOptions; +import com.google.android.gms.maps.model.Polyline; +import com.google.android.gms.maps.model.PolylineOptions; +import com.google.maps.android.ui.IconGenerator; +import com.mapsindoors.mapssdk.MPDirectionsRenderer; +import com.mapsindoors.mapssdk.MPPositionResult; +import com.mapsindoors.mapssdk.MPRoutingProvider; +import com.mapsindoors.mapssdk.OnLegSelectedListener; +import com.mapsindoors.mapssdk.OnRouteResultListener; +import com.mapsindoors.mapssdk.Point; +import com.mapsindoors.mapssdk.Route; +import com.mapsindoors.mapssdk.RouteCoordinate; +import com.mapsindoors.mapssdk.RouteLeg; +import com.mapsindoors.mapssdk.RoutePolyline; +import com.mapsindoors.mapssdk.RouteStep; +import com.mapsindoors.mapssdk.TravelMode; +import com.mapsindoors.mapssdk.errors.MIError; + +import org.json.JSONObject; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import edu.illinois.covid.Constants; +import edu.illinois.covid.MainActivity; +import edu.illinois.covid.R; +import edu.illinois.covid.Utils; + +public class MapDirectionsActivity extends MapActivity implements OnRouteResultListener, OnLegSelectedListener { + + //region Class fields + + //Explores - could be Event, Dining, Laundry or ParkingLotInventory + private Object explore; + private HashMap exploreLocation; + private Marker exploreMarker; + private IconGenerator iconGenerator; + private View markerLayoutView; + private View markerGroupLayoutView; + private float cameraZoom; + + //Navigation + private Route mpRoute; + private CameraPosition cameraPosition; + private MPDirectionsRenderer mpDirectionsRenderer; + private MPRoutingProvider mpRoutingProvider; + private MIError mpRouteError; + private List mpRouteStepCoordCounts; + private Polyline routePolyline; + private NavStatus navStatus = NavStatus.UNKNOWN; + private boolean navAutoUpdate; + private int currentLegIndex = 0; + private int currentStepIndex = -1; + private boolean buildRouteAfterInitialization; + + //Navigation UI + private static final String TRAVEL_MODE_PREFS_KEY = "directions.travelMode"; + private static final String[] TRAVEL_MODES = {TravelMode.WALKING, TravelMode.BICYCLING, + TravelMode.DRIVING, TravelMode.TRANSIT}; + private String selectedTravelMode; + private Map travelModesMap; + private View navRefreshButton; + private View navTravelModesContainer; + private View navAutoUpdateButton; + private View navPrevButton; + private View navNextButton; + private TextView navStepLabel; + private View routeLoadingFrame; + + //endregion + + //region Activity methods + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + initExplore(); + buildTravelModes(); + } + + //endregion + + //region Map views initialization + + @Override + protected void afterMapControlInitialized() { + super.afterMapControlInitialized(); + buildExploreMarker(); + buildPolygon(); + if (buildRouteAfterInitialization) { + buildRouteAfterInitialization = false; + buildRoute(); + } + } + + //endregion + + //region MapsIndoors + + @Override + protected boolean onMarkerClicked(Marker marker) { + Object exploreMarkerRawData = Utils.Explore.optExploreMarkerRawData(marker); + return (exploreMarkerRawData != null); + } + + @Override + protected void onFloorChanged(int floor) { + super.onFloorChanged(floor); + updateExploreMarkerVisibility(); + } + + @Override + protected void onCameraIdle() { + super.onCameraIdle(); + updateExploreMarkerAppearance(); + } + + /** + * OnRouteResultListener + */ + + @Override + public void onRouteResult(@Nullable Route route, @Nullable MIError miError) { + //DD: Workaround for multiple calling 'onRouteResult' from MapsIndoors sdk + if (mpRoutingProvider == null) { + return; + } + mpRoutingProvider.setOnRouteResultListener(null); + mpRoutingProvider = null; + mpRoute = route; + mpRouteError = miError; + runOnUiThread(this::didBuildRoute); + } + + /** + * OnLegSelectedListener + */ + + @Override + public void onLegSelected(int i) { + Log.d(getLogTag(), "OnLegSelectedListener.onLegSelected"); + } + + //endregion + + //region Common Location + + @Override + protected void notifyLocationUpdate(MPPositionResult positionResult, long timestamp) { + super.notifyLocationUpdate(positionResult, timestamp); + if (positionResult != null) { + if ((navStatus == NavStatus.PROGRESS) && navAutoUpdate) { + updateNavByCurrentLocation(); + } + } + } + + @Override + protected void notifyLocationFail() { + super.notifyLocationFail(); + if (mpPositionResult == null) { + handleFirstLocationUpdate(); + } + } + + @Override + protected void onLocationTimerTimeout() { + super.onLocationTimerTimeout(); + if (coreLocation == null) { + runOnUiThread(() -> { + enableView(navPrevButton, false); + enableView(navNextButton, false); + showLoadingFrame(false); + showAlert(getString(R.string.locationFailedMsg)); + }); + } + } + + //endregion + + //region Explores + + private void initExplore() { + Serializable exploreSeriazible = getIntent().getSerializableExtra("explore"); + if (exploreSeriazible == null) { + return; + } + if (exploreSeriazible instanceof HashMap) { + HashMap singleExplore; + singleExplore = (HashMap) exploreSeriazible; + this.explore = singleExplore; + initExploreLocation(singleExplore); + } else if (exploreSeriazible instanceof ArrayList) { + ArrayList explores = (ArrayList) exploreSeriazible; + this.explore = explores; + Object firstExplore = (explores.size() > 0) ? explores.get(0) : null; + if (firstExplore instanceof HashMap) { + initExploreLocation((HashMap) firstExplore); + } + } + } + + @Override + protected void initUiViews() { + super.initUiViews(); + showDirectionsUiViews(); + iconGenerator = new IconGenerator(this); + iconGenerator.setBackground(getDrawable(R.color.transparent)); + LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); + if (inflater != null) { + markerLayoutView = inflater.inflate(R.layout.marker_info_layout, null); + markerGroupLayoutView = inflater.inflate(R.layout.marker_group_layout, null); + } + navRefreshButton = findViewById(R.id.navRefreshButton); + navTravelModesContainer = findViewById(R.id.navTravelModesContainer); + navAutoUpdateButton = findViewById(R.id.navAutoUpdateButton); + navPrevButton = findViewById(R.id.navPrevButton); + navNextButton = findViewById(R.id.navNextButton); + navStepLabel = findViewById(R.id.navStepLabel); + routeLoadingFrame = findViewById(R.id.routeLoadingFrame); + } + + private void showDirectionsUiViews() { + View topNavBar = findViewById(R.id.topNavBar); + if (topNavBar != null) { + topNavBar.setVisibility(View.VISIBLE); + } + View bottomNavBar = findViewById(R.id.bottomNavBar); + if (topNavBar != null) { + bottomNavBar.setVisibility(View.VISIBLE); + } + } + + private void buildExploreMarker() { + if (exploreLocation != null) { + MarkerOptions markerOptions = Utils.Explore.constructMarkerOptions(this, explore, markerLayoutView, markerGroupLayoutView, iconGenerator); + if (markerOptions != null) { + exploreMarker = googleMap.addMarker(markerOptions); + JSONObject tagJson = Utils.Explore.constructMarkerTagJson(this, exploreMarker.getTitle(), explore); + exploreMarker.setTag(tagJson); + } + updateExploreMarkerAppearance(); + } + } + + private void updateExploreMarkerAppearance() { + float currentCameraZoom = googleMap.getCameraPosition().zoom; + boolean updateMarkerInfo = (currentCameraZoom != cameraZoom); + if (updateMarkerInfo) { + boolean singleExploreMarker = Utils.Explore.optSingleExploreMarker(exploreMarker); + Utils.Explore.updateCustomMarkerAppearance(this, exploreMarker, singleExploreMarker, currentCameraZoom, cameraZoom, markerLayoutView, markerGroupLayoutView, iconGenerator); + } + cameraZoom = currentCameraZoom; + } + + private void updateExploreMarkerVisibility() { + if (exploreMarker == null) { + return; + } + int currentFloorIndex = (mapControl != null) ? mapControl.getCurrentFloorIndex() : 0; + Integer markerFloor = Utils.Explore.optMarkerLocationFloor(exploreMarker); + boolean markerVisible = (markerFloor == null) || (currentFloorIndex == markerFloor); + exploreMarker.setVisible(markerVisible); + } + + private void initExploreLocation(HashMap singleExplore) { + Utils.ExploreType exploreType = Utils.Explore.getExploreType(singleExplore); + if (exploreType == Utils.ExploreType.PARKING) { + LatLng latLng = Utils.Explore.optLocationLatLng(singleExplore); + if (latLng != null) { + this.exploreLocation = Utils.Explore.createLocationMap(latLng); + } + } else { + this.exploreLocation = Utils.Explore.optLocation(singleExplore); + } + } + + private void buildPolygon() { + if (googleMap == null) { + return; + } + List polygonPoints = Utils.Explore.getExplorePolygon(explore); + if ((polygonPoints == null) || polygonPoints.isEmpty()) { + return; + } + Utils.ExploreType exploreType = Utils.Explore.getExploreType(explore); + int strokeColor = getResources().getColor(Utils.Explore.getExploreColorResource(exploreType)); + int fillColor = Color.argb(10, 0, 0, 0); + googleMap.addPolygon(new PolygonOptions().addAll(polygonPoints). + clickable(false).strokeColor(strokeColor).strokeWidth(5.0f).fillColor(fillColor).zIndex(1.0f)); + } + + //endregion + + //region Navigation + + public void onRefreshNavClicked(View view) { + mpRoute = null; + mpRouteError = null; + if (routePolyline != null) { + routePolyline.remove(); + routePolyline = null; + } + mpRouteStepCoordCounts = null; + if (mpDirectionsRenderer != null) { + mpDirectionsRenderer.clear(); + mpDirectionsRenderer = null; + } + navStatus = NavStatus.UNKNOWN; + navAutoUpdate = false; + + if (cameraPosition != null && googleMap != null) { + googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(cameraPosition.target, cameraPosition.zoom)); + } + cameraPosition = null; + + updateNav(); + buildRoute(); + } + + public void onAutoUpdateNavClicked(View view) { + if (navStatus == NavStatus.PROGRESS) { + MPRouteSegmentPath segmentPath = findNearestRouteSegmentByCurrentLocation(); + if (isValidSegmentPath(segmentPath)) { + currentLegIndex = segmentPath.legIndex; + currentStepIndex = segmentPath.stepIndex; + moveTo(currentLegIndex, currentStepIndex); + navAutoUpdate = true; + } + updateNav(); + } + } + + public void onWalkTravelModeClicked(View view) { + changeSelectedTravelMode(TravelMode.WALKING); + } + + public void onBikeTravelModeClicked(View view) { + changeSelectedTravelMode(TravelMode.BICYCLING); + } + + public void onDriveTravelModeClicked(View view) { + changeSelectedTravelMode(TravelMode.DRIVING); + } + + public void onTransitTravelModeClicked(View view) { + changeSelectedTravelMode(TravelMode.TRANSIT); + } + + public void onPrevNavClicked(View view) { + if (navStatus == NavStatus.START) { + //Do nothing + } else if (navStatus == NavStatus.PROGRESS) { + if (mpRoute == null) { + return; + } + if (currentStepIndex > 0) { + moveTo(currentLegIndex, --currentStepIndex); + } else if (currentLegIndex > 0) { + currentLegIndex--; + List routeLegs = mpRoute.getLegs(); + RouteLeg currentLeg = routeLegs.get(currentLegIndex); + List routeSteps = currentLeg.getSteps(); + int stepsSize = routeSteps.size(); + currentStepIndex = stepsSize - 1; + moveTo(currentLegIndex, currentStepIndex); + } else { + navStatus = NavStatus.START; + mpDirectionsRenderer.clear(); + } + } else if (navStatus == NavStatus.FINISHED) { + navStatus = NavStatus.PROGRESS; + moveTo(currentLegIndex, currentStepIndex); + } + updateNavAutoUpdate(); + updateNav(); + } + + public void onNextNavClicked(View view) { + if (navStatus == NavStatus.START) { + navStatus = NavStatus.PROGRESS; + currentLegIndex = 0; + currentStepIndex = 0; + moveTo(currentLegIndex, currentStepIndex); + notifyRouteStart(); + } else if (navStatus == NavStatus.PROGRESS) { + if (mpRoute == null) { + return; + } + List routeLegs = mpRoute.getLegs(); + int legsSize = routeLegs.size(); + RouteLeg currentLeg = routeLegs.get(currentLegIndex); + List routeSteps = currentLeg.getSteps(); + int stepsSize = routeSteps.size(); + if ((currentStepIndex + 1) < stepsSize) { + moveTo(currentLegIndex, ++currentStepIndex); + } else if ((currentLegIndex + 1) < legsSize) { + currentStepIndex = 0; + currentLegIndex++; + moveTo(currentLegIndex, currentStepIndex); + } else { + navStatus = NavStatus.FINISHED; + mpDirectionsRenderer.clear(); + notifyRouteFinish(); + } + } else if (navStatus == NavStatus.FINISHED) {/*Do nothing*/} + updateNavAutoUpdate(); + updateNav(); + } + + private void buildTravelModes() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + String selectedTravelMode = preferences.getString(TRAVEL_MODE_PREFS_KEY, TravelMode.WALKING); + travelModesMap = new HashMap<>(); + for (String currentTravelMode : TRAVEL_MODES) { + View travelModeView = null; + switch (currentTravelMode) { + case TravelMode.WALKING: + travelModeView = findViewById(R.id.walkTravelModeButton); + break; + case TravelMode.BICYCLING: + travelModeView = findViewById(R.id.bikeTravelModeButton); + break; + case TravelMode.DRIVING: + travelModeView = findViewById(R.id.driveTravelModeButton); + break; + case TravelMode.TRANSIT: + travelModeView = findViewById(R.id.transitTravelModeButton); + break; + default: + break; + } + travelModesMap.put(currentTravelMode, travelModeView); + if (currentTravelMode.equals(selectedTravelMode)) { + this.selectedTravelMode = selectedTravelMode; + travelModeView.setBackgroundResource(R.color.grey40); + } + } + } + + private void buildRoute() { + if (selectedTravelMode == null) { + selectedTravelMode = TravelMode.WALKING; + } + buildRoute(selectedTravelMode); + } + + private void buildRoute(String travelModeValue) { + showLoadingFrame(true); + if (travelModeValue == null || travelModeValue.isEmpty()) { + travelModeValue = TravelMode.WALKING; + } + Point originPoint = (mpPositionResult != null) ? mpPositionResult.getPoint() : null; + Point destinationPoint = getRouteDestinationPoint(); + if (originPoint == null || destinationPoint == null) { + showLoadingFrame(false); + Log.e(getLogTag(), "buildRoute() -> origin or destination point is null!"); + String routeFailedMsg = getString(R.string.routeFailedMsg); + showAlert(routeFailedMsg); + return; + } + mpRoutingProvider = new MPRoutingProvider(); + mpRoutingProvider.setOnRouteResultListener(this); + mpRoutingProvider.setTravelMode(travelModeValue); + mpRoutingProvider.query(originPoint, destinationPoint); + } + + /*** + * Calculates route destination point based on explore type. + * @return parking entrance if explore is Parking, explore location - otherwise + */ + private Point getRouteDestinationPoint() { + Utils.ExploreType exploreType = Utils.Explore.getExploreType(explore); + LatLng destinationLatLng = null; + + if (exploreType == Utils.ExploreType.PARKING) { + HashMap exploreMap = (HashMap) explore; + destinationLatLng = Utils.Explore.optLocationLatLng(exploreMap); + if (destinationLatLng != null) { + return new Point(destinationLatLng.latitude, destinationLatLng.longitude, 0); + } + } + destinationLatLng = Utils.Explore.optLatLng(exploreLocation); + if (destinationLatLng != null) { + Integer floor = Utils.Explore.optFloor(exploreLocation); + return new Point(destinationLatLng.latitude, destinationLatLng.longitude, (floor != null ? floor : 0)); + } else { + return null; + } + } + + private void changeSelectedTravelMode(String newTravelMode) { + if (newTravelMode != null) { + mpRoute = null; + mpRouteError = null; + if (routePolyline != null) { + routePolyline.remove(); + routePolyline = null; + } + mpRoutingProvider = null; + mpRouteStepCoordCounts = null; + if (mpDirectionsRenderer != null) { + mpDirectionsRenderer.clear(); + mpDirectionsRenderer = null; + } + navStatus = NavStatus.UNKNOWN; + navAutoUpdate = false; + if (travelModesMap != null) { + for (String travelMode : travelModesMap.keySet()) { + View travelModeView = travelModesMap.get(travelMode); + if (travelModeView != null) { + int backgroundResource = (newTravelMode.equals(travelMode)) ? R.color.grey40 : 0; + travelModeView.setBackgroundResource(backgroundResource); + } + } + } + updateNav(); + selectedTravelMode = newTravelMode; + buildRoute(newTravelMode); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + SharedPreferences.Editor editor = preferences.edit(); + editor.putString(TRAVEL_MODE_PREFS_KEY, selectedTravelMode); + editor.apply(); + } + } + + @Override + protected void handleFirstLocationUpdate() { + if (exploreLocation == null) { + if (mpPositionResult != null) { + Location location = mpPositionResult.getAndroidLocation(); + if (location != null) { + LatLng cameraPosition = new LatLng(location.getLatitude(), location.getLongitude()); + if (googleMap != null) { + googleMap.moveCamera(CameraUpdateFactory.newLatLng(cameraPosition)); + } + } + } + } else if (mpPositionResult == null) { + showLoadingFrame(false); + LatLng cameraPosition = Utils.Explore.optLatLng(exploreLocation); + if ((googleMap != null) && (cameraPosition != null)) { + googleMap.moveCamera(CameraUpdateFactory.newLatLng(cameraPosition)); + } + String errorMessage = getString(R.string.locationFailedMsg); + showAlert(errorMessage); + } else { + if ((mpRoutingProvider == null) && (mpRoute == null) && (mpRouteError == null)) { + if ((mapControl != null) && mapControl.isReady()) { + buildRoute(); + } else { + buildRouteAfterInitialization = true; + } + } + } + } + + private void didBuildRoute() { + showLoadingFrame(false); + if (mpRoute != null) { + buildRoutePolyline(); + mpDirectionsRenderer = new MPDirectionsRenderer(this, googleMap, mapControl, this); + mpDirectionsRenderer.setRoute(mpRoute); + cameraPosition = googleMap.getCameraPosition(); + navStatus = NavStatus.START; + } else { + String routeFailedMsg = getString(R.string.routeFailedMsg); + showAlert(routeFailedMsg); + } + + updateNav(); + + Point point = mpPositionResult.getPoint(); + if (point != null) { + LatLng currentLatLng = point.getLatLng(); + LatLng exploreLatLng = Utils.Explore.optLatLng(exploreLocation); + LatLngBounds.Builder latLngBuilder = new LatLngBounds.Builder(); + latLngBuilder.include(currentLatLng); + latLngBuilder.include(exploreLatLng); + if (mpRoute != null && mpRoute.getBounds() != null) { + Object routeBoundsObject = mpRoute.getBounds(); + if (routeBoundsObject instanceof LatLngBounds) { + LatLngBounds routeLatLngBounds = (LatLngBounds) routeBoundsObject; + latLngBuilder.include(routeLatLngBounds.northeast); + latLngBuilder.include(routeLatLngBounds.southwest); + } + } + googleMap.moveCamera(CameraUpdateFactory.newLatLngBounds(latLngBuilder.build(), 50)); + } + } + + private void buildRoutePolyline() { + mpRouteStepCoordCounts = new ArrayList<>(); + List routePoints = new ArrayList<>(); + for (RouteLeg routeLeg : mpRoute.getLegs()) { + for (RouteStep routeStep : routeLeg.getSteps()) { + RoutePolyline routePolyline = routeStep.getPolyline(); + if (routePolyline != null) { + Point[] polylinePoints = routePolyline.getPoints(); + for (Point point : polylinePoints) { + LatLng latLng = point.getLatLng(); + routePoints.add(latLng); + } + mpRouteStepCoordCounts.add(polylinePoints.length); + } + } + } + if (googleMap != null) { + routePolyline = googleMap.addPolyline(new PolylineOptions() + .addAll(routePoints) + .color(Color.BLUE)); + } + } + + private void moveTo(int legIndex, int stepIndex) { + if (mpDirectionsRenderer != null) { + mpDirectionsRenderer.setRouteLegIndex(legIndex, stepIndex); + mpDirectionsRenderer.animate(0, true); + } + } + + private void updateNav() { + navRefreshButton.setVisibility(View.VISIBLE); + enableView(navRefreshButton, (mpRoutingProvider == null)); + + int travelModesVisibility = ((navStatus != NavStatus.UNKNOWN) && (navStatus != NavStatus.START)) ? View.GONE : View.VISIBLE; + navTravelModesContainer.setVisibility(travelModesVisibility); + enableView(navTravelModesContainer, (mpRoutingProvider == null)); + + int autoUpdateVisibility = ((navStatus != NavStatus.PROGRESS) || navAutoUpdate) ? View.GONE : View.VISIBLE; + navAutoUpdateButton.setVisibility(autoUpdateVisibility); + int navBottomVisibility = (navStatus == NavStatus.UNKNOWN) ? View.GONE : View.VISIBLE; + navPrevButton.setVisibility(navBottomVisibility); + navNextButton.setVisibility(navBottomVisibility); + navStepLabel.setVisibility(navBottomVisibility); + + if (navStatus == NavStatus.START) { + String routeDisplayDescription = buildRouteDisplayDescription(); + boolean hasDescription = (routeDisplayDescription != null) && !routeDisplayDescription.isEmpty(); + String secondRow = hasDescription ? String.format("
(%s)", routeDisplayDescription) : ""; + String stepHtmlContent = String.format("%s%s", getString(R.string.start), secondRow); + setStepHtml(stepHtmlContent); + enableView(navPrevButton, false); + enableView(navNextButton, true); + } else if (navStatus == NavStatus.PROGRESS) { + List routeLegs = mpRoute.getLegs(); + RouteLeg leg = (currentLegIndex >= 0 && currentLegIndex < routeLegs.size()) ? routeLegs.get(currentLegIndex) : null; + List routeSteps = (leg != null) ? leg.getSteps() : null; + RouteStep step = ((routeSteps != null) && (currentStepIndex >= 0) && (currentStepIndex < routeSteps.size())) ? + routeSteps.get(currentStepIndex) : null; + if (step != null) { + if (step.getHtmlInstructions() != null) { + setStepHtml(step.getHtmlInstructions()); + } else if (step.getManeuver() != null || !step.getHighway().isEmpty() || !step.getAbutters().isEmpty()) { + String maneuver = (step.getManeuver() != null) ? step.getManeuver() : ""; + String plainStepText = String.format("%s | %s | %s", maneuver, step.getHighway(), step.getAbutters()); + navStepLabel.setText(plainStepText); + } else if (step.getDistance() > 0.0f || step.getDuration() > 0.0f) { + String plainStepText = String.format(getString(R.string.routeDistanceDurationFormat), step.getDistance(), step.getDuration()); + navStepLabel.setText(plainStepText); + } + } else { + String plainStepText = String.format(getString(R.string.routeLegStepFormat), (currentLegIndex + 1), (currentStepIndex + 1)); + navStepLabel.setText(plainStepText); + } + + enableView(navPrevButton, true); + enableView(navNextButton, true); + if (step != null) { + updateCurrentFloor(step.getStartPoint().getZIndex()); + } + + } else if (navStatus == NavStatus.FINISHED) { + String htmlContent = String.format("%s", getString(R.string.finish)); + setStepHtml(htmlContent); + enableView(navPrevButton, true); + enableView(navNextButton, false); + } + } + + private void updateCurrentFloor(int floor) { + if (floor != mapControl.getCurrentFloorIndex()) { + mapControl.selectFloor(floor); + onFloorChanged(floor); + } + } + + private void updateNavAutoUpdate() { + MPRouteSegmentPath segmentPath = findNearestRouteSegmentByCurrentLocation(); + navAutoUpdate = (isValidSegmentPath(segmentPath) && + (currentLegIndex == segmentPath.legIndex) && + (currentStepIndex == segmentPath.stepIndex)); + } + + private void updateNavByCurrentLocation() { + if ((navStatus == NavStatus.PROGRESS) && navAutoUpdate && + (mpPositionResult != null) && (mpRoute != null) && (mpDirectionsRenderer != null)) { + MPRouteSegmentPath segmentPath = findNearestRouteSegmentByCurrentLocation(); + if (isValidSegmentPath(segmentPath)) { + updateNavFromSegmentPath(segmentPath); + } + } + } + + private void updateNavFromSegmentPath(MPRouteSegmentPath segmentPath) { + boolean modified = false; + if (currentLegIndex != segmentPath.legIndex) { + currentLegIndex = segmentPath.legIndex; + modified = true; + } + if (currentStepIndex != segmentPath.stepIndex) { + currentStepIndex = segmentPath.stepIndex; + modified = true; + } + if (modified) { + moveTo(currentLegIndex, currentStepIndex); + updateNav(); + } + } + + @NonNull + private MPRouteSegmentPath findNearestRouteSegmentByCurrentLocation() { + MPRouteSegmentPath minRouteSegmentPath = new MPRouteSegmentPath(-1, -1); + if (mpPositionResult != null && mpRoute != null) { + double minLegDistance = -1; + Point mpPoint = mpPositionResult.getPoint(); + if (mpPoint != null) { + LatLng locationLatLng = mpPoint.getLatLng(); + int globalStepIndex = 0; + int locationIndex = 0; + List routePolylinePoints = routePolyline.getPoints(); + List routeLegs = mpRoute.getLegs(); + for (int legIndex = 0; legIndex < routeLegs.size(); legIndex++) { + RouteLeg routeLeg = routeLegs.get(legIndex); + List legSteps = routeLeg.getSteps(); + for (int stepIndex = 0; stepIndex < legSteps.size(); stepIndex++) { + int increasedIndex = (globalStepIndex < mpRouteStepCoordCounts.size()) ? mpRouteStepCoordCounts.get(globalStepIndex) : 0; + int lastLocationIndex = locationIndex + increasedIndex; + while (locationIndex < lastLocationIndex) { + LatLng latLng = routePolylinePoints.get(locationIndex); + Double coordDistance = Utils.Location.getDistanceBetween(locationLatLng, latLng); + if (coordDistance != null && (minLegDistance < 0.0d || coordDistance < minLegDistance)) { + minLegDistance = coordDistance; + minRouteSegmentPath = new MPRouteSegmentPath(legIndex, stepIndex); + locationIndex = lastLocationIndex; + break; + } + locationIndex++; + } + globalStepIndex++; + } + } + } + } + return minRouteSegmentPath; + } + + private boolean isValidSegmentPath(MPRouteSegmentPath segmentPath) { + if (mpRoute == null || segmentPath == null) { + return false; + } + List routeLegs = mpRoute.getLegs(); + if ((segmentPath.legIndex >= 0) && (segmentPath.legIndex < routeLegs.size())) { + RouteLeg leg = routeLegs.get(segmentPath.legIndex); + if ((segmentPath.stepIndex >= 0) && segmentPath.stepIndex < leg.getSteps().size()) { + return true; + } + } + return false; + } + + private void setStepHtml(String htmlContent) { + String formattedHtml = String.format("

%s
", htmlContent); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + navStepLabel.setText(Html.fromHtml(formattedHtml, Html.FROM_HTML_MODE_COMPACT)); + } else { + navStepLabel.setText(Html.fromHtml(formattedHtml)); + } + } + + private String buildRouteDisplayDescription() { + if (mpRoute == null) { + return null; + } + StringBuilder descriptionBuilder = new StringBuilder(); + + if (mpRoute.getDistance() > 0) { + // 1 foot = 0.3048 meters + // 1 mile = 1609.34 meters + + long totalMeters = Math.abs(mpRoute.getDistance()); + double totalMiles = (totalMeters / 1609.34d); + if (descriptionBuilder.length() > 0) { + descriptionBuilder.append(", "); + } + descriptionBuilder.append(String.format(Locale.getDefault(), "%.1f %s", totalMiles, getString((totalMiles != 1.0) ? R.string.miles : R.string.mile))); + } + if (mpRoute.getDuration() > 0) { + long totalSeconds = Math.abs(mpRoute.getDuration()); + long totalMinutes = totalSeconds / 60; + long totalHours = totalMinutes / 60; + long minutes = totalMinutes % 60; + + if (descriptionBuilder.length() > 0) { + descriptionBuilder.append(", "); + } + String formattedTime; + if (totalHours < 1) { + formattedTime = String.format(Locale.getDefault(), "%d %s", minutes, getString(R.string.minute)); + } else if (totalHours < 24) { + formattedTime = String.format(Locale.getDefault(), "%d h %2d %s", totalHours, minutes, getString(R.string.minute)); + } else { + formattedTime = String.format(Locale.getDefault(), "%d h", totalHours); + } + descriptionBuilder.append(formattedTime); + } + + String routeSummary = mpRoute.getSummary(); + if (routeSummary != null && !routeSummary.isEmpty()) { + descriptionBuilder.append(routeSummary); + } + return descriptionBuilder.toString(); + } + + private void notifyRouteStart() { + notifyRouteEvent("map.route.start"); + } + + private void notifyRouteFinish() { + notifyRouteEvent("map.route.finish"); + } + + private void notifyRouteEvent(String event) { + String originString = null; + String destinationString = null; + String locationString = null; + List routeLegs = (mpRoute != null) ? mpRoute.getLegs() : null; + int legsCount = (routeLegs != null && routeLegs.size() > 0) ? routeLegs.size() : 0; + if (legsCount > 0) { + RouteCoordinate origin = routeLegs.get(0).getStartLocation(); + RouteCoordinate destination = routeLegs.get(legsCount - 1).getEndLocation(); + int originFloor = (int) origin.getZIndex(); + int destinationFloor = (int) destination.getZIndex(); + originString = String.format(Locale.getDefault(), Constants.ANALYTICS_ROUTE_LOCATION_FORMAT, origin.getLat(), origin.getLng(), originFloor); + destinationString = String.format(Locale.getDefault(), Constants.ANALYTICS_ROUTE_LOCATION_FORMAT, destination.getLat(), destination.getLng(), destinationFloor); + } + if (mpPositionResult != null) { + Point locationPoint = mpPositionResult.getPoint(); + int locationFloor = mpPositionResult.getFloor(); + if (locationPoint != null) { + locationString = String.format(Locale.getDefault(), Constants.ANALYTICS_USER_LOCATION_FORMAT, locationPoint.getLat(), locationPoint.getLng(), locationFloor, locationTimestamp); + } + } + String analyticsParam = String.format(Locale.getDefault(), "{\"origin\":%s,\"destination\":%s,\"location\":%s}", originString, destinationString, locationString); + MainActivity.invokeFlutterMethod(event, analyticsParam); + } + + //endregion + + //region Utilities + + @Override + protected String getLogTag() { + return MapDirectionsActivity.class.getSimpleName(); + } + + private void showLoadingFrame(boolean show) { + if (routeLoadingFrame != null) { + routeLoadingFrame.setVisibility(show ? View.VISIBLE : View.GONE); + } + } + + private void showAlert(String message) { + String appName = getString(R.string.app_name); + AlertDialog.Builder alertBuilder = new AlertDialog.Builder(this); + alertBuilder.setTitle(appName); + alertBuilder.setMessage(message); + alertBuilder.setPositiveButton(R.string.ok, null); + alertBuilder.show(); + } + + private void enableView(View view, boolean enabled) { + if (view == null) { + return; + } + float viewAlpha = enabled ? 1.0f : 0.5f; + view.setEnabled(enabled); + view.setAlpha(viewAlpha); + } + + //endregion + + //region NavStatus + + private enum NavStatus {UNKNOWN, START, PROGRESS, FINISHED} + + //endregion + + //region MPRouteSegmentPath + + private static class MPRouteSegmentPath { + private int legIndex; + private int stepIndex; + + private MPRouteSegmentPath(int legIndex, int stepIndex) { + this.legIndex = legIndex; + this.stepIndex = stepIndex; + } + } + + //endregion +} diff --git a/android/app/src/main/java/edu/illinois/covid/maps/MapMarkerViewType.java b/android/app/src/main/java/edu/illinois/covid/maps/MapMarkerViewType.java new file mode 100644 index 00000000..7263ac5e --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/maps/MapMarkerViewType.java @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.maps; + +public enum MapMarkerViewType {SINGLE, GROUP, UNKNOWN} diff --git a/android/app/src/main/java/edu/illinois/covid/maps/MapPickLocationActivity.java b/android/app/src/main/java/edu/illinois/covid/maps/MapPickLocationActivity.java new file mode 100644 index 00000000..a94073a8 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/maps/MapPickLocationActivity.java @@ -0,0 +1,385 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.maps; + +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.mapsindoors.mapssdk.MPLocation; +import com.mapsindoors.mapssdk.MapControl; +import com.mapsindoors.mapssdk.MapsIndoors; +import com.mapsindoors.mapssdk.errors.MIError; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; + +import edu.illinois.covid.Constants; +import edu.illinois.covid.R; +import edu.illinois.covid.Utils; + +public class MapPickLocationActivity extends AppCompatActivity { + + private static final String TAG = MapPickLocationActivity.class.getSimpleName(); + + private SupportMapFragment mapFragment; + private GoogleMap googleMap; + private MapControl mapControl; + private TextView locationInfoTextView; + private Marker customLocationMarker; + private Marker selectedMarker; + private HashMap initialLocation; + private LatLng initialCameraPosition; + private HashMap explore; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.map_pick_location_layout); + + initHeaderBar(); + initInitialLocation(); + initMapFragment(); + locationInfoTextView = findViewById(R.id.locationInfoTextView); + updateLocationInfo(null); + } + + @Override + protected void onStart() { + super.onStart(); + if (mapControl != null) { + mapControl.onStart(); + } + } + + @Override + protected void onStop() { + super.onStop(); + if (mapControl != null) { + mapControl.onStop(); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (mapControl != null) { + mapControl.onResume(); + } + } + + @Override + protected void onPause() { + super.onPause(); + if (mapControl != null) { + mapControl.onPause(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mapControl != null) { + mapControl.onDestroy(); + } + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + if (mapControl != null) { + mapControl.onLowMemory(); + } + } + + public void onSaveClicked(View view) { + if (selectedMarker == null) { + Utils.showDialog(this, getString(R.string.app_name), + getString(R.string.select_location_msg), + (dialog, which) -> dialog.dismiss(), + getString(R.string.ok), null, null, false); + return; + } + String resultData = null; + if (selectedMarker == customLocationMarker) { + resultData = (String) selectedMarker.getTag(); + } else { + MPLocation location = mapControl.getLocation(selectedMarker); + if (location != null) { + resultData = String.format(Locale.getDefault(), Constants.LOCATION_PICKER_DATA_FORMAT, + selectedMarker.getPosition().latitude, selectedMarker.getPosition().longitude, + location.getFloor(), (selectedMarker.getSnippet() != null ? selectedMarker.getSnippet() : ""), + location.getId(), location.getName()); + } + } + Intent resultDataIntent = new Intent(); + resultDataIntent.putExtra("location", resultData); + setResult(RESULT_OK, resultDataIntent); + finish(); + } + + private void initHeaderBar() { + setSupportActionBar(findViewById(R.id.toolbar)); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayShowTitleEnabled(false); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } + } + + private void initInitialLocation() { + Bundle initialLocationArguments = getIntent().getExtras(); + if (initialLocationArguments != null) { + Serializable serializable = initialLocationArguments.getSerializable("explore"); + if (serializable instanceof HashMap) { + explore = (HashMap) serializable; + initialLocation = Utils.Explore.optLocation(explore); + } + } + initialCameraPosition = Constants.DEFAULT_INITIAL_CAMERA_POSITION; + if (initialLocation != null) { + initialCameraPosition = Utils.Explore.optLatLng(initialLocation); + } + } + + private void initMapFragment() { + mapFragment = ((SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map_fragment)); + if (mapFragment != null) { + mapFragment.getMapAsync(this::didGetMapAsync); + } + } + + private void didGetMapAsync(GoogleMap map) { + googleMap = map; + googleMap.setMapType(GoogleMap.MAP_TYPE_TERRAIN); + googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(initialCameraPosition, Constants.INDOORS_BUILDING_ZOOM)); + setupMapsIndoors(); + loadInitialLocation(); + } + + private void setupMapsIndoors() { + mapControl = new MapControl(this); + mapControl.setGoogleMap(googleMap, mapFragment.getView()); + mapControl.setOnMarkerClickListener(marker -> { + setSelectedLocationMarker(marker); + return true; + }); + mapControl.setOnMapClickListener(this::onMapClicked); + mapControl.setOnFloorUpdateListener((building, i) -> onFloorUpdate()); + mapControl.init(this::mapControlDidInit); + } + + private void mapControlDidInit(MIError error) { + runOnUiThread(() -> { + if (error == null) { + mapControl.selectFloor(0); + } else { + Log.e(TAG, error.message); + } + }); + } + + private void loadInitialLocation() { + if (initialLocation != null) { + String locationId = null; + Object locationIdObj = initialLocation.get("location_id"); + if (locationIdObj instanceof String) { + locationId = (String) locationIdObj; + } + //MapsIndoors removed mapControl.getMarker(locationId) in version 3.x.x + MPLocation mpLocation = (locationId != null) ? MapsIndoors.getLocationById(locationId) : null; + int floorIndex = Utils.Explore.optFloor(initialLocation); + selectedMarker = createCustomLocationMarker(initialLocation); + mapControl.selectFloor(floorIndex); + updateLocationInfo(selectedMarker); + } + } + + private boolean onMapClicked(@NonNull LatLng latLng, @Nullable List list) { + runOnUiThread(() -> { + if ((selectedMarker != null) || (customLocationMarker != null)) { + clearCustomLocationMarker(); + setSelectedLocationMarker(null); + } else { + Marker customMarker = createCustomLocationMarker(latLng); + setSelectedLocationMarker(customMarker); + } + }); + return true; + } + + private void onFloorUpdate() { + updateCustomLocationMarker(); + updateSelectedMarker(); + } + + private void updateCustomLocationMarker() { + if (customLocationMarker == null) { + return; + } + int floorIndex; + Object userDataObj = customLocationMarker.getTag(); + if (userDataObj instanceof Integer) { + floorIndex = (Integer) userDataObj; + } else { + floorIndex = getFloorIndexFromMarkerTag(userDataObj); + } + boolean markerVisible = (floorIndex == mapControl.getCurrentFloorIndex()); + if (markerVisible && (!customLocationMarker.isInfoWindowShown())) { + customLocationMarker.setVisible(true); + customLocationMarker.showInfoWindow(); + } else if (!markerVisible && (customLocationMarker.isInfoWindowShown())) { + customLocationMarker.hideInfoWindow(); + customLocationMarker.setVisible(false); + } + } + + private int getFloorIndexFromMarkerTag(Object markerTag) { + if (!(markerTag instanceof String)) { + return 0; + } + JSONObject tagJson = null; + try { + tagJson = new JSONObject((String) markerTag); + } catch (JSONException e) { + e.printStackTrace(); + } + Integer floorIndex = null; + if (tagJson != null) { + floorIndex = tagJson.optInt("floor", 0); + } + return (floorIndex != null) ? floorIndex : 0; + } + + private void updateSelectedMarker() { + if (selectedMarker == null) { + return; + } + int floorIndex = 0; + if (selectedMarker == customLocationMarker) { + floorIndex = getFloorIndexFromMarkerTag(customLocationMarker); + } else { + MPLocation location = mapControl.getLocation(selectedMarker); + if (location != null) { + floorIndex = location.getFloor(); + } + } + if (floorIndex == mapControl.getCurrentFloorIndex()) { + selectedMarker.showInfoWindow(); + } else { + selectedMarker.hideInfoWindow(); + } + } + + private Marker createCustomLocationMarker(HashMap locationMap) { + clearCustomLocationMarker(); + LatLng latLng = Utils.Explore.optLatLng(locationMap); + String locationName = null; + Object nameObj = locationMap.get("name"); + if (nameObj instanceof String) { + locationName = (String) nameObj; + } + String locationDesc = null; + Object descrObj = locationMap.get("description"); + if (descrObj instanceof String) { + locationDesc = (String) descrObj; + } + MarkerOptions markerOptions = new MarkerOptions(); + markerOptions.position(latLng); + markerOptions.zIndex(1); + markerOptions.title(locationName); + markerOptions.snippet(locationDesc); + customLocationMarker = googleMap.addMarker(markerOptions); + String tag = String.format(Locale.getDefault(), Constants.LOCATION_PICKER_DATA_FORMAT, latLng.latitude, latLng.longitude, Utils.Explore.optFloor(locationMap), locationDesc, "", locationName); + customLocationMarker.setTag(tag); + customLocationMarker.showInfoWindow(); + return customLocationMarker; + } + + private Marker createCustomLocationMarker(LatLng latLng) { + clearCustomLocationMarker(); + MarkerOptions markerOptions = new MarkerOptions(); + markerOptions.position(latLng); + markerOptions.zIndex(1); + String title = (explore != null) ? (String)explore.get("name") : getString(R.string.custom); + if (Utils.Str.isEmpty(title) || "null".equals(title)) { + title = getString(R.string.custom); + } + markerOptions.title(title); + customLocationMarker = googleMap.addMarker(markerOptions); + String userData = String.format(Locale.getDefault(), Constants.LOCATION_PICKER_DATA_FORMAT, + latLng.latitude, latLng.longitude, mapControl.getCurrentFloorIndex(), + "", "", "");//empty "name", "description" and empty "location_id" + customLocationMarker.setTag(userData); + customLocationMarker.showInfoWindow(); + return customLocationMarker; + } + + private void setSelectedLocationMarker(Marker marker) { + if ((customLocationMarker != null) && (customLocationMarker != marker)) { + clearCustomLocationMarker(); + } + selectedMarker = marker; + if (selectedMarker != null) { + selectedMarker.showInfoWindow(); + } + updateLocationInfo(marker); + } + + private void clearCustomLocationMarker() { + if (customLocationMarker != null) { + if (selectedMarker == customLocationMarker) { + selectedMarker.hideInfoWindow(); + selectedMarker = null; + } + customLocationMarker.hideInfoWindow(); + customLocationMarker.remove(); + customLocationMarker = null; + } + } + + private void updateLocationInfo(Marker marker) { + String locationInfoText; + if (marker != null) { + MPLocation location = mapControl.getLocation(marker); + String locationName = (location != null) ? location.getName() : marker.getTitle(); + locationInfoText = getString(R.string.location_label, locationName); + } else { + locationInfoText = getString(R.string.select_location_msg); + } + locationInfoTextView.setText(locationInfoText); + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/maps/MapView.java b/android/app/src/main/java/edu/illinois/covid/maps/MapView.java new file mode 100644 index 00000000..735fd2aa --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/maps/MapView.java @@ -0,0 +1,420 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.maps; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewParent; +import android.widget.FrameLayout; +import android.widget.RelativeLayout; + +import com.google.android.gms.maps.CameraUpdate; +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.model.CameraPosition; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import com.google.gson.Gson; +import com.google.maps.android.ui.IconGenerator; +import com.mapsindoors.mapssdk.FloorSelectorInterface; +import com.mapsindoors.mapssdk.MPLocation; +import com.mapsindoors.mapssdk.MapControl; +import com.mapsindoors.mapssdk.errors.MIError; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import edu.illinois.covid.Constants; +import edu.illinois.covid.MainActivity; +import edu.illinois.covid.R; +import edu.illinois.covid.Utils; + +public class MapView extends FrameLayout implements OnMapReadyCallback { + + private Context context; + private int mapId; + private Object args; + private Activity activity; + private com.google.android.gms.maps.MapView googleMapView; + private GoogleMap googleMap; + private MapControl mapControl; + private List explores; + private List markers; + + private IconGenerator iconGenerator; + private View markerLayoutView; + private View markerGroupLayoutView; + private float cameraZoom; + + private boolean mapLayoutPassed; + private boolean enableLocationValue; + + public MapView(Context context, int mapId, Object args) { + super(context); + this.context = context; + this.mapId = mapId; + this.args = args; + if (context instanceof Activity) { + this.activity = (Activity) context; + } + init(); + } + + public void onDestroy() { + clearMarkers(); + if (mapControl != null) { + mapControl.onDestroy(); + mapControl = null; + } + if (googleMapView != null) { + googleMapView.onDestroy(); + } + } + + private void onCreate() { + if (googleMapView != null) { + googleMapView.onCreate(null); + } + } + + private void onResume() { + if (googleMapView != null) { + googleMapView.onResume(); + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + googleMapView.layout(0, 0, r, b); + if (!mapLayoutPassed) { + mapLayoutPassed = true; + showExploresOnMap(); + } + } + + private void init() { + initMarkerView(); + initMapView(); + } + + private void initMapView() { + acknowledgeLocationEnabledFromArgs(); + googleMapView = new com.google.android.gms.maps.MapView(context); + googleMapView.setBackgroundColor(0xFF0000FF); + addView(googleMapView); + onCreate(); + googleMapView.getMapAsync(this); + } + + private void initMarkerView() { + iconGenerator = new IconGenerator(activity); + iconGenerator.setBackground(activity.getDrawable(R.color.transparent)); + LayoutInflater inflater = (activity != null) ? (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE) : null; + markerLayoutView = (inflater != null) ? inflater.inflate(R.layout.marker_info_layout, null) : null; + markerGroupLayoutView = (inflater != null) ? inflater.inflate(R.layout.marker_group_layout, null) : null; + } + + private void initMapControl() { + mapControl = new MapControl(activity); + mapControl.setGoogleMap(googleMap, googleMapView); + mapControl.addOnCameraMoveListener(this::updateMarkers); + mapControl.setOnMarkerClickListener(this::onMarkerClicked); + mapControl.setOnMapClickListener(this::onMapClick); + mapControl.setOnFloorUpdateListener((building, i) -> updateMarkersVisibility()); + + mapControl.init(this::mapControlDidInit); + } + + @Override + public void onMapReady(GoogleMap map) { + onResume(); + googleMap = map; + enableMyLocation(enableLocationValue); + googleMap.moveCamera(CameraUpdateFactory.newCameraPosition(CameraPosition.fromLatLngZoom(Constants.DEFAULT_INITIAL_CAMERA_POSITION, Constants.DEFAULT_CAMERA_ZOOM))); + showExploresOnMap(); + relocateMyLocationButton(); + initMapControl(); + } + + private void acknowledgeLocationEnabledFromArgs() { + boolean myLocationEnabled = false; + if (args instanceof Map) { + //{ "myLocationEnabled" : true} + Map jsonArgs = (Map) args; + Object myLocationEnabledObj = jsonArgs.get("myLocationEnabled"); + if (myLocationEnabledObj instanceof Boolean) { + myLocationEnabled = (Boolean) myLocationEnabledObj; + } + } + this.enableLocationValue = myLocationEnabled; + } + + private void mapControlDidInit(MIError error) { + if (error != null) { + Log.d(MapViewController.class.getCanonicalName(), error.message); + } else { + if (activity != null) { + activity.runOnUiThread(this::mapControlInitIsReady); + } + } + } + + private void mapControlInitIsReady() { + if (mapControl != null) { + mapControl.selectFloor(0); + // ======================================================================================= + // + // This is a workaround for a current issue in the default floor selector. Without it, + // the floor selector will only show up once we pan away from our building and back... + // + // This issue is still present in the current SDK version (3.1.3-beta-4) + // ======================================================================================= + // + final FloorSelectorInterface floorSelector = mapControl.getFloorSelector(); + if (floorSelector != null) { + // Hide without animating + floorSelector.show(false, false); + // ...and show without animating + floorSelector.show(true, false); + } + } + } + + private void moveCameraToSpecificPosition() { + if ((markers != null) && (markers.size() > 0)) { + LatLngBounds.Builder builder = new LatLngBounds.Builder(); + for (Marker marker : markers) { + builder.include(marker.getPosition()); + } + LatLngBounds bounds = builder.build(); + int width = getResources().getDisplayMetrics().widthPixels; + int height = getResources().getDisplayMetrics().heightPixels; + int padding = 150; // offset from edges of the map in pixels + CameraUpdate cu = CameraUpdateFactory.newLatLngBounds(bounds, width, height, padding); + googleMap.animateCamera(cu); + } else { + googleMap.animateCamera(CameraUpdateFactory.newLatLngZoom(Constants.DEFAULT_INITIAL_CAMERA_POSITION, Constants.DEFAULT_CAMERA_ZOOM)); + } + } + + public void applyExplores(ArrayList explores, HashMap options) { + this.explores = buildExplores(explores, options); + if (mapLayoutPassed) { + showExploresOnMap(); + } + } + + //This has already been checked in flutter portion of the app + @SuppressLint("MissingPermission") + public void enableMyLocation(boolean enable) { + enableLocationValue = enable; + if (googleMap != null) { + googleMap.setMyLocationEnabled(enable); + } + } + + private List buildExplores(ArrayList rawExplores, HashMap options) { + if (rawExplores == null || rawExplores.size() == 0) { + return null; + } + Object exploreLocationThresholdParam = (options != null) ? options.get("LocationThresoldDistance") : null; + double exploreLocationThresholdDistance = Constants.EXPLORE_LOCATION_THRESHOLD_DISTANCE; + if (exploreLocationThresholdParam instanceof Double) { + exploreLocationThresholdDistance = (Double) exploreLocationThresholdParam; + } + List> mappedExploreGroups = new ArrayList<>(); + int rawExploresCount = rawExplores.size(); + for (int rawExploresIndex = 0; rawExploresIndex < rawExploresCount; rawExploresIndex++) { + Object exploreObject = rawExplores.get(rawExploresIndex); + if (exploreObject instanceof HashMap) { + HashMap explore = (HashMap) exploreObject; + Integer exploreFloor = Utils.Explore.optLocationFloor(explore); + LatLng exploreLatLng = Utils.Explore.optLocationLatLng(explore); + if (exploreLatLng != null) { + boolean exploreMapped = false; + for (List mappedExploreGroup : mappedExploreGroups) { + for (HashMap mappedExplore : mappedExploreGroup) { + LatLng mappedExploreLatLng = Utils.Explore.optLocationLatLng(mappedExplore); + Double distance = Utils.Location.getDistanceBetween(exploreLatLng, mappedExploreLatLng); + Integer mappedExploreFloor = Utils.Explore.optLocationFloor(mappedExplore); + boolean sameFloor = (exploreFloor == null && mappedExploreFloor == null) || + ((exploreFloor != null && mappedExploreFloor != null) && exploreFloor.equals(mappedExploreFloor)); + if ((distance != null) && (distance < exploreLocationThresholdDistance) && sameFloor) { + mappedExploreGroup.add(explore); + exploreMapped = true; + break; + } + } + if (exploreMapped) { + break; + } + } + if (!exploreMapped) { + ArrayList mappedExploreGroup = new ArrayList<>(Collections.singletonList(explore)); + mappedExploreGroups.add(mappedExploreGroup); + } + } + } + } + List resultExplores = new ArrayList<>(); + for (List mappedExploreGroup : mappedExploreGroups) { + if (mappedExploreGroup.size() == 1) { + HashMap firstExplore = mappedExploreGroup.get(0); + resultExplores.add(firstExplore); + } else { + resultExplores.add(mappedExploreGroup); + } + } + return resultExplores; + } + + private void showExploresOnMap() { + if (googleMap == null || !mapLayoutPassed) { + return; + } + clearMarkers(); + if (explores != null && explores.size() > 0) { + markers = new ArrayList<>(); + for (Object explore : explores) { + MarkerOptions markerOptions = Utils.Explore.constructMarkerOptions(getContext(), explore, markerLayoutView, markerGroupLayoutView, iconGenerator); + if (markerOptions != null) { + Marker marker = googleMap.addMarker(markerOptions); + JSONObject tagJson = Utils.Explore.constructMarkerTagJson(getContext(), marker.getTitle(), explore); + marker.setTag(tagJson); + markers.add(marker); + } + } + } + updateMarkers(); + moveCameraToSpecificPosition(); + } + + private synchronized void clearMarkers() { + if (markers != null) { + for (Marker marker : markers) { + marker.remove(); + } + markers.clear(); + markers = null; + } + } + + private void updateMarkers() { + float currentCameraZoom = googleMap.getCameraPosition().zoom; + boolean updateMarkerInfo = (currentCameraZoom != cameraZoom); + if (markers != null && !markers.isEmpty()) { + for (Marker marker : markers) { + if (updateMarkerInfo) { + boolean singleExploreMarker = Utils.Explore.optSingleExploreMarker(marker); + Utils.Explore.updateCustomMarkerAppearance(getContext(), marker, singleExploreMarker, currentCameraZoom, cameraZoom, markerLayoutView, markerGroupLayoutView, iconGenerator); + } + } + } + cameraZoom = currentCameraZoom; + } + + private void updateMarkersVisibility() { + int currentFloorIndex = (mapControl != null) ? mapControl.getCurrentFloorIndex() : 0; + if (markers != null && !markers.isEmpty()) { + for (Marker marker : markers) { + Integer markerFloor = Utils.Explore.optMarkerLocationFloor(marker); + boolean markerVisible = (markerFloor == null) || (currentFloorIndex == markerFloor); + marker.setVisible(markerVisible); + } + } + } + + private boolean onMarkerClicked(Marker marker) { + Object rawData = Utils.Explore.optExploreMarkerRawData(marker); + if (rawData != null) { + if (rawData instanceof HashMap) { + Gson gson = new Gson(); + String rawDataToString = gson.toJson(rawData); + try { + rawData = new JSONObject(rawDataToString); + } catch (JSONException e) { + e.printStackTrace(); + } + } else if (rawData instanceof ArrayList) { + ArrayList rawDataList = (ArrayList) rawData; + rawData = new JSONArray(rawDataList); + } + JSONObject jsonArgs = new JSONObject(); + try { + jsonArgs.put("mapId", mapId); + jsonArgs.put("explore", rawData); + } catch (JSONException e) { + e.printStackTrace(); + } + String methodArguments = jsonArgs.toString(); + MainActivity.invokeFlutterMethod("map.explore.select", methodArguments); + return true; + } + return false; + } + + private boolean onMapClick(@NonNull LatLng latLng, @Nullable List list) { + JSONObject jsonArgs = new JSONObject(); + try { + jsonArgs.put("mapId", mapId); + } catch (JSONException e) { + e.printStackTrace(); + } + String methodArguments = jsonArgs.toString(); + MainActivity.invokeFlutterMethod("map.explore.clear", methodArguments); + return true; + } + + private void relocateMyLocationButton() { + if (googleMapView == null) { + return; + } + View firstView = googleMapView.findViewById(Integer.parseInt("1")); + if (firstView == null) { + return; + } + ViewParent parentView = firstView.getParent(); + if (!(parentView instanceof View)) { + return; + } + View myLocationButton = ((View) parentView).findViewById(Integer.parseInt("2")); + if (myLocationButton == null) { + return; + } + //Place it on bottom right + RelativeLayout.LayoutParams rlp = (RelativeLayout.LayoutParams) myLocationButton.getLayoutParams(); + rlp.addRule(RelativeLayout.ALIGN_PARENT_TOP, 0); + rlp.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, RelativeLayout.TRUE); + rlp.setMargins(0, 0, 30, 30); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/edu/illinois/covid/maps/MapViewController.java b/android/app/src/main/java/edu/illinois/covid/maps/MapViewController.java new file mode 100644 index 00000000..9e182ee3 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/maps/MapViewController.java @@ -0,0 +1,107 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.maps; + +import android.content.Context; +import android.util.Log; +import android.view.View; + +import java.util.ArrayList; +import java.util.HashMap; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry; +import io.flutter.plugin.platform.PlatformView; + +public class MapViewController implements PlatformView, MethodChannel.MethodCallHandler { + + private Context context; + private PluginRegistry.Registrar registrar; + private MapView mapView; + private MethodChannel channel; + + MapViewController(Context activityContext, PluginRegistry.Registrar registrar, int id, Object args) { + this.context = activityContext; + this.registrar = registrar; + mapView = new MapView(context, id, args); + channel = new MethodChannel(registrar.messenger(), "edu.illinois.covid/mapview_" + id); + + channel.setMethodCallHandler(this); + } + + @Override + public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) { + try { + if ("placePOIs".equals(methodCall.method)) { + showExploresOnMap(methodCall.arguments); + result.success(true); + } else if ("enable".equals(methodCall.method)) { + enableMap(methodCall.arguments); + result.success(true); + } else if ("enableMyLocation".equals(methodCall.method)) { + enableMyLocation(methodCall.arguments); + result.success(true); + } else { + result.notImplemented(); + } + } catch (IllegalStateException exception) { + String errorMsg = String.format("Ignoring exception '%s'. See https://github.com/flutter/flutter/issues/29092 for details.", exception.toString()); + Log.e("MapView", errorMsg); + exception.printStackTrace(); + } + } + + @Override + public View getView() { + return mapView; + } + + @Override + public void dispose() { + if(mapView != null) { + mapView.onDestroy(); + } + } + + private void enableMap(Object enableObject) { + //Do not hide mapView, as initially GoogleMap shows blue screen for few seconds. + + //Boolean enableBool = ((enableObject instanceof Boolean)) ? ((Boolean) enableObject) : null; + //boolean enable = (enableBool != null) && enableBool; + //mapView.setVisibility(enable ? View.VISIBLE : View.INVISIBLE); + } + + private void enableMyLocation(Object enableObject) { + Boolean enableBool = ((enableObject instanceof Boolean)) ? ((Boolean) enableObject) : null; + boolean enable = (enableBool != null) && enableBool; + mapView.enableMyLocation(enable); + } + + private void showExploresOnMap(Object params) { + ArrayList explores = null; + HashMap options = null; + if (params instanceof HashMap) { + HashMap map = (HashMap) params; + explores = (ArrayList) map.get("explores"); + options = (HashMap) map.get("options"); + } + if (mapView != null) { + mapView.applyExplores(explores, options); + } + } +} diff --git a/android/app/src/main/java/edu/illinois/covid/maps/MapViewFactory.java b/android/app/src/main/java/edu/illinois/covid/maps/MapViewFactory.java new file mode 100644 index 00000000..b8c58b56 --- /dev/null +++ b/android/app/src/main/java/edu/illinois/covid/maps/MapViewFactory.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.illinois.covid.maps; + +import android.content.Context; + +import io.flutter.plugin.common.PluginRegistry; +import io.flutter.plugin.common.StandardMessageCodec; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugin.platform.PlatformViewFactory; + +public class MapViewFactory extends PlatformViewFactory { + + private Context activityContext; + private final PluginRegistry.Registrar mPluginRegistrar; + + public MapViewFactory(Context context, PluginRegistry.Registrar registrar) { + super(StandardMessageCodec.INSTANCE); + this.activityContext = context; + this.mPluginRegistrar = registrar; + } + + @Override + public PlatformView create(Context context, int i, Object args) { + return new MapViewController(activityContext, mPluginRegistrar, i, args); + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java new file mode 100644 index 00000000..43ccd2fc --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -0,0 +1,65 @@ +package io.flutter.plugins; + +import io.flutter.plugin.common.PluginRegistry; +import de.mintware.barcode_scan.BarcodeScanPlugin; +import io.flutter.plugins.connectivity.ConnectivityPlugin; +import io.flutter.plugins.deviceinfo.DeviceInfoPlugin; +import io.flutter.plugins.firebase.core.FirebaseCorePlugin; +import io.flutter.plugins.firebase.crashlytics.firebasecrashlytics.FirebaseCrashlyticsPlugin; +import io.flutter.plugins.firebasemessaging.FirebaseMessagingPlugin; +import io.flutter.plugins.firebasemlvision.FirebaseMlVisionPlugin; +import com.example.flutterimagecompress.FlutterImageCompressPlugin; +import com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin; +import com.whelksoft.flutter_native_timezone.FlutterNativeTimezonePlugin; +import io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin; +import io.github.ponnamkarthik.toast.fluttertoast.FluttertoastPlugin; +import io.flutter.plugins.imagepicker.ImagePickerPlugin; +import com.lyokone.location.LocationPlugin; +import io.flutter.plugins.packageinfo.PackageInfoPlugin; +import io.flutter.plugins.pathprovider.PathProviderPlugin; +import io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin; +import com.tekartik.sqflite.SqflitePlugin; +import name.avioli.unilinks.UniLinksPlugin; +import io.flutter.plugins.urllauncher.UrlLauncherPlugin; +import io.flutter.plugins.webviewflutter.WebViewFlutterPlugin; + +/** + * Generated file. Do not edit. + */ +public final class GeneratedPluginRegistrant { + public static void registerWith(PluginRegistry registry) { + if (alreadyRegisteredWith(registry)) { + return; + } + BarcodeScanPlugin.registerWith(registry.registrarFor("de.mintware.barcode_scan.BarcodeScanPlugin")); + ConnectivityPlugin.registerWith(registry.registrarFor("io.flutter.plugins.connectivity.ConnectivityPlugin")); + DeviceInfoPlugin.registerWith(registry.registrarFor("io.flutter.plugins.deviceinfo.DeviceInfoPlugin")); + FirebaseCorePlugin.registerWith(registry.registrarFor("io.flutter.plugins.firebase.core.FirebaseCorePlugin")); + FirebaseCrashlyticsPlugin.registerWith(registry.registrarFor("io.flutter.plugins.firebase.crashlytics.firebasecrashlytics.FirebaseCrashlyticsPlugin")); + FirebaseMessagingPlugin.registerWith(registry.registrarFor("io.flutter.plugins.firebasemessaging.FirebaseMessagingPlugin")); + FirebaseMlVisionPlugin.registerWith(registry.registrarFor("io.flutter.plugins.firebasemlvision.FirebaseMlVisionPlugin")); + FlutterImageCompressPlugin.registerWith(registry.registrarFor("com.example.flutterimagecompress.FlutterImageCompressPlugin")); + FlutterLocalNotificationsPlugin.registerWith(registry.registrarFor("com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin")); + FlutterNativeTimezonePlugin.registerWith(registry.registrarFor("com.whelksoft.flutter_native_timezone.FlutterNativeTimezonePlugin")); + FlutterAndroidLifecyclePlugin.registerWith(registry.registrarFor("io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin")); + FluttertoastPlugin.registerWith(registry.registrarFor("io.github.ponnamkarthik.toast.fluttertoast.FluttertoastPlugin")); + ImagePickerPlugin.registerWith(registry.registrarFor("io.flutter.plugins.imagepicker.ImagePickerPlugin")); + LocationPlugin.registerWith(registry.registrarFor("com.lyokone.location.LocationPlugin")); + PackageInfoPlugin.registerWith(registry.registrarFor("io.flutter.plugins.packageinfo.PackageInfoPlugin")); + PathProviderPlugin.registerWith(registry.registrarFor("io.flutter.plugins.pathprovider.PathProviderPlugin")); + SharedPreferencesPlugin.registerWith(registry.registrarFor("io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")); + SqflitePlugin.registerWith(registry.registrarFor("com.tekartik.sqflite.SqflitePlugin")); + UniLinksPlugin.registerWith(registry.registrarFor("name.avioli.unilinks.UniLinksPlugin")); + UrlLauncherPlugin.registerWith(registry.registrarFor("io.flutter.plugins.urllauncher.UrlLauncherPlugin")); + WebViewFlutterPlugin.registerWith(registry.registrarFor("io.flutter.plugins.webviewflutter.WebViewFlutterPlugin")); + } + + private static boolean alreadyRegisteredWith(PluginRegistry registry) { + final String key = GeneratedPluginRegistrant.class.getCanonicalName(); + if (registry.hasPlugin(key)) { + return true; + } + registry.registrarFor(key); + return false; + } +} diff --git a/android/app/src/main/res/drawable-hdpi/splash_image.png b/android/app/src/main/res/drawable-hdpi/splash_image.png new file mode 100644 index 00000000..a0e8aa5e Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash_image.png differ diff --git a/android/app/src/main/res/drawable-ldpi/splash_image.png b/android/app/src/main/res/drawable-ldpi/splash_image.png new file mode 100644 index 00000000..2d9a6a28 Binary files /dev/null and b/android/app/src/main/res/drawable-ldpi/splash_image.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash_image.png b/android/app/src/main/res/drawable-mdpi/splash_image.png new file mode 100644 index 00000000..3990dbed Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash_image.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash_image.png b/android/app/src/main/res/drawable-xhdpi/splash_image.png new file mode 100644 index 00000000..19fc7b20 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash_image.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash_image.png b/android/app/src/main/res/drawable-xxhdpi/splash_image.png new file mode 100644 index 00000000..170b006a Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash_image.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash_image.png b/android/app/src/main/res/drawable-xxxhdpi/splash_image.png new file mode 100644 index 00000000..fb024a7c Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash_image.png differ diff --git a/android/app/src/main/res/drawable/app_icon.png b/android/app/src/main/res/drawable/app_icon.png new file mode 100644 index 00000000..d233e3a2 Binary files /dev/null and b/android/app/src/main/res/drawable/app_icon.png differ diff --git a/android/app/src/main/res/drawable/button_icon_nav_clear.png b/android/app/src/main/res/drawable/button_icon_nav_clear.png new file mode 100644 index 00000000..e1955794 Binary files /dev/null and b/android/app/src/main/res/drawable/button_icon_nav_clear.png differ diff --git a/android/app/src/main/res/drawable/button_icon_nav_location.png b/android/app/src/main/res/drawable/button_icon_nav_location.png new file mode 100644 index 00000000..0b1e58e7 Binary files /dev/null and b/android/app/src/main/res/drawable/button_icon_nav_location.png differ diff --git a/android/app/src/main/res/drawable/button_icon_nav_next.png b/android/app/src/main/res/drawable/button_icon_nav_next.png new file mode 100644 index 00000000..6499528e Binary files /dev/null and b/android/app/src/main/res/drawable/button_icon_nav_next.png differ diff --git a/android/app/src/main/res/drawable/button_icon_nav_prev.png b/android/app/src/main/res/drawable/button_icon_nav_prev.png new file mode 100644 index 00000000..d4b59a8f Binary files /dev/null and b/android/app/src/main/res/drawable/button_icon_nav_prev.png differ diff --git a/android/app/src/main/res/drawable/button_icon_nav_refresh.png b/android/app/src/main/res/drawable/button_icon_nav_refresh.png new file mode 100644 index 00000000..2ffd0eff Binary files /dev/null and b/android/app/src/main/res/drawable/button_icon_nav_refresh.png differ diff --git a/android/app/src/main/res/drawable/chevron_left.png b/android/app/src/main/res/drawable/chevron_left.png new file mode 100755 index 00000000..0bf34aba Binary files /dev/null and b/android/app/src/main/res/drawable/chevron_left.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..2dcc98c3 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/marker_default_teal.png b/android/app/src/main/res/drawable/marker_default_teal.png new file mode 100644 index 00000000..b8a56840 Binary files /dev/null and b/android/app/src/main/res/drawable/marker_default_teal.png differ diff --git a/android/app/src/main/res/drawable/marker_dining.png b/android/app/src/main/res/drawable/marker_dining.png new file mode 100644 index 00000000..e608e4f0 Binary files /dev/null and b/android/app/src/main/res/drawable/marker_dining.png differ diff --git a/android/app/src/main/res/drawable/marker_event.png b/android/app/src/main/res/drawable/marker_event.png new file mode 100644 index 00000000..8646ec86 Binary files /dev/null and b/android/app/src/main/res/drawable/marker_event.png differ diff --git a/android/app/src/main/res/drawable/marker_group_shape.xml b/android/app/src/main/res/drawable/marker_group_shape.xml new file mode 100644 index 00000000..92eaaad5 --- /dev/null +++ b/android/app/src/main/res/drawable/marker_group_shape.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/splash_image.png b/android/app/src/main/res/drawable/splash_image.png new file mode 100644 index 00000000..a0e8aa5e Binary files /dev/null and b/android/app/src/main/res/drawable/splash_image.png differ diff --git a/android/app/src/main/res/drawable/travel_mode_bicycle.png b/android/app/src/main/res/drawable/travel_mode_bicycle.png new file mode 100644 index 00000000..e3d7dcad Binary files /dev/null and b/android/app/src/main/res/drawable/travel_mode_bicycle.png differ diff --git a/android/app/src/main/res/drawable/travel_mode_drive.png b/android/app/src/main/res/drawable/travel_mode_drive.png new file mode 100644 index 00000000..db3fa67d Binary files /dev/null and b/android/app/src/main/res/drawable/travel_mode_drive.png differ diff --git a/android/app/src/main/res/drawable/travel_mode_transit.png b/android/app/src/main/res/drawable/travel_mode_transit.png new file mode 100644 index 00000000..60d26d2d Binary files /dev/null and b/android/app/src/main/res/drawable/travel_mode_transit.png differ diff --git a/android/app/src/main/res/drawable/travel_mode_unknown.png b/android/app/src/main/res/drawable/travel_mode_unknown.png new file mode 100644 index 00000000..a39d5231 Binary files /dev/null and b/android/app/src/main/res/drawable/travel_mode_unknown.png differ diff --git a/android/app/src/main/res/drawable/travel_mode_walk.png b/android/app/src/main/res/drawable/travel_mode_walk.png new file mode 100644 index 00000000..79bd533b Binary files /dev/null and b/android/app/src/main/res/drawable/travel_mode_walk.png differ diff --git a/android/app/src/main/res/drawable/white_bg_black_border_shape.xml b/android/app/src/main/res/drawable/white_bg_black_border_shape.xml new file mode 100644 index 00000000..6a67c1ac --- /dev/null +++ b/android/app/src/main/res/drawable/white_bg_black_border_shape.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/font/proximanova_extrabold.otf b/android/app/src/main/res/font/proximanova_extrabold.otf new file mode 100755 index 00000000..cc2b8c8e Binary files /dev/null and b/android/app/src/main/res/font/proximanova_extrabold.otf differ diff --git a/android/app/src/main/res/font/proximanova_semibold.otf b/android/app/src/main/res/font/proximanova_semibold.otf new file mode 100755 index 00000000..5436663e Binary files /dev/null and b/android/app/src/main/res/font/proximanova_semibold.otf differ diff --git a/android/app/src/main/res/layout/map_layout.xml b/android/app/src/main/res/layout/map_layout.xml new file mode 100644 index 00000000..e48df3c4 --- /dev/null +++ b/android/app/src/main/res/layout/map_layout.xml @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/map_pick_location_layout.xml b/android/app/src/main/res/layout/map_pick_location_layout.xml new file mode 100644 index 00000000..f611f999 --- /dev/null +++ b/android/app/src/main/res/layout/map_pick_location_layout.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/marker_group_layout.xml b/android/app/src/main/res/layout/marker_group_layout.xml new file mode 100644 index 00000000..c03e2940 --- /dev/null +++ b/android/app/src/main/res/layout/marker_group_layout.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/marker_info_layout.xml b/android/app/src/main/res/layout/marker_info_layout.xml new file mode 100644 index 00000000..d22fa51e --- /dev/null +++ b/android/app/src/main/res/layout/marker_info_layout.xml @@ -0,0 +1,56 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..bdf8e251 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 00000000..ee44d111 Binary files /dev/null and b/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..916e3734 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..1393cf3c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..7373ee05 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..f46e189a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap/ic_launcher.png b/android/app/src/main/res/mipmap/ic_launcher.png new file mode 100644 index 00000000..bdf8e251 Binary files /dev/null and b/android/app/src/main/res/mipmap/ic_launcher.png differ diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml new file mode 100644 index 00000000..964643cb --- /dev/null +++ b/android/app/src/main/res/values-es/strings.xml @@ -0,0 +1,61 @@ + + + + + Safer Illinois + + Hoy + Mañana + a + + Explora + Eventos + Opciones para comer + Lugares + Lavanderías + Estacionamientos + OK + + + Direcciones + No se pudo encontrar la ruta. + No se pudo detectar la ubicación actual. + COMIENZO + TERMINAR + %1$d m | %2$d seg + Pierna %1$d / Paso %2$d + milla + millas + min + + + Mapas + + + Elegir ubicación + PERSONALIZADO + Por favor, seleccione una ubicación + Ubicación: %1$s + Salvar + + + Notificación de seguimiento de contactos del edificio + Seguimiento de contactos + El seguimiento de contactos para preservar la privacidad se está ejecutando actualmente + Seguimiento de contactos + Permitir notificaciones de exposición. ¿Quieres encender el bluetooth? + \ No newline at end of file diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml new file mode 100644 index 00000000..b0d49114 --- /dev/null +++ b/android/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,61 @@ + + + + + Safer Illinois + + 今天 + 明天 + + + 探索 + 大事記 + 餐飲選擇 + 地方 + 洗衣房 + 停車場 + + + + 方向 + 找不到路線. + 無法檢測到當前位置. + 開始 + + %1$d 分 | %2$d 秒 + 腿 %1$d / 步 %2$d + 英里 + 英里 + + + + 地圖 + + + 選擇地點 + 定制 + 請選擇一個位置 + 位置: %1$s + 保存 + + + 建築物聯繫人跟踪通知 + 聯繫人跟踪 + 當前正在運行保護隱私的聯繫人跟踪 + 聯繫人跟踪 + 允許曝光通知. 您要打開藍牙嗎? + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..8e14fe2d --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,30 @@ + + + + #ff13294b + #ff0e203b + #ffe84a27 + #fff29835 + #ff5fa7a3 + #00000000 + @color/darkBlueGrey + + #ffffff + #000000 + #99ffffff + #66606060 + \ No newline at end of file diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..83823356 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,22 @@ + + + + + 22dp + 26dp + 30dp + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..e11bd99e --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,61 @@ + + + + + Safer Illinois + + Today + Tomorrow + at + + Explores + Events + Dining Options + Places + Laundries + Parking lots + OK + + + Directions + Failed to find route. + Failed to detect current location. + START + FINISH + %1$d m | %2$d sec + Leg %1$d / Step %2$d + mile + miles + min + + + Maps + + + Pick Location + CUSTOM + Please, select a location + Location: %1$s + Save + + + Building contact tracing notification + Contact tracing + Privacy-preserving contact tracing is currently running + Contact tracing + Allow exposure notifications. Do you want to turn the bluetooth on? + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..3ac51923 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,29 @@ + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..b050c82c --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..ad5a92b4 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +buildscript { + ext { + gradle_version = '3.5.3' + kotlin_version = '1.3.61' + } + repositories { + google() + jcenter() + maven { url 'https://maven.fabric.io/public' } + } + + dependencies { + classpath "com.android.tools.build:gradle:$gradle_version" + classpath "io.fabric.tools:gradle:1.31.0" + classpath "com.google.gms:google-services:4.3.3" + } +} + +allprojects { + repositories { + google() + jcenter() + flatDir { + dirs '../lib' + } + maven { url 'https://maven.microblink.com' } + maven { url 'https://jitpack.io' } + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..96af46b9 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,20 @@ +# +# Copyright 2020 Board of Trustees of the University of Illinois. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +org.gradle.jvmargs=-Xmx1536M +android.enableJetifier=true +android.useAndroidX=true +android.enableR8=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..13372aef Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b55a0129 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,22 @@ +# +# Copyright 2020 Board of Trustees of the University of Illinois. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +#Tue Oct 22 15:06:41 EEST 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 00000000..aec99730 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/illini_android.iml b/android/illini_android.iml new file mode 100644 index 00000000..269c4d74 --- /dev/null +++ b/android/illini_android.iml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 00000000..83b42406 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/assets/assets.json b/assets/assets.json new file mode 100644 index 00000000..27854803 --- /dev/null +++ b/assets/assets.json @@ -0,0 +1,1634 @@ +{ + "dining": { + "food_types": ["Halal", "Kosher", "Sustainable Seafood", "Vegan", "Vegetarian"], + "food_ingredients": ["Alcohol", "Coconut", "Corn", "Eggs", "Fish", "Gelatin", "Gluten", "Milk", "MSG", "Peanuts", "Pork", "Red Dye", "Sesame", "Shellfish", "Soy", "Sulfites", "Tree Nuts", "Wheat"], + "strings": { + "en": {"Halal":"Halal", "Kosher":"Kosher", "Sustainable Seafood":"Sustainable Seafood", "Vegan":"Vegan", "Vegetarian":"Vegetarian", "Alcohol":"Alcohol", "Coconut":"Coconut", "Corn":"Corn", "Eggs":"Eggs", "Fish":"Fish", "Gelatin":"Gelatin", "Gluten":"Gluten", "Milk":"Milk", "MSG":"MSG", "Peanuts":"Peanuts", "Pork":"Pork", "Red Dye":"Red Dye", "Sesame":"Sesame", "Shellfish":"Shellfish", "Soy":"Soy", "Sulfites":"Sulfites", "Tree Nuts":"Tree Nuts", "Wheat":"Wheat", "Serving":"Serving", "Calories":"Calories", "CaloriesFromFat":"Calories From Fat", "SaturatedFat":"Saturated Fat", "Transfat":"Transfat", "PolyUnsaturatedFat":"Poly Unsaturated Fat", "CalciumPercent":"Calcium Percent", "IronPercent":"Iron Percent", "Sugars":"Sugars", "TotalCarbs":"Total Carbs", "DietaryFiber":"Dietary Fiber", "Protein":"Protein", "TotalFat":"Total Fat", "Cholesterol":"Cholesterol", "Sodium":"Sodium"}, + "es": {"Halal":"Halal", "Kosher":"Kosher", "Sustainable Seafood":"Sustainable Seafood", "Vegan":"Vegan", "Vegetarian":"Vegetarian", "Alcohol":"Alcohol", "Coconut":"Coconut", "Corn":"Corn", "Eggs":"Eggs", "Fish":"Fish", "Gelatin":"Gelatin", "Gluten":"Gluten", "Milk":"Milk", "MSG":"MSG", "Peanuts":"Peanuts", "Pork":"Pork", "Red Dye":"Red Dye", "Sesame":"Sesame", "Shellfish":"Shellfish", "Soy":"Soy", "Sulfites":"Sulfites", "Tree Nuts":"Tree Nuts", "Wheat":"Wheat", "Serving":"Porción", "Calories":"Calorías", "CaloriesFromFat":"Calorías de la grasa", "SaturatedFat":"Grasas saturadas", "Transfat":"Grasa Trans", "PolyUnsaturatedFat":"Grasa poliinsaturada", "CalciumPercent":"Porcentaje de calcio", "IronPercent":"Porcentaje de hierro", "Sugars":"Azúcares", "TotalCarbs":"Carbohidratos totales", "DietaryFiber":"Fibra dietética", "Protein":"Proteína", "TotalFat":"Grasa total", "Cholesterol":"Colesterol", "Sodium":"Sodio"}, + "zh": {"Halal":"Halal", "Kosher":"Kosher", "Sustainable Seafood":"Sustainable Seafood", "Vegan":"Vegan", "Vegetarian":"Vegetarian", "Alcohol":"Alcohol", "Coconut":"Coconut", "Corn":"Corn", "Eggs":"Eggs", "Fish":"Fish", "Gelatin":"Gelatin", "Gluten":"Gluten", "Milk":"Milk", "MSG":"MSG", "Peanuts":"Peanuts", "Pork":"Pork", "Red Dye":"Red Dye", "Sesame":"Sesame", "Shellfish":"Shellfish", "Soy":"Soy", "Sulfites":"Sulfites", "Tree Nuts":"Tree Nuts", "Wheat":"Wheat", "Serving":"服务", "Calories":"卡路里", "CaloriesFromFat":"来自脂肪的卡路里", "SaturatedFat":"饱和脂肪", "Transfat":"反式脂肪", "PolyUnsaturatedFat":"多不饱和脂肪", "CalciumPercent":"钙百分比", "IronPercent":"铁百分比", "Sugars":"糖", "TotalCarbs":"碳水化合物总量", "DietaryFiber":"膳食纤维", "Protein":"蛋白质", "TotalFat":"总脂肪", "Cholesterol":"胆固醇", "Sodium":"钠"} + } + }, + + "sports": { + "types":[ + {"shortName": "baseball", "name":"Baseball", "custom_name":"Baseball", "hasPosition":true, "hasHeight":true, "hasWeight":true, "hasSortByPosition":true, "hasSortByNumber":true, "hasScores":true, "gender":"men", "ticketed":false, "icon":"images/athletics-baseball-orange.png"}, + {"shortName": "mbball", "name":"Men's Basketball", "custom_name":"Basketball", "hasPosition":true, "hasHeight":true, "hasWeight":true, "hasSortByPosition":true, "hasSortByNumber":true, "hasScores":true, "gender":"men", "ticketed":true, "icon":"images/athletics-basketball-orange.png"}, + {"shortName": "mcross", "name":"Men's Cross Country", "custom_name":"Cross Country", "hasPosition":false, "hasHeight":false, "hasWeight":false, "hasSortByPosition":false, "hasSortByNumber":false, "gender":"men", "ticketed":false, "icon":"images/athletics-cross-orange.png"}, + {"shortName": "football", "name":"Football", "custom_name":"Football", "hasPosition":true, "hasHeight":true, "hasWeight":true, "hasSortByPosition":true, "hasSortByNumber":true, "hasScores":true, "gender":"men", "ticketed":true, "icon":"images/athletics-football-orange.png"}, + {"shortName": "mgolf", "name":"Men's Golf", "custom_name":"Golf", "hasPosition":false, "hasHeight":false, "hasWeight":false, "hasSortByPosition":false, "hasSortByNumber":false, "gender":"men", "ticketed":false, "icon":"images/athletics-golf-orange.png"}, + {"shortName": "mgym", "name":"Men's Gymnastics", "custom_name":"Gymnastics", "hasPosition":true, "hasHeight":false, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":true, "gender":"men", "ticketed":false, "icon":"images/athletics-gymnastics-orange.png"}, + {"shortName": "mten", "name":"Men's Tennis", "custom_name":"Tennis", "hasPosition":false, "hasHeight":true, "hasWeight":false, "hasSortByPosition":false, "hasSortByNumber":false, "hasScores":true, "gender":"men", "ticketed":false, "icon":"images/athletics-tennis-orange.png"}, + {"shortName": "mtrack", "name":"Men's Track & Field", "custom_name":"Track & Field", "hasPosition":true, "hasHeight":false, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":false, "gender":"men", "ticketed":false, "icon":"images/athletics-track-orange.png"}, + {"shortName": "wrestling", "name":"Men's Wrestling", "custom_name":"Wrestling", "hasPosition":false, "hasHeight":true, "hasWeight":true, "hasSortByPosition":false, "hasSortByNumber":false, "gender":"men", "ticketed":false, "icon":"images/athletics-wrestling-orange.png"}, + {"shortName": "wbball", "name":"Women's Basketball", "custom_name":"Basketball", "hasPosition":true, "hasHeight":true, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":true, "hasScores":true, "gender":"women", "ticketed":true, "icon":"images/athletics-basketball-orange.png"}, + {"shortName": "wcross", "name":"Women's Cross Country", "custom_name":"Cross Country", "hasPosition":false, "hasHeight":false, "hasWeight":false, "hasSortByPosition":false, "hasSortByNumber":false, "gender":"women", "ticketed":false, "icon":"images/athletics-cross-orange.png"}, + {"shortName": "wgolf", "name":"Women's Golf", "custom_name":"Golf", "hasPosition":false, "hasHeight":false, "hasWeight":false, "hasSortByPosition":false, "hasSortByNumber":false, "gender":"women", "ticketed":false, "icon":"images/athletics-golf-orange.png"}, + {"shortName": "wgym", "name":"Women's Gymnastics", "custom_name":"Gymnastics", "hasPosition":true, "hasHeight":true, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":false, "gender":"women", "ticketed":false, "icon":"images/athletics-gymnastics-orange.png"}, + {"shortName": "wsoc", "name":"Women's Soccer", "custom_name":"Soccer", "hasPosition":true, "hasHeight":true, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":true, "hasScores":true, "gender":"women", "ticketed":false, "icon":"images/athletics-soccer-orange.png"}, + {"shortName": "softball", "name":"Softball", "custom_name":"Softball", "hasPosition":true, "hasHeight":true, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":true, "hasScores":true, "gender":"women", "ticketed":false, "icon":"images/athletics-softball-orange.png"}, + {"shortName": "wswim", "name":"Women's Swimming & Diving", "custom_name":"Swimming & Diving", "hasPosition":true, "hasHeight":false, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":false, "gender":"women", "ticketed":false, "icon":"images/athletics-swim-orange.png"}, + {"shortName": "wten", "name":"Women's Tennis", "custom_name":"Tennis", "hasPosition":false, "hasHeight":false, "hasWeight":false, "hasSortByPosition":false, "hasSortByNumber":false, "hasScores":true, "gender":"women", "ticketed":false, "icon":"images/athletics-tennis-orange.png"}, + {"shortName": "wtrack", "name":"Women's Track & Field", "custom_name":"Track & Field", "hasPosition":true, "hasHeight":false, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":false, "gender":"women", "ticketed":false, "icon":"images/athletics-track-orange.png"}, + {"shortName": "wvball", "name":"Women's Volleyball", "custom_name":"Volleyball", "hasPosition":true, "hasHeight":true, "hasWeight":false, "hasSortByPosition":true, "hasSortByNumber":true, "hasScores":true, "gender":"women", "ticketed":true, "icon":"images/athletics-volleyball-orange.png"} + ] + }, + + "images": { + "random": { + "events":{ + "Academic": ["https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/536ab9a5-c9c8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ba94d55d-c9ca-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/8d80c73d-cab8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/5c8ecdc2-cab8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/e7eac331-cab8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/1901c69c-cab9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/41b41fcd-cab9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/41b41fcd-cab9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/98296276-cab9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b2ef73d2-cab9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/d24aa5df-cab9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/fe1ce39d-cab9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/2b12d45f-caba-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/50436052-caba-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/86e656ff-caba-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/bff31abc-caba-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/e1f30139-caba-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/02ea99ca-cabb-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/31840f7d-cabb-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/604d7a95-cabb-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ae46c0de-cabb-11e9-88b6-0a58a9feac2a.webp"], + "Entertainment": ["https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/a200ab0c-c9cc-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/dc059d5d-c9cc-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/4bd2d215-cde2-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/960743f8-cde2-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b5c38d95-cde2-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/d752bce5-cde2-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ef962306-cde2-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/0b591886-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/231194d6-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/3f742b15-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/6149506f-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/76a50491-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/909f42ab-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/a82f30df-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/c43ba19d-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/e409138f-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ff7f0567-cde3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/16dd1855-cde4-11e9-88b6-0a58a9feac2a.webp"], + "Community": ["https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/3adfe066-c9ce-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/6bb3f9ad-c9ce-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/4085db95-cdf8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/5a807ff5-cdf8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b2c378cf-cdf8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/cb2c02a8-cdf8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/e41bc78a-cdf8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ffaeef8a-cdf8-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/179a9726-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/3081cc2e-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/49256c79-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/68254af9-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/860161c1-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/9e2c7cfd-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b2178b24-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/c92a1089-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/df397ce2-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/f8a5f87e-cdf9-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/121d971e-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/2c900ef3-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/42fcbae0-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/5b5335cd-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/707bc956-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/8b8c01c8-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/a383594c-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b989b49e-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/d0e119a8-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/e5bca07a-cdfa-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/00ac8410-cdfb-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/13aa3f3e-cdfb-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/2978c9dc-cdfb-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/3da9c481-cdfb-11e9-88b6-0a58a9feac2a.webp"], + "Career Development": ["https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/31c7ffd8-c9d4-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/6f192cf4-c9d4-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/35b8a0a3-cf63-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/4d7c80f3-cf63-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/6a1b897c-cf63-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/991422fd-cf63-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/aec5f88a-cf63-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/d130c6c5-cf63-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/efccd5cd-cf63-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/10d5f5af-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/3d3741b6-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/5c99bfb1-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/76c5201c-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/8b203e82-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/aa884883-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/c1f1b2f2-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/dd587562-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/f95bc33b-cf64-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/12e89ad3-cf65-11e9-88b6-0a58a9feac2a.webp"], + "Athletics": ["https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b475bd11-c9d4-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/d604fc38-c9d4-11e9-88b6-0a58a9feac2a.webp"], + "Other": ["https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/2890eff7-c9d5-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/51cb8f85-c9d5-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ec0606d4-d260-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/05874ca8-d261-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/1fa1b6f0-d261-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/36b454f9-d261-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/4d715915-d261-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/73410ea5-d261-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/89b66a33-d261-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/a06eaea5-d261-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b86cb864-d261-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/04e35b53-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/3fc504de-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/59e10e64-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/7a8735e6-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/97131e28-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ad2a0f39-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/c725839a-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/e4aa8b4a-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ff7d982d-d262-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/1d45ec8a-d263-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/3b49b1c0-d263-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/53943299-d263-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/73441f37-d263-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/8cada753-d263-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/a7fc273c-d263-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/c711f5f2-d263-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/f93611a0-d263-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/108cec19-d264-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/2f74f16b-d264-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/4d8e7b51-d264-11e9-88b6-0a58a9feac2a.webp"], + "Recreation": ["https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/c085d844-c9d5-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/1d89fdf1-c9d6-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/0ecf5ef2-d265-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/24bba90b-d265-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/1299a8cb-d296-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/37803339-d296-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/51e00fb5-d296-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/740bca52-d296-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/9a3871f5-d296-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b4dd8f5c-d296-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/dd84d091-d296-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/fe42ecf2-d296-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/23cd21e7-d297-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/4b499ff8-d297-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/74dbadd4-d297-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/8bc0c32b-d297-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/a654ab5d-d297-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/bfd6a22d-d297-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/e2a4b86c-d297-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/fb4c18a2-d297-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/1b26aa99-d298-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/36aada45-d298-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/4fac59ef-d298-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/741709bd-d298-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/8a794bad-d298-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/a2c995d6-d298-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/b8169210-d298-11e9-88b6-0a58a9feac2a.webp"] + }, + "sports":{ + "baseball": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/baseball/tout/7746fb3f-d36f-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/baseball/tout/cb753691-d36f-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/baseball/tout/e807bab6-d9b0-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/baseball/tout/03ed7808-d9b1-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/baseball/tout/1fa33439-d9b1-11e9-88b6-0a58a9feac2a.webp"], + "mbball": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/01dbdaff-d370-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/25ebd2ee-d370-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/4595ef6b-d370-11e9-88b6-0a58a9feac2a.webp"], + "mcross": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/crosscountry/tout/200cd2f3-d29a-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/crosscountry/tout/8d435b9b-d29a-11e9-88b6-0a58a9feac2a.webp"], + "football": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/football/tout/d8cbf697-cab6-11e9-88b6-0a58a9feac2a.webp","https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/football/tout/038e18c5-cab7-11e9-88b6-0a58a9feac2a.webp","https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/football/tout/24870af6-cab7-11e9-88b6-0a58a9feac2a.webp","https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/football/tout/4455ebec-cab7-11e9-88b6-0a58a9feac2a.webp","https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/football/tout/63fda99f-cab7-11e9-88b6-0a58a9feac2a.webp"], + "mgolf": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/8b0c9120-d370-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/aaf66464-d370-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/cb0c922f-d370-11e9-88b6-0a58a9feac2a.webp"], + "mgym": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/f677a5c2-d370-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/16e511a6-d371-11e9-88b6-0a58a9feac2a.webp"], + "mten": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/tennis/tout/5b20eef9-d371-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/tennis/tout/769e10f6-d371-11e9-88b6-0a58a9feac2a.webp"], + "mtrack": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/trackandfield/tout/c58e5654-d29c-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/trackandfield/tout/aaf95c42-d29d-11e9-88b6-0a58a9feac2a.webp"], + "wrestling": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/wrestling/tout/04783cae-d29b-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/wrestling/tout/10cfd476-d29c-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/wrestling/tout/f9da35cb-d9af-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/wrestling/tout/15f81545-d9b0-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/wrestling/tout/2d85f86c-d9b0-11e9-88b6-0a58a9feac2a.webp"], + "wbball": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/b663be7a-d371-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/ded21df5-d371-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/fcdac77b-d371-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/611e2428-d816-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/80d688eb-d816-11e9-88b6-0a58a9feac2a.webp"], + "wcross": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/crosscountry/tout/33b3ac5b-d372-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/crosscountry/tout/5008312a-d372-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/crosscountry/tout/482d69f4-d9af-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/crosscountry/tout/696e1fe8-d9af-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/crosscountry/tout/81933383-d9af-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/crosscountry/tout/9840bee9-d9af-11e9-88b6-0a58a9feac2a.webp"], + "wgolf": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/7a3276a7-d372-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/8dbba2b8-d372-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/be5fce7b-d9ad-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/da5837f3-d9ad-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/f4da3ab2-d9ad-11e9-88b6-0a58a9feac2a.webp"], + "wgym": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/ae63d3c2-d372-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/cc48ed21-d372-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/e6d01799-d372-11e9-88b6-0a58a9feac2a.webp"], + "wsoc": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/79cdfd2e-d29f-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/119a5d80-d373-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/football/tout/fc95e333-d8d3-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/football/tout/1efe4a3a-d8d4-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/football/tout/40140e24-d8d4-11e9-88b6-0a58a9feac2a.webp"], + "softball": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/42213b9c-d373-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/golf/tout/5f4c633c-d373-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/baseball/tout/62fdc77b-d817-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/baseball/tout/93dfecae-d817-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/baseball/tout/b0a2bdb0-d817-11e9-88b6-0a58a9feac2a.webp"], + "wswim": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/a027b8dc-d373-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/c0c87e50-d373-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/d81943ef-d373-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/0b6484ab-d8d5-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/3237cfb7-d8d5-11e9-88b6-0a58a9feac2a.webp"], + "wten": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/tennis/tout/0a965cd0-d374-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/tennis/tout/61c1817d-d815-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/tennis/tout/85318155-d815-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/tennis/tout/ac9c35c6-d815-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/tennis/tout/cfc157cf-d815-11e9-88b6-0a58a9feac2a.webp"], + "wtrack": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/trackandfield/tout/3912674b-d374-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/trackandfield/tout/55934f55-d374-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/trackandfield/tout/6f84a592-d374-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/trackandfield/tout/85507adb-d374-11e9-88b6-0a58a9feac2a.webp"], + "wvball": ["https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/b5b2c188-d29e-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/gimnastics/tout/e25bc8f6-d29e-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/642c5674-d814-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/907ac2c7-d814-11e9-88b6-0a58a9feac2a.webp", "https://rokwire-images.s3.us-east-2.amazonaws.com/athletics/basketball/tout/b6963a95-d814-11e9-88b6-0a58a9feac2a.webp"] + } + } + }, + + "reminders": { + "content":[ + {"id":"ST20200508","date":"2020-05-08","label":"Final Exams Begin (Spring 2020)"}, + {"id":"ST20200515","date":"2020-05-15","label":"Final Exams End (Spring 2020)"}, + {"id":"SD20200515","date":"2020-05-15","label":"Deadline to submit a Certified Housing Reciprocal Release Application for Fall 2020 for continuing students"}, + {"id":"SA20200515","date":"2020-05-15","label":"Student health insurance change/waiver period begins for coverage period May 16, 2020 - August 21, 2020"}, + {"id":"SD20200516","date":"2020-05-16","label":"University Housing undergraduate residence halls close at 3 pm"}, + {"id":"2ST0200619","date":"2020-06-19","label":"Student health insurance change/waiver period ends for coverage period May 16, 2020 - August 21, 2020"} + ] + }, + + "geo_fence": { + "regions": [ + {"id":"@MH", "types":["test", "voter"], "name":"633 Trumbull Ave, Novato, CA 94947, USA", "enabled": true, "location": {"latitude": 38.104240, "longitude": -122.604582, "radius": 200}}, + {"id":"@JP", "types":["test", "voter"], "name":"780 Seale Ave, Palo Alto, CA 94303, USA", "enabled": true, "location": {"latitude": 37.441194, "longitude": -122.137691, "radius": 200}}, + {"id":"@Rokwire", "types":["test"], "name":"1301 W Springfield Ave, Urbana, IL 61801, USA", "enabled": true, "location": {"latitude": 40.112501, "longitude": -88.226915, "radius": 200}}, + {"id":"@Misho", "types":["test"], "name":"ul. Bratovan 3B, 1505 Reduta, Sofia", "enabled": true, "location": {"latitude": 42.690307, "longitude": 23.364563, "radius": 200}}, + + {"id":"state_farm_center", "types":["stadium"], "name":"State Farm Center", "enabled": true, "location": {"latitude": 40.096247, "longitude": -88.235923, "radius": 280}}, + {"id":"memorial_stadium", "types":["stadium"], "name":"Memorial Stadium", "enabled": true, "location": {"latitude": 40.099187, "longitude": -88.235956, "radius": 150}}, + + {"id":"illini_union", "types":["voter"], "name":"Illini Union", "enabled": true, "location": {"latitude": 40.109211, "longitude": -88.227221, "radius": 100}}, + {"id":"arc", "types":["voter"], "name":"ARC", "enabled": true, "location": {"latitude": 40.100729, "longitude": -88.235799, "radius": 150}}, + + {"id":"MTD", "types":["test", "MTD"], "name":"Champaign–Urbana MTD", "enabled": true, "beacon": {"uuid": "d7d5a51f-ce7f-4a4c-a944-bed73578b201"}} + ] + }, + + "voter": { + "rules": [ + { + "date_start": null, + "date_end": "2020/02/05", + "NRV_title": "widget.voter.nrv_title.registered_to_vote.question", + "NRV_text": "widget.voter.nrv_text.register_online.text", + "NRV_options": [ + { + "label": "widget.voter.option.yes", + "value": "rv_yes" + }, + { + "label": "widget.voter.option.register_now", + "value": "nrv_place" + } + ], + "NRV_place_title": "widget.voter.nrv_place_title.place_to_vote.question", + "NRV_place_options": [ + { + "label": "widget.voter.option.champaign_county", + "value": "https://ova.elections.il.gov/" + }, + { + "label": "widget.voter.option.elsewhere", + "value": "https://illinois.turbovote.org/" + } + ], + "NRV_alert": "widget.voter.nrv_alert.text", + "RV_place_title": "widget.voter.rv_place_title.place_to_vote.question", + "RV_place_options": [ + { + "label": "widget.voter.option.champaign_county", + "value": "champaign" + }, + { + "label": "widget.voter.option.elsewhere", + "value": "elsewhere" + } + ], + "RV_title": "widget.voter.rv_title.signed_to_vote_by_mail.question", + "RV_text": "widget.voter.rv_text.vote_by_mail_ballot.text", + "RV_options": [ + { + "label": "widget.voter.option.yes", + "value": "vbm_yes" + }, + { + "label": "widget.voter.option.vote_by_mail", + "value": "rv_url" + }, + { + "label": "widget.voter.option.no_thanks", + "value": "vbm_no" + } + ], + "RV_url": "https://www.champaigncountyclerk.com/elections/vote-by-mail-reside" + }, + { + "date_start": "2020/02/06", + "date_end": "2020/02/18", + "NRV_title": "widget.voter.nrv_title.registered_to_vote.question", + "NRV_text": "widget.voter.nrv_text.register_online.text", + "NRV_options": [ + { + "label": "widget.voter.option.yes", + "value": "rv_yes" + }, + { + "label": "widget.voter.option.register_now", + "value": "nrv_place" + } + ], + "NRV_place_title": "widget.voter.nrv_place_title.place_to_vote.question", + "NRV_place_options": [ + { + "label": "widget.voter.option.champaign_county", + "value": "https://ova.elections.il.gov/" + }, + { + "label": "widget.voter.option.elsewhere", + "value": "https://illinois.turbovote.org/" + } + ], + "NRV_alert": "widget.voter.nrv_alert.text", + "RV_place_title": "widget.voter.rv_place_title.place_to_vote.question", + "RV_place_options": [ + { + "label": "widget.voter.option.champaign_county", + "value": "champaign" + }, + { + "label": "widget.voter.option.elsewhere", + "value": "elsewhere" + } + ], + "RV_title": "widget.voter.rv_title.signed_to_vote_by_mail.question", + "RV_text": "widget.voter.rv_text.vote_by_mail_ballot.text", + "RV_options": [ + { + "label": "widget.voter.option.yes", + "value": "vbm_yes" + }, + { + "label": "widget.voter.option.vote_by_mail", + "value": "rv_url" + }, + { + "label": "widget.voter.option.no_thanks", + "value": "vbm_no" + } + ], + "RV_url": "https://www.champaigncountyclerk.com/elections/vote-by-mail-reside", + "RV_alert": "widget.voter.rv_alert.vote_early.text", + "VBM_text": "widget.voter.vbm_text.early_voting_clerk.text", + "VBM_button": "widget.voter.option.more_info", + "VBM_url": "https://www.champaigncountyclerk.com/elections/early-voting" + }, + { + "date_start": "2020/02/19", + "date_end": "2020/03/01", + "NRV_title": "widget.voter.nrv_title.registered_to_vote.question", + "NRV_text": "widget.voter.nrv_text.register_vote_today.text", + "NRV_options": [ + { + "label": "widget.voter.option.yes", + "value": "rv_yes" + } + ], + "NRV_alert": "widget.voter.nrv_alert.text", + "RV_place_title": "widget.voter.rv_place_title.place_to_vote.question", + "RV_place_options": [ + { + "label": "widget.voter.option.champaign_county", + "value": "champaign" + }, + { + "label": "widget.voter.option.elsewhere", + "value": "elsewhere" + } + ], + "RV_title": "widget.voter.rv_title.signed_to_vote_by_mail.question", + "RV_text": "widget.voter.rv_text.vote_by_mail_ballot.text", + "RV_options": [ + { + "label": "widget.voter.option.yes", + "value": "vbm_yes" + }, + { + "label": "widget.voter.option.vote_by_mail", + "value": "rv_url" + }, + { + "label": "widget.voter.option.no_thanks", + "value": "vbm_no" + } + ], + "RV_url": "https://www.champaigncountyclerk.com/elections/vote-by-mail-reside", + "RV_alert": "widget.voter.rv_alert.vote_early.text", + "VBM_text": "widget.voter.vbm_text.early_voting_clerk.text", + "VBM_button": "widget.voter.option.more_info", + "VBM_url": "https://www.champaigncountyclerk.com/elections/early-voting" + }, + { + "date_start": "2020/03/02", + "date_end": "2020/03/11", + "NRV_title": "widget.voter.nrv_title.registered_to_vote.question", + "NRV_text": "widget.voter.nrv_text.register_vote_today.text", + "NRV_options": [ + { + "label": "widget.voter.option.yes", + "value": "rv_yes" + } + ], + "NRV_alert": "widget.voter.nrv_alert.text", + "RV_place_title": "widget.voter.rv_place_title.place_to_vote.question", + "RV_place_options": [ + { + "label": "widget.voter.option.champaign_county", + "value": "champaign" + }, + { + "label": "widget.voter.option.elsewhere", + "value": "elsewhere" + } + ], + "RV_title": "widget.voter.rv_title.signed_to_vote_by_mail.question", + "RV_text": "widget.voter.rv_text.vote_by_mail_ballot.text", + "RV_options": [ + { + "label": "widget.voter.option.yes", + "value": "vbm_yes" + }, + { + "label": "widget.voter.option.vote_by_mail", + "value": "rv_url" + }, + { + "label": "widget.voter.option.no_thanks", + "value": "vbm_no" + } + ], + "RV_url": "https://www.champaigncountyclerk.com/elections/vote-by-mail-reside", + "RV_alert": "widget.voter.rv_alert.vote_early.text", + "VBM_text": "widget.voter.vbm_text.no_need_to_wait.text", + "VBM_button": "widget.voter.option.more_info", + "VBM_url": "https://www.champaigncountyclerk.com/elections/early-voting" + }, + { + "date_start": "2020/03/12", + "date_end": "2020/03/16", + "NRV_title": "widget.voter.nrv_title.registered_to_vote.question", + "NRV_text": "widget.voter.nrv_text.grace_period_open.text", + "NRV_options": [ + { + "label": "widget.voter.option.yes", + "value": "rv_yes" + } + ], + "NRV_alert": "widget.voter.nrv_alert.text", + "RV_place_title": "widget.voter.rv_place_title.place_to_vote.question", + "RV_place_options": [ + { + "label": "widget.voter.option.champaign_county", + "value": "champaign" + }, + { + "label": "widget.voter.option.elsewhere", + "value": "elsewhere" + } + ], + "RV_title": "widget.voter.rv_title.early_voting.text", + "RV_text": "widget.voter.rv_text.vote_now.text", + "RV_options": [ + { + "label": "widget.voter.option.more_info", + "value": "rv_url" + } + ], + "RV_url": "https://www.champaigncountyclerk.com/elections/early-voting", + "RV_alert": "widget.voter.rv_alert.vote_early.text", + "hide_for_period": true + }, + { + "date_start": "2020/05/18" + } + ] + }, + + "wellness": { + "panels": { + "home": { + "header": { + "image": "group-4.png", + "title": "panel.wellness.home.header.title" + }, + "description": { + "main_text": "panel.wellness.home.description.main_text", + "secondary_text": "panel.wellness.home.description.secondary_text" + }, + "activities": [ + { + "header": { + "icon": "campus-tools.png", + "title": "panel.wellness.home.eight_dimensions.header.title" + }, + "items": [ + { + "title": "panel.wellness.home.activities.physical", + "font_size": 20, + "image": "physical.png", + "action": { + "name": "panel", + "source": "physical" + } + }, + { + "title": "panel.wellness.home.activities.mental", + "font_size": 20, + "image": "mental.png", + "action": { + "name": "panel", + "source": "mental" + } + }, + { + "title": "panel.wellness.home.activities.environmental", + "image": "environmental.png", + "action": { + "name": "panel", + "source": "environmental" + } + }, + { + "title": "panel.wellness.home.activities.financial", + "font_size": 20, + "image": "financial-2.png", + "action": { + "name": "panel", + "source": "financial" + } + }, + { + "title": "panel.wellness.home.activities.spiritual", + "font_size": 20, + "image": "spiritual.png", + "action": { + "name": "panel", + "source": "spiritual" + } + }, + { + "title": "panel.wellness.home.activities.vocational", + "font_size": 20, + "image": "vocational.png", + "action": { + "name": "panel", + "source": "vocational" + } + }, + { + "title": "panel.wellness.home.activities.emotional", + "font_size": 20, + "image": "emotional.png", + "action": { + "name": "panel", + "source": "emotional" + } + }, + { + "title": "panel.wellness.home.activities.social", + "font_size": 20, + "image": "social.png", + "action": { + "name": "panel", + "source": "social" + } + } + ] + }, + { + "header": { + "icon": "icon-schedule.png", + "title": "panel.wellness.common.interactive_activities.title" + }, + "items": [ + { + "title": "panel.wellness.home.activities.interactive_mood_meter", + "image": "group-7.png", + "action": { + "name": "web", + "source": "http://mhcwellness.illinois.edu/interactive-mood-meter" + } + }, + { + "title": "panel.wellness.home.activities.memory_mix_up", + "image": "group-2.png", + "action": { + "name": "web", + "source": "http://mhcwellness.illinois.edu/memory-mixup" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.upace", + "image": "group-18.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/group-fitness/upace-app/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.reflection", + "image": "reflection.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/wellness-reflection-start/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.pledge", + "image": "pledge.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/take-the-wellness-pledge/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.kognito_at_risk", + "image": "kognito.png", + "action": { + "name": "web", + "source": "https://counselingcenter.illinois.edu/emergency/kognito-risk-suicide-prevention-training" + } + } + ] + } + ] + }, + "physical": { + "header": { + "image": "group.png", + "title": "panel.wellness.physical.header.title" + }, + "description": { + "main_text": "panel.wellness.physical.description.main_text", + "bullet": "panel.wellness.common.description.bullet", + "secondary_text": "panel.wellness.physical.description.secondary_text" + }, + "activities": [ + { + "header": { + "icon": "campus-tools.png", + "title": "panel.wellness.common.interactive_activities.title" + }, + "items": [ + { + "title": "panel.wellness.common.interactive_activities.activity.upace", + "image": "group-18.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/group-fitness/upace-app/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.reflection", + "image": "reflection.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/wellness-reflection-start/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.pledge", + "image": "pledge.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/take-the-wellness-pledge/" + } + } + ] + } + ], + "resources": [ + { + "title": "panel.wellness.common.resources.campus_recreation.title", + "ribbon_buttons": [ + { + "title": "panel.wellness.physical.resources.personal_training", + "url": "https://campusrec.illinois.edu/programs/personal-training/", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.physical.resources.group_fitness", + "url": "https://campusrec.illinois.edu/programs/group-fitness/", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.physical.resources.aquatics", + "url": "https://campusrec.illinois.edu/programs/aquatics/", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.physical.resources.food_nutrition", + "url": "https://campusrec.illinois.edu/programs/student-wellness/nutrition-food/", + "icon": "link-out.png", + "hint": "" + } + ], + "social_media": [ + { + "type": "instagram", + "url": "https://www.instagram.com/illinoiscampusrec/" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_health_center.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.physical.resources.mymckinley.title", + "url":"https://mymckinley.illinois.edu/", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.appointments_mckinley", + "url":"http://mckinley.illinois.edu/medical-services/appointments", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.immunization", + "url":"http://mckinley.illinois.edu/medical-services/immunization-allergy-travel-clinic", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.womens_health", + "url":"http://mckinley.illinois.edu/medical-services/womens-health", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.fitness", + "url":"http://mckinley.illinois.edu/health-education/fitness", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.nutrition", + "url":"http://mckinley.illinois.edu/health-education/nutrition", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.sexual_health", + "url":"http://mckinley.illinois.edu/health-education/sexual-health", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.lgbtq", + "url":"http://mckinley.illinois.edu/health-education/sexual-health/lgbtq-health", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.lab", + "url":"http://mckinley.illinois.edu/medical-services/laboratory", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.radiology", + "url":"http://mckinley.illinois.edu/medical-services/radiology", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.pharmacy", + "url":"http://mckinley.illinois.edu/pharmacy/about-pharmacy", + "icon": "link-out.png", + "hint": "" + } + ], + "social_media": [ + { + "type":"youtube", + "url":"https://www.youtube.com/user/MHCMcTV" + }, + { + "type":"facebook", + "url":"https://www.facebook.com/MckinleyHC/" + }, + { + "type":"instagram", + "url":"https://www.instagram.com/mckinleyhealthcenter/" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_wellness_app.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.physical.resources.first_time_visitors", + "url":"http://mhcwellness.illinois.edu/first-time-visitors", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.primary_care", + "url":"http://mhcwellness.illinois.edu/primary-care", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.schedule_appointment", + "url":"http://mhcwellness.illinois.edu/mymckinley-schedule-appointment", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.dial_nurse", + "url":"http://mhcwellness.illinois.edu/dial-nurse", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.health_resource_centers", + "url":"http://mhcwellness.illinois.edu/health-resource-centers", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.exercise", + "url":"http://mhcwellness.illinois.edu/exercise", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.physical.resources.eat_well", + "url":"http://mhcwellness.illinois.edu/eat-well", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.sexual_health", + "url":"http://mhcwellness.illinois.edu/sexual-health", + "icon": "link-out.png", + "hint": "" + } + ] + } + ] + }, + "mental": { + "header": { + "image": "group-444.png", + "title": "panel.wellness.mental.header.title" + }, + "description": { + "main_text": "panel.wellness.mental.description.main_text", + "bullet": "panel.wellness.common.description.bullet", + "secondary_text": "panel.wellness.mental.description.secondary_text" + }, + "activities": [ + { + "header": { + "icon": "campus-tools.png", + "title": "panel.wellness.common.interactive_activities.title" + }, + "items": [ + { + "title": "panel.wellness.common.interactive_activities.activity.kognito_at_risk_center", + "image": "kognito.png", + "action": { + "name": "web", + "source": "https://counselingcenter.illinois.edu/emergency/kognito-risk-suicide-prevention-training" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.mood_meter", + "image": "group-7.png", + "action": { + "name": "web", + "source": "http://mhcwellness.illinois.edu/interactive-mood-meter" + } + }, + { + "title": "panel.wellness.mental.interactive_activities.activity.mixup", + "image": "group-2.png", + "action": { + "name": "web", + "source": "http://mhcwellness.illinois.edu/memory-mixup" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.reflection", + "image": "reflection.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/wellness-reflection-start/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.pledge", + "image": "pledge.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/take-the-wellness-pledge/" + } + } + ] + } + ], + "resources": [ + { + "title": "panel.wellness.common.resources.counseling_center.title", + "ribbon_buttons": [ + { + "title": "panel.wellness.mental.resources.counseling", + "url": "https://counselingcenter.illinois.edu/counseling", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.mental.resources.outreach", + "url": "https://counselingcenter.illinois.edu/outreach-and-prevention-services", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.common.resources.ace_it", + "url": "https://counselingcenter.illinois.edu/aceit", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_health_center.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.mental.resources.mental_services", + "url":"http://mckinley.illinois.edu/medical-services/mental-health", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.stress_management", + "url":"http://mckinley.illinois.edu/health-education/stress-management", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.relaxation_techniques", + "url":"http://mckinley.illinois.edu/health-education/stress-management/relaxation-techniques", + "icon": "link-out.png", + "hint": "" + } + ], + "social_media": [ + { + "type":"youtube", + "url":"https://www.youtube.com/user/MHCMcTV" + }, + { + "type":"facebook", + "url":"https://www.facebook.com/MckinleyHC/" + }, + { + "type":"instagram", + "url":"https://www.instagram.com/mckinleyhealthcenter/" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_wellness_app.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.common.resources.mental_health", + "url":"http://mhcwellness.illinois.edu/mental-health", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.mental.resources.manage_stress", + "url":"http://mhcwellness.illinois.edu/manage-stress", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.mental.resources.get_sleep", + "url":"http://mhcwellness.illinois.edu/get-sleep", + "icon": "link-out.png", + "hint": "" + } + ] + } + ] + }, + "environmental": { + "header": { + "image": "group-9.png", + "title": "panel.wellness.environmental.header.title" + }, + "description": { + "main_text": "panel.wellness.environmental.description.main_text", + "bullet": "panel.wellness.common.description.bullet", + "secondary_text": "panel.wellness.environmental.description.secondary_text" + }, + "activities": [ + { + "header": { + "icon": "campus-tools.png", + "title": "panel.wellness.common.interactive_activities.title" + }, + "items": [ + { + "title": "panel.wellness.common.interactive_activities.activity.reflection", + "image": "reflection.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/wellness-reflection-start/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.pledge", + "image": "pledge.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/take-the-wellness-pledge/" + } + } + ] + } + ], + "resources": [ + { + "ribbon_buttons": [ + { + "title": "panel.wellness.environmental.resources.arboretum", + "url": "http://arboretum.illinois.edu/", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.campus_recreation.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.environmental.resources.campus_bike_center", + "url":"https://campusrec.illinois.edu/programs/campus-bike-center/", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_wellness_app.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.environmental.resources.transportation_safety", + "url":"http://mhcwellness.illinois.edu/safety-transportation", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.environmental.resources.late_night_studying", + "url":"http://mhcwellness.illinois.edu/studying-late-night", + "icon": "link-out.png", + "hint": "" + } + ] + } + ] + }, + "financial": { + "header": { + "image": "financial.png", + "title": "panel.wellness.financial.header.title" + }, + "description": { + "main_text": "panel.wellness.financial.description.main_text", + "bullet": "panel.wellness.common.description.bullet", + "secondary_text": "panel.wellness.financial.description.secondary_text" + }, + "resources": [ + { + "ribbon_buttons": [ + { + "title": "panel.wellness.financial.resources.financial_wellness_program", + "url": "https://extension.illinois.edu/cfiv/financial-wellness-college-students", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.financial.resources.your_legal_health", + "url": "https://odos.illinois.edu/sls/resources/brochures/docs/your-legal-health.pdf", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.financial.resources.student_money_management", + "url": "https://www.studentmoney.uillinois.edu/", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_wellness_app.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.financial.resources.budgets", + "url":"http://mhcwellness.illinois.edu/budget-finances", + "icon": "link-out.png", + "hint": "" + } + ] + } + ] + }, + "spiritual": { + "header": { + "image": "group-44.png", + "title": "panel.wellness.spiritual.header.title" + }, + "description": { + "main_text": "panel.wellness.spiritual.description.main_text", + "bullet": "panel.wellness.common.description.bullet", + "secondary_text": "panel.wellness.spiritual.description.secondary_text" + }, + "activities": [ + { + "header": { + "icon": "campus-tools.png", + "title": "panel.wellness.common.interactive_activities.title" + }, + "items": [ + { + "title": "panel.wellness.common.interactive_activities.activity.reflection", + "image": "reflection.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/wellness-reflection-start/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.pledge", + "image": "pledge.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/take-the-wellness-pledge/" + } + } + ] + } + ], + "resources": [ + { + "ribbon_buttons": [ + { + "title": "panel.wellness.spiritual.resources.reflection_rooms", + "url": "https://www.library.illinois.edu/ugl/about/reflection-rooms/", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.spiritual.resources.open_prayer", + "url": "https://union.illinois.edu/get-involved/office-of-registered-organizations/student-organization-complex", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.office_inclusion.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.common.resources.programs", + "url":"https://oiir.illinois.edu/programs", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.spiritual.resources.interfaith", + "url":"https://oiir.illinois.edu/diversityed/interfaith", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.bnaacc", + "url":"https://oiir.illinois.edu/bnaacc", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.aacc", + "url":"https://oiir.illinois.edu/aacc", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.la_casa", + "url":"https://oiir.illinois.edu/la-casa-cultural-latina", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.native_american_house", + "url":"https://oiir.illinois.edu/native-american-house", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.spiritual.resources.dean_students.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.spiritual.resources.religious_worker", + "url":"https://odos.illinois.edu/resources/rwa/", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_health_center.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.common.resources.relaxation_techniques", + "url":"http://mckinley.illinois.edu/health-education/stress-management/relaxation-techniques", + "icon": "link-out.png", + "hint": "" + } + ], + "social_media": [ + { + "type":"youtube", + "url":"https://www.youtube.com/user/MHCMcTV" + }, + { + "type":"facebook", + "url":"https://www.facebook.com/MckinleyHC/" + }, + { + "type":"instagram", + "url":"https://www.instagram.com/mckinleyhealthcenter/" + } + ] + } + ] + }, + "vocational": { + "header": { + "image": "group-15.png", + "title": "panel.wellness.vocational.header.title" + }, + "description": { + "main_text": "panel.wellness.vocational.description.main_text", + "bullet": "panel.wellness.common.description.bullet", + "secondary_text": "panel.wellness.vocational.description.secondary_text" + }, + "activities": [ + { + "header": { + "icon": "campus-tools.png", + "title": "panel.wellness.common.interactive_activities.title" + }, + "items": [ + { + "title": "panel.wellness.common.interactive_activities.activity.reflection", + "image": "reflection.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/wellness-reflection-start/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.pledge", + "image": "pledge.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/take-the-wellness-pledge/" + } + } + ] + } + ], + "resources": [ + { + "ribbon_buttons": [ + { + "title": "panel.wellness.vocational.resources.career_center", + "url": "https://www.careercenter.illinois.edu/", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.vocational.resources.student_job_board", + "url": "https://secure.osfa.illinois.edu/vjb/", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.vocational.resources.top_scholars", + "url": "https://topscholars.illinois.edu/", + "icon": "link-out.png", + "hint": "" + }, + { + "title": "panel.wellness.vocational.resources.i_programs", + "url": "http://leadership.illinois.edu/i-programs", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_wellness_app.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.vocational.resources.networking", + "url":"http://mhcwellness.illinois.edu/networking", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.vocational.resources.student_assistance_center", + "url":"http://mhcwellness.illinois.edu/student-assistance-center%E2%80%A8", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.vocational.resources.academic_advisement", + "url":"http://mhcwellness.illinois.edu/academic-advisement", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.vocational.resources.faqs", + "url":"http://mhcwellness.illinois.edu/faq", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.vocational.resources.study_tips", + "url":"http://mhcwellness.illinois.edu/study-tips", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.vocational.resources.compass", + "url":"http://mhcwellness.illinois.edu/what-compass", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.vocational.resources.tutoring", + "url":"http://mhcwellness.illinois.edu/tutoring", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.involved", + "url":"http://mhcwellness.illinois.edu/getting-involved", + "icon": "link-out.png", + "hint": "" + } + ] + } + ] + }, + "emotional": { + "header": { + "image": "path.png", + "title": "panel.wellness.emotional.header.title" + }, + "description": { + "main_text": "panel.wellness.emotional.description.main_text", + "bullet": "panel.wellness.common.description.bullet", + "secondary_text": "panel.wellness.emotional.description.secondary_text" + }, + "activities": [ + { + "header": { + "icon": "campus-tools.png", + "title": "panel.wellness.common.interactive_activities.title" + }, + "items": [ + { + "title": "panel.wellness.common.interactive_activities.activity.mood_meter", + "image": "group-7.png", + "action": { + "name": "web", + "source": "http://mhcwellness.illinois.edu/interactive-mood-meter" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.reflection", + "image": "reflection.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/wellness-reflection-start/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.pledge", + "image": "pledge.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/take-the-wellness-pledge/" + } + } + ] + } + ], + "resources": [ + { + "ribbon_buttons": [ + { + "title": "panel.wellness.emotional.resources.community_care", + "url": "https://odos.uiuc.edu/community-of-care/resources/students/", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.office_inclusion.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.common.resources.programs", + "url":"https://oiir.illinois.edu/programs", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.women_center", + "url":"https://oiir.illinois.edu/womens-center", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.lgbt_center", + "url":"https://oiir.illinois.edu/lgbt-resource-center", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.counseling_center.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.common.interactive_activities.activity.kognito_at_risk_center", + "url":"https://counselingcenter.illinois.edu/emergency/kognito-risk-suicide-prevention-training", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.ace_it", + "url":"https://counselingcenter.illinois.edu/aceit", + "icon": "link-out.png", + "hint": "" + } + ], + "social_media": [ + { + "type":"twitter", + "url":"http://twitter.com/UI_Counseling" + }, + { + "type":"facebook", + "url":"https://www.facebook.com/IllinoisCounselingCenter" + }, + { + "type":"youtube", + "url":"https://www.youtube.com/channel/UCyxUtefuFNxkk2MuwhcDgig" + }, + { + "type":"instagram", + "url":"https://www.instagram.com/illinoiscounselingcenter/" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_health_center.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.common.resources.stress_management", + "url":"http://mckinley.illinois.edu/health-education/stress-management", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.mental_health", + "url":"http://mckinley.illinois.edu/medical-services/mental-health", + "icon": "link-out.png", + "hint": "" + } + ], + "social_media": [ + { + "type":"youtube", + "url":"https://www.youtube.com/user/MHCMcTV" + }, + { + "type":"facebook", + "url":"https://www.facebook.com/MckinleyHC/" + }, + { + "type":"instagram", + "url":"https://www.instagram.com/mckinleyhealthcenter/" + } + ] + } + ] + }, + "social": { + "header": { + "image": "group-16.png", + "title": "panel.wellness.social.header.title" + }, + "description": { + "main_text": "panel.wellness.social.description.main_text", + "bullet": "panel.wellness.common.description.bullet", + "secondary_text": "panel.wellness.social.description.secondary_text" + }, + "activities": [ + { + "header": { + "icon": "campus-tools.png", + "title": "panel.wellness.common.interactive_activities.title" + }, + "items": [ + { + "title": "panel.wellness.common.interactive_activities.activity.mood_meter", + "image": "group-7.png", + "action": { + "name": "web", + "source": "http://mhcwellness.illinois.edu/interactive-mood-meter" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.reflection", + "image": "reflection.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/wellness-reflection-start/" + } + }, + { + "title": "panel.wellness.common.interactive_activities.activity.pledge", + "image": "pledge.png", + "action": { + "name": "web", + "source": "https://campusrec.illinois.edu/programs/student-wellness/take-the-wellness-pledge/" + } + } + ] + } + ], + "resources": [ + { + "ribbon_buttons": [ + { + "title": "panel.wellness.social.resources.engage", + "url": "https://illinois.campuslabs.com/engage/", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.campus_recreation.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.social.resources.intramural_sports", + "url":"https://campusrec.illinois.edu/programs/intramural/", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.social.resources.illini_union.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.social.resources.rso", + "url":"https://union.illinois.edu/get-involved/office-of-registered-organizations", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.social.resources.master_calendar", + "url":"https://mastercalendar.union.illinois.edu/MasterCalendar/MasterCalendar.aspx", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.office_inclusion.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.common.resources.bnaacc", + "url":"https://oiir.illinois.edu/bnaacc", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.aacc", + "url":"https://oiir.illinois.edu/aacc", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.la_casa", + "url":"https://oiir.illinois.edu/la-casa-cultural-latina", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.native_american_house", + "url":"https://oiir.illinois.edu/native-american-house", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.women_center", + "url":"https://oiir.illinois.edu/womens-center", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.common.resources.lgbt_center", + "url":"https://oiir.illinois.edu/lgbt-resource-center", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.social.resources.international_education", + "url":"https://oiir.illinois.edu/cultural-resource-centers/international-education", + "icon": "link-out.png", + "hint": "" + } + ] + }, + { + "title": "panel.wellness.common.resources.mckinley_wellness_app.title", + "ribbon_buttons": [ + { + "title":"panel.wellness.common.resources.involved", + "url":"http://mhcwellness.illinois.edu/getting-involved", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.social.resources.going_out", + "url":"http://mhcwellness.illinois.edu/going-out", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.social.resources.peer_pressure", + "url":"http://mhcwellness.illinois.edu/peer-pressure", + "icon": "link-out.png", + "hint": "" + }, + { + "title":"panel.wellness.social.resources.roommates", + "url":"http://mhcwellness.illinois.edu/roommates", + "icon": "link-out.png", + "hint": "" + } + ] + } + ] + } + } + }, + + "covid19_guidelines": { + "content": { + "green": [ + { + "image": "icon-stay-at-home.png", + "description": "Stay at home", + "requirement": "Required until April 31" + }, + { + "image": "icon-face-mask.png", + "description": "Wearing facemask", + "requirement": "Recommended" + } + ], + "yellow": [ + { + "image": "icon-social-distance.png", + "description": "Social distancing", + "requirement": "Required" + }, + { + "image": "icon-stay-at-home.png", + "description": "Stay at home", + "requirement": "Required until April 31" + }, + { + "image": "icon-face-mask.png", + "description": "Wearing facemask", + "requirement": "Recommended" + } + ], + "red": [ + { + "image": "icon-stay-at-home.png", + "description": "Stay at home", + "requirement": "Required" + }, + { + "image": "icon-separate-people.png", + "description": "Separate from other people", + "requirement": "Required" + }, + { + "image": "icon-face-mask.png", + "description": "Wearing facemask", + "requirement": "Required" + } + ] + }, + "strings": { + "en": { + "Stay at home": "Stay at home", + "Separate from other people": "Separate from other people", + "Wearing facemask": "Wearing facemask", + "Social distancing": "Social distancing", + "Required": "Required", + "Required until April 31": "Required until April 31", + "Recommended": "Recommended" + }, + "es": { + "Stay at home": "Quédate en casa", + "Separate from other people": "Separado de otras personas", + "Wearing facemask": "Usar mascarilla", + "Social distancing": "Distanciamiento social", + "Required": "Necesario", + "Required until April 31": "Requerido hasta el 31 de abril", + "Recommended": "Recomendado" + }, + "zh": { + "Stay at home": "呆在家裡", + "Separate from other people": "與其他人分開", + "Wearing facemask": "戴口罩", + "Social distancing": "社交隔離", + "Required": "需要", + "Required until April 31": "要求至4月31日", + "Recommended": "推薦的" + } + }, + "icons": { + "home": "icon-stay-at-home.png", + "separate": "icon-separate-people.png", + "distance": "icon-social-distance.png", + "mask": "icon-face-mask.png" + } + } +} + diff --git a/assets/flexUI.json b/assets/flexUI.json new file mode 100644 index 00000000..36a46a03 --- /dev/null +++ b/assets/flexUI.json @@ -0,0 +1,132 @@ +{ + "content": { + "tabbar": ["home", "athletics", "explore", "wallet", "browse"], + + "home": ["upgrade_version_message", "voter_registration", "covid19_info", "game_day", "campus_tools", "create_poll", "pref_sports", "campus_reminders", "upcoming_events", "recent_items"], + "campus_tools": ["events", "covid19", "dining", "athletics", "illini_cash", "laundry", "my_illini"], + + "health.covid19": ["latest_update", "stay_informed", "news", "resources", "general", "faq"], + + "wallet": ["wallet.connect", "wallet.content", "wallet.cards"], + "wallet.connect":["netid", "phone"], + "wallet.content": ["illini_cash", "meal_plan"], + "wallet.cards":["covid19_passport", "mtd", "id", "library"], + + "browse": ["browse.all", "browse.content"], + "browse.all": ["athletics", "saved", "covid19", "dining", "events", "quick_polls", "wellness", "groups"], + "browse.content": ["settings", "my_illini", "laundry", "illini_cash", "meal_plan", "parking", "feedback", "create_event", "create_stadium_poll", "state_farm_wayfinding"], + + "settings": ["user_info", "connect", "customizations", "connected", "notifications", "covid19", "privacy", "account", "feedback"], + "settings.connect": ["netid", "phone"], + "settings.customizations": ["roles"], + "settings.connected": ["netid", "phone"], + "settings.connected.netid": ["info", "disconnect", "connect"], + "settings.connected.phone": ["info", "disconnect", "verify"], + "settings.notifications": ["covid19"], + "settings.covid19": ["exposure_notifications", "provider_test_result", "qr_code"], + "settings.privacy": ["statement"], + "settings.account": ["personal_info"], + + "onboarding":["get_started", "notifications_auth", "location_auth", "bluetooth_auth", "roles", "login_netid", "login_phone", "verify_phone", "confirm_phone", "resident_info", "review_scan", "covid19_intro", "covid19_how_works", "covid19_consent", "covid19_qrcode", "covid19_final"], + + "features": ["converge", "create_poll", "mtd_bus_number", "parking_lot_directions"] + }, + "rules": { + "roles" : { + "tabbar.home" : [["NOT", "fan"], "OR", "student", "OR", "employee", "OR", "parent"], + "tabbar.athletics" : ["fan", "AND", ["NOT", ["student", "OR", "employee", "OR", "parent"]]], + "tabbar.explore" : ["NOT", ["employee", "OR", "student", "OR", "resident"]], + "tabbar.wallet" : ["student", "OR", "employee", "OR", "resident"], + + "game_day" : ["fan", "AND", ["student", "OR", "employee", "OR", "parent"], "AND", ["NOT", ["visitor", "OR", "alumni"]]], + "pref_sports" : ["fan", "AND", ["student", "OR", "employee", "OR", "parent"], "AND", ["NOT", ["visitor", "OR", "alumni"]]], + "campus_reminders" : [["NOT", "alumni"], "OR", "student", "OR", "fan", "OR", "employee", "OR", "parent"], + + "laundry" : ["student"], + "my_illini" : ["student"], + "illini_cash" : ["student", "OR", "employee", "OR", "parent"], + "meal_plan" : ["student"], + "create_poll" : ["student", "OR", "employee"], + "upgrade_version_message" : ["student", "OR", "employee"], + "covid19_info" : ["student", "OR", "employee", "OR", "resident"], + + "onboarding.login_netid" : ["student", "OR", "employee"], + "onboarding.login_phone" : ["NOT", ["employee", "OR", "student"]], + "onboarding.verify_phone" : ["NOT", ["employee", "OR", "student"]], + "onboarding.confirm_phone" : ["NOT", ["employee", "OR", "student"]], + + "onboarding.sport_prefs" : ["fan"], + + "wallet.cards.mtd" : ["NOT",["resident"]] + }, + "privacy" : { + "illini_cash" : 3, + "laundry" : 4, + "interests_selection" : 3, + "recent_items" : 4, + "saved" : 3, + "converge" : 5, + + "settings.user_info" : 4, + "settings.connect" : 4, + "settings.connected" : 4, + "settings.customizations" : 3, + "settings.notifications" : 4, + "settings.account" : 4, + + "tabbar.wallet" : 4, + "wallet.connect" : 4, + + "onboarding.notifications_auth" : 4, + "onboarding.location_auth" : 2, + "onboarding.bluetooth_auth" : 2, + "onboarding.roles" : 3, + "onboarding.login_netid" : 3, + "onboarding.login_phone" : 3, + "onboarding.verify_phone" : 3, + "onboarding.confirm_phone" : 3, + "onboarding.sport_prefs" : 3 + }, + "auth": { + "laundry" : { "shibbolethLoggedIn": true }, + "illini_cash" : { "shibbolethLoggedIn": true }, + "create_event" : { "shibbolethMemberOf": "urn:mace:uiuc.edu:urbana:authman:app-rokwire-service-policy-rokwire event approvers" }, + "create_stadium_poll" : { "shibbolethMemberOf": "urn:mace:uiuc.edu:urbana:authman:app-rokwire-service-policy-rokwire stadium poll manager" }, + "upgrade_version_message" : { "shibbolethLoggedIn": true }, + + "wallet.connect" : { "loggedIn": false }, + "wallet.content" : { "loggedIn": true }, + "wallet.cards" : { "loggedIn": true }, + "wallet.cards.id" : { "iCardNum": true }, + "wallet.cards.library" : { "iCardLibraryNum": true }, + "wallet.cards.mtd" : { "shibbolethLoggedIn": true }, + + "settings.user_info" : { "loggedIn": true }, + "settings.connect" : { "loggedIn": false }, + "settings.connected" : { "loggedIn": true }, + "settings.account" : { "loggedIn": true }, + "settings.covid19" : { "loggedIn": true }, + "settings.connected.netid" : { "shibbolethLoggedIn": true }, + "settings.connected.phone" : { "phoneLoggedIn": true }, + "settings.connected.netid.info" : { "shibbolethLoggedIn": true }, + "settings.connected.netid.disconnect" : { "shibbolethLoggedIn": true }, + "settings.connected.netid.connect" : { "shibbolethLoggedIn": false }, + "settings.connected.phone.info" : { "phoneLoggedIn": true }, + "settings.connected.phone.verify" : { "phoneLoggedIn": false }, + "settings.connected.phone.disconnect" : { "phoneLoggedIn": true } + }, + "platform":{ + "onboarding.bluetooth_auth" : { "os": "ios" } + }, + "illini_cash": { + "laundry" : { "housingResidenceStatus" : true } + }, + "enable" : { + "illini_cash" : true, + "create_event" : false, + "converge" : true, + "mtd_bus_number" : true, + "parking_lot_directions" : true + } + } +} diff --git a/assets/sample.groups.json b/assets/sample.groups.json new file mode 100644 index 00000000..213b635e --- /dev/null +++ b/assets/sample.groups.json @@ -0,0 +1,225 @@ +{ + "categories": ["Academic/Pre-Professional", "Athletic/Recreation", "Club Sports", "Creative/Media/Performing Arts", "Cultural/Ethnic", "Graduate", "Honorary", "International", "Other Social", "Political", "Religious", "Residence Hall", "Rights/Freedom Issues", "ROTC", "Service/Philanthropy", "Social Fraternity/Sorority", "University Student Governance/Council/Committee"], + "types": ["RSO", "College", "Department", "Class", "Study Group", "Other"], + "tags": ["Casual", "Professional", "High Engagement", "Competitive", "Low Commitment"], + "officerTitles": ["President", "Treasurer", "Assistant"], + "groups": [ + { + "id" : "1", + "category" : "Academic/Pre-Professional", + "type" : "College", + "title" : "Aerospace Outreach at Illinois", + "creatorUin" : "12345678", + "privacy" : "public", + "certified": true, + "description" : "This is a group for Aerospace fans.", + "imageURL" : "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/ae46c0de-cabb-11e9-88b6-0a58a9feac2a.webp", + "webURL" : "https://www.inabyte.com", + "membersCount": 154, + "tags" : ["Professional", "High Engagement"], + "membershipQuest" : { + "steps": [ + { "description": "Come to one of three info night at the beginning of each semester.", "eventIds":["5e50aa0d543e12000e910dea", "5e7593d0a5bdb5000bce81f7", "5df06a423f603800094294a4"] }, + { "description": "Interview with our current members so we can both get to know eachother better and get some questions answered." } + ], + "questions": [ + {"question": "Why are you interested in our group?"}, + {"question": "What is the best way to contact you?"} + ] + } + }, + { + "id" : "2", + "category" : "Social Fraternity/Sorority", + "type" : "Department", + "title" : "Aplha Chi Omega", + "creatorUin" : "12345678", + "privacy" : "private", + "certified": false, + "description" : "This is a Social Fraternity/Sorority group for Aplha Chi Omega.", + "imageURL" : "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/604d7a95-cabb-11e9-88b6-0a58a9feac2a.webp", + "webURL" : "https://www.inabyte.com", + "membersCount": 42, + "tags" : ["Casual", "Low Commitment"], + "membershipQuest" : { + "steps": [ + { "description": "Come to one of three info night at the beginning of each semester.", "eventIds":["5e50aa0d543e12000e910dea", "5e7593d0a5bdb5000bce81f7", "5df06a423f603800094294a4"] }, + { "description": "Interview with our current members so we can both get to know eachother better and get some questions answered." } + ], + "questions": [ + {"question": "Why are you interested in our group?"}, + {"question": "What is the best way to contact you?"} + ] + } + }, + { + "id" : "3", + "category" : "Creative/Media/Performing Arts", + "type" : "RSO", + "title" : "Altgeld Ringers", + "creatorUin" : "12345678", + "privacy" : "public", + "certified": false, + "description" : "This is a Creative/Media/Performing Arts group for Altgeld Ringers.", + "imageURL" : "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/31840f7d-cabb-11e9-88b6-0a58a9feac2a.webp", + "webURL" : "https://www.inabyte.com", + "membersCount": 427, + "tags" : ["Competitive", "Professional"], + "membershipQuest" : { + "steps": [ + { "description": "Come to one of three info night at the beginning of each semester.", "eventIds":["5e50aa0d543e12000e910dea", "5e7593d0a5bdb5000bce81f7", "5df06a423f603800094294a4"] }, + { "description": "Interview with our current members so we can both get to know eachother better and get some questions answered." } + ], + "questions": [ + {"question": "Why are you interested in our group?"}, + {"question": "What is the best way to contact you?"} + ] + } + }, + { + "id" : "4", + "category" : "Academic/Pre-Professional", + "type" : "Class", + "title" : "100 STRONG Coordinating Committee", + "creatorUin" : "12345678", + "privacy" : "public", + "certified": true, + "description" : "The 100 STRONG program will serve to systemically aclimate incoming African American freshman to the campus environment and to the programs and offices that were created to enhance academic and social success.", + "imageURL" : "https://rokwire-images.s3.us-east-2.amazonaws.com/event/tout/02ea99ca-cabb-11e9-88b6-0a58a9feac2a.webp", + "webURL" : "https://www.inabyte.com", + "membersCount": 3433, + "tags" : ["Casual", "High Engagement"], + "membershipQuest" : { + "steps": [ + { "description": "Come to one of three info night at the beginning of each semester.", "eventIds":["5e50aa0d543e12000e910dea", "5e7593d0a5bdb5000bce81f7", "5df06a423f603800094294a4"] }, + { "description": "Interview with our current members so we can both get to know eachother better and get some questions answered." } + ], + "questions": [ + {"question": "Why are you interested in our group?"}, + {"question": "What is the best way to contact you?"} + ] + } + } + ], + + "members": [ + {"uin": "10000000", "name": "Sarah Lee", "email": "sarah.lee@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo4.png", "status": "officer", "officerTitle":"President", "admin": true, "dateAdded":"2020-01-01T12:30:00"}, + {"uin": "10000001", "name": "Jared Bastian", "email": "jared.bastian@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo2.png", "status": "officer", "officerTitle":"Treasurer", "admin": false, "dateAdded":"2020-01-02T12:30:00"}, + {"uin": "10000002", "name": "Ron Adams", "email": "ron.adams@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo3.png", "status": "officer", "officerTitle":"Assistant", "admin": false, "dateAdded":"2020-01-03T12:30:00"}, + {"uin": "10000003", "name": "Anna Robinson", "email": "anna.robinson@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo5.png", "status": "current", "officerTitle":null, "admin": false, "dateAdded":"2020-01-04T12:30:00"}, + {"uin": "10000004", "name": "Ely London", "email": "ely.london@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo4.png", "status": "current", "officerTitle":null, "admin": false, "dateAdded":"2020-01-05T12:30:00"}, + {"uin": "10000005", "name": "John Baxter", "email": "john.baxter@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo.png", "status": "inactive", "officerTitle":null, "admin": false, "dateAdded":"2020-01-06T12:30:00"} + ], + "pending_members": [ + {"uin": "10000006", "name": "Ian Gillan", "email": "ian.gillan@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo6.png", "membershipRequest": {"dateCreated":"2020-04-10T05:00:00", "answers":[{"answer": "Because so."}, {"answer": "By carrier pigeon."}]} }, + {"uin": "10000007", "name": "Emily Blunt", "email": "emily.blunt@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo7.png", "membershipRequest": {"dateCreated":"2020-04-11T06:00:00", "answers":[{"answer": "I am not sure."}, {"answer": "By telegraph."}]} } + ], + + "events": [ + { + "allDay": false, + "calendarId": "1354", + "category": "Academic", + "contacts": [{"email":"iage@illinois.edu","phone":"217-333-6322"}], + "createdBy": "anw@illinois.edu", + "dataModified": "2019-12-10T05:00:00", + "dataSourceEventId": "33366515", + "dateCreated": "2019-12-10T05:00:00", + "endDate": "Wed, 08 Apr 2020 15:45:00 GMT", + "eventId": "5df06a3f6e33095e1a214d6e", + "icalUrl": "https://calendars.illinois.edu/ical/1354/33366515.ics", + "id": "5df06a423f603800094294a4", + "location": {"description":"International Studies Building 101","latitude": 40.1072128,"longitude": -88.2316263}, + "longDescription": "

Not sure how to get started in the study abroad process?  Attend a First Steps Workshop to learn what it means to study abroad, how to select a program, components of an application, and how study abroad can advance your academic, professional, and personal goals.  Topics covered in the presentation include:

\n
    \n
  • academics
  • \n
  • housing
  • \n
  • program duration/type
  • \n
  • locations
  • \n
  • costs
  • \n
\n

You will have the opportunity to ask questions of our student staff, called Program Assistants, who have been through the study abroad process.  Whether you're deciding if study abroad is for you, assesing program options, or have started an application, we welcome all students to learn more about what study abroad has to offer and how to get started!

", + "outlookUrl": "https://calendars.illinois.edu/outlook2010/1354/33366515.ics", + "recurrenceId": 18789, + "recurringFlag": true, + "sourceId": "0", + "sponsor": "Illinois Abroad & Global Exchange", + "startDate": "Wed, 08 Apr 2020 15:00:00 GMT", + "tags": ["application process","information session","international","presentation","research abroad","scholarship","scholarships","study abroad","workshop"], + "title": "First Steps Workshop", + "comments": [ + { + "text": "This event iss sponsored by IBM! A great opportunity for anyone networking or looking for potential internship opportunities.", + "dateCreated": "2020-04-01T05:00:00", + "member": {"uin": "10000000", "name": "Sarah Lee", "email": "sarah.lee@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo4.png", "status": "officer", "officerTitle":"President", "admin": true, "dateAdded":"2020-01-01T12:30:00"} + },{ + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In congue ut metus at bibendum.", + "dateCreated": "2020-04-01T04:00:00", + "member": {"uin": "10000001", "name": "Jared Bastian", "email": "jared.bastian@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo2.png", "status": "officer", "officerTitle":"Treasurer", "admin": false, "dateAdded":"2020-01-02T12:30:00"} + } + ] + }, { + "allDay": false, + "calendarId": "5598", + "category": "Academic", + "contacts": [{"email":"dmclur2@illinois.edu","firstName":"D'Mayza","lastName":"McClure"}], + "createdBy": "erink@illinois.edu", + "dataModified": "2020-04-01T05:00:00", + "dataSourceEventId": "33376772", + "dateCreated": "2020-03-20T05:00:00", + "endDate": "Wed, 08 Apr 2020 16:00:00 GMT", + "eventId": "5e7593cf605f07a6ec47f2ab", + "icalUrl": "https://calendars.illinois.edu/ical/5598/33376772.ics", + "id": "5e7593d0a5bdb5000bce81f7", + "location": {"description":"Talk and Q&A link will be disseminated via email to limited participants","latitude":40.1105875,"longitude":-88.2072697}, + "longDescription": "

Abstract:
Self-driving vehicles will bring us safer, cleaner, and more convenient transportation. To make this dream come true, we need our autonomous system to perceive, plan, and execute effectively in unstructured environments and have guaranteed safety. While machine learning has significantly enhanced autonomous capabilities, we are still missing key ingredients to achieve the desired goal. In this talk, I will present our approach towards autonomous driving. The core idea is to systematically integrate learning methods with structured models and human priors of the world. The effectiveness of our integrated approach has been demonstrated at the full spectrum of self-driving tasks, including localization, perception, planning and simulation, and our developed algorithms have been deployed in real-world production systems. Finally, I will give a brief personal outlook on open research topics towards realistically solving self-driving.

\n

 

\n

Bio:
Shenlong Wang is a PhD student at the University of Toronto under the supervision of Raquel Urtasun. He is also a Senior Research Scientist at the Uber Advanced Technology Group. Shenlong's research interests span the spectrum from computer vision, robotics and machine learning. His recent work involves developing robust algorithms for self-driving and making autonomous vehicles more reliable and scalable. His research has resulted in over 30 papers at top conferences including over 10 oral and spotlight presentations. He was selected as the recipient of the Facebook, Adobe and Royal Bank of Canada Fellowships in 2017.

\n


Faculty Host: Derek Hoiem

", + "outlookUrl": "https://calendars.illinois.edu/outlook2010/5598/33376772.ics", + "recurrenceId": 0, + "recurringFlag": false, + "sourceId": "0", + "sponsor": "Illinois Computer Science", + "startDate": "Wed, 08 Apr 2020 15:00:00 GMT", + "title": "SPECIAL SEMINAR: Shenglong Wang, University of Toronto, \"Learning to Drive With a Touch of Human Knowledge\"", + "comments": [ + { + "text": "Sed nec dignissim lacus. Proin vestibulum, lacus at gravida tincidunt, est sapien tristique orci, imperdiet molestie metus metus in lorem.", + "dateCreated": "2020-04-01T05:00:00", + "member": {"uin": "10000000", "name": "Sarah Lee", "email": "sarah.lee@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo4.png", "status": "officer", "officerTitle":"President", "admin": true, "dateAdded":"2020-01-01T12:30:00"} + },{ + "text": "Integer ex metus, interdum vitae lacinia at, venenatis ut nisi. Morbi aliquam facilisis ornare.", + "dateCreated": "2020-04-01T04:00:00", + "member": {"uin": "10000001", "name": "Jared Bastian", "email": "jared.bastian@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo2.png", "status": "officer", "officerTitle":"Treasurer", "admin": false, "dateAdded":"2020-01-02T12:30:00"} + },{ + "text": "Aenean vulputate aliquam mi, vel vestibulum est varius eu. In nec quam ac neque molestie egestas laoreet vitae quam.", + "dateCreated": "2020-04-01T03:00:00", + "member": {"uin": "10000002", "name": "Ron Adams", "email": "ron.adams@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo3.png", "status": "officer", "officerTitle":"Assistant", "admin": false, "dateAdded":"2020-01-03T12:30:00"} + } + ] + }, { + "allDay": false, + "calendarId": "3777", + "category": "Academic", + "contacts": [{"email":"rbeech1@uic.edu","firstName":"Rosie","lastName":"Beechen","phone":"312-996-5580"}], + "createdBy": "rbeech1@uic.edu", + "dataModified": "2020-02-24T05:00:00", + "dataSourceEventId": "33375189", + "dateCreated": "2020-02-21T05:00:00", + "endDate": "Wed, 08 Apr 2020 16:45:00 GMT", + "eventId": "5e50aa09605f07a6ec4072b8", + "icalUrl": "https://calendars.illinois.edu/ical/3777/33375189.ics", + "id": "5e50aa0d543e12000e910dea", + "location": {"description":"3427 ETMSW","latitude":41.8749488,"longitude":-87.6529231}, + "outlookUrl": "https://calendars.illinois.edu/outlook2010/3777/33375189.ics", + "recurrenceId": 0, + "recurringFlag": false, + "sourceId": "0", + "sponsor": "Stacey Horn", + "startDate": "Wed, 08 Apr 2020 15:30:00 GMT", + "title": "EPSY Faculty Candidate Job Talk", + "comments": [ + { + "text": "Cras ultrices interdum eros. In et massa vitae ex pellentesque dignissim ut nec erat. Cras et erat nec augue auctor malesuada at vitae ipsum.", + "dateCreated": "2020-04-01T05:00:00", + "member": {"uin": "10000000", "name": "Sarah Lee", "email": "sarah.lee@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo4.png", "status": "officer", "officerTitle":"President", "admin": true, "dateAdded":"2020-01-01T12:30:00"} + } + ] + } + ], + + "userMembership": { + "1": {"uin": "20000000", "name": "Current User", "email": "current.user@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo6.png", "status": "officer", "officerTitle":"President", "admin": true, "dateAdded":"2020-01-01T12:30:00"}, + "2": {"uin": "20000000", "name": "Current User", "email": "current.user@illinois.edu", "photoURL": "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Images/icard_photo6.png", "status": "current", "officerTitle":null, "admin": false, "dateAdded":"2020-01-01T12:30:00"} + } +} diff --git a/assets/sample.health.rules.json b/assets/sample.health.rules.json new file mode 100644 index 00000000..74c09e1f --- /dev/null +++ b/assets/sample.health.rules.json @@ -0,0 +1,381 @@ +{ + "defaults": { + "status": { + "health_status": "orange", + "next_step": "Take a SHIELD Saliva Test when you return to campus." + } + }, + "tests" : { + "rules": [ + { + "test_type": "Antibody Test A1", + "category": "antibody", + "results": [ + { + "result": "not present", + "category": "antibody.negavitve", + "status": "antibody.negavitve" + }, + { + "result": "present", + "category": "antibody.positive", + "status": "antibody.positive" + } + ] + }, + { + "test_type": "Covid-19 test B1", + "category": "PCR", + "results": [ + { + "result": "positive", + "category": "PCR.positive", + "status": "PCR.positive" + }, + { + "result": "negative", + "category": "PCR.negative", + "status": "PCR.negative" + } + ] + }, + { + "test_type": "COVID-19 Antibody", + "category": "antibody", + "results": [ + { + "result": "Positive", + "category": "antibody.positive", + "status": "antibody.positive" + }, + { + "result": "Negative", + "category": "antibody.negative", + "status": "antibody.negative" + } + ] + }, + { + "test_type": "COVID-19 Antigen", + "category": "PCR", + "results": [ + { + "result": "Positive", + "category": "PCR.positive", + "status": "PCR.positive" + }, + { + "result": "Negative", + "category": "PCR.negative", + "status": "PCR.negative" + } + ] + }, + { + "test_type": "SARS-COV-2 BY PCR, BKR", + "category": "PCR", + "results": [ + { + "result": "DETECTED", + "category": "PCR.positive", + "status": "PCR.positive" + }, + { + "result": "Not Detected", + "category": "PCR.negative", + "status": "PCR.negative" + } + ] + }, + { + "test_type": "COVID-19 PCR", + "category": "PCR", + "results": [ + { + "result": "POSITIVE", + "category": "PCR.positive", + "status": "PCR.positive" + }, + { + "result": "NEGATIVE", + "category": "PCR.negative", + "status": "PCR.negative" + }, + { + "result": "INVALID", + "category": "PCR.invalid", + "status": "PCR.invalid" + } + ] + } + ], + + "statuses": { + "antibody.negavitve": { + "condition": "require-test", + "params": { + "interval": { "min": 0, "max": 4 }, + "current_interval": { "min": 0, "max": 4 } + }, + "success": null, + "fail": { + "health_status": "orange", + "priority": 1, + "next_step": "Get a test now", + "reason": "Your status changed to Orange because you are past due for a test." + } + }, + "antibody.positive": { + "condition": "require-test", + "params": { + "interval": { "min": 0, "max": 4 }, + "current_interval": { "min": 0, "max": 4 } + }, + "success": { + "health_status": "green", + "priority": 1, + "next_step": "Monitor your test results", + "next_step_interval": 4 + }, + "fail": { + "health_status": "orange", + "priority": 1, + "next_step": "Get a test now", + "reason": "Your status changed to Orange because you are past due for a test." + } + }, + "PCR.positive": { + "health_status": "red", + "priority": 11, + "next_step": "Isolate at home and call your healthcare provider" + }, + "PCR.negative": { + "condition": "require-test", + "params": { + "interval": { "min": 0, "max": 4 }, + "current_interval": { "min": 0, "max": 4 } + }, + "success": { + "health_status": "yellow", + "priority": 1, + "next_step": "Monitor your test results" + }, + "fail": { + "health_status": "orange", + "priority": 1, + "next_step": "Get a test now", + "reason": "Your status changed to Orange because you are past due for a test." + } + }, + "PCR.invalid": { + "condition": "require-test", + "params": { + "interval": { "min": 0, "max": 1 }, + "current_interval": { "min": 0, "max": 1 } + }, + "success": { + "health_status": null, + "priority": 1, + "next_step": "Get another test asap", + "next_step_interval": 1 + }, + "fail": { + "health_status": "orange", + "priority": 1, + "next_step": "Get a test now", + "reason": "Your status changed to Orange because you are past due for a test." + } + } + } + }, + + "symptoms": { + "rules": [ + { + "counts": { + "gr1": { "min": 2 }, + "gr2": { "min": 1 } + }, + "status": { + "health_status": "orange", + "priority": 1, + "next_step": "Take a COVID-19 test now", + "reason": "Your status changed to Orange because you self-reported symptoms consistent with the virus." + } + } + ], + + "groups": [ + { + "id": null, + "name": "gr0", + "symptoms": [ + { + "id": "b669503f-938b-11ea-8f2a-0a58a9feac2a", + "name": "No symptoms" + } + ] + }, + { + "id": "0952bb51-937b-11ea-8f2a-0a58a9feac2a", + "name": "gr1", + "symptoms": [ + { + "id": "b41b12cc-93be-11ea-ae23-0a58a9feac2a", + "name": "Fever" + }, + { + "id": "8f83787b-93c9-11ea-ae23-0a58a9feac2a", + "name": "Chills" + }, + { + "id": "191df3ae-93ca-11ea-ae23-0a58a9feac2a", + "name": "Shaking or Shivering" + }, + { + "id": "9ee1831e-93ca-11ea-ae23-0a58a9feac2a", + "name": "Muscle or joint pain" + }, + { + "id": "acda4f1e-93ca-11ea-ae23-0a58a9feac2a", + "name": "Headache" + }, + { + "id": "bad0cc3c-93ca-11ea-ae23-0a58a9feac2a", + "name": "Sore Throat" + }, + { + "id": "d5afe77f-93ca-11ea-ae23-0a58a9feac2a", + "name": "Loss of taste and/or smell" + } + ] + }, + { + "id": "0952df75-937b-11ea-8f2a-0a58a9feac2a", + "name": "gr2", + "symptoms": [ + { + "id": "e35c8441-93ca-11ea-ae23-0a58a9feac2a", + "name": "Cough" + }, + { + "id": "f3b23b65-93ca-11ea-ae23-0a58a9feac2a", + "name": "Shortness of breath" + }, + { + "id": "05239c9e-93cb-11ea-ae23-0a58a9feac2a", + "name": "Difficulty breathing" + } + ] + } + ] + + }, + + "contact_trace": { + "rules": [ + { + "duration": { "min": 120 }, + "status": { + "condition": "timeout", + "params": { + "interval": { "min": 0, "max": 4 } + }, + "success": { + "condition": "require-test", + "params": { + "interval": { "min": 5, "max": null } + }, + "success": null, + "fail": { + "health_status": "orange", + "priority": 1, + "next_step": "Get a test now", + "reason": "Your status changed to Orange because you are past due for a test." + } + }, + "fail": { + "health_status": "orange", + "priority": 2, + "next_step": "Take a COVID-19 test after {next_step_date}", + "next_step_interval": 4, + "reason": "Your status changed to Orange because you received an exposure notification." + } + } + } + ] + }, + + "actions": { + "rules": [ + { + "type": "quarantine-on", + "status": { + "health_status": "orange", + "priority": 10, + "next_step": "Stay at home and avoid contacts", + "reason": "Your status changed to Orange because the Public Health department placed you in Quarantine." + } + }, + { + "type": "quarantine-off", + "status": { + "condition": "require-test", + "params": { + "interval": { "min": 0, "max": 4 }, + "current_interval": { "min": 0, "max": 4 } + }, + "success": { + "health_status": "yellow", + "priority": -1, + "next_step": "Resume testing on your assigned days" + }, + "fail": { + "health_status": "orange", + "priority": -1, + "next_step": "Get a test now", + "reason": "Your status changed to Orange because you are past due for a test." + } + } + }, + { + "type": "out-of-test-compliance", + "status": { + "condition": "require-test", + "params": { + "interval": { "min": -1, "max": 1 }, + "current_interval": { "min": -1, "max": 1 } + }, + "success": null, + "fail": { + "health_status": "orange", + "priority": 1, + "next_step": "Get a test now", + "reason": "Your status changed to Orange because you are past due for a test." + } + } + }, + { + "type": "test_pending", + "status": { + "condition": "require-test", + "params": { + "interval": { "min": 0, "max": 4 }, + "current_interval": { "min": 0, "max": 4 } + }, + "success": { + "health_status": "yellow", + "priority": 1, + "next_step": "Monitor your test results" + }, + "fail": { + "health_status": "orange", + "priority": 1, + "next_step": "Get a test now", + "reason": "Your status changed to Orange because you are past due for a test." + } + } + } + ] + } +} diff --git a/assets/sample.health.symptoms.json b/assets/sample.health.symptoms.json new file mode 100644 index 00000000..e1db5ba4 --- /dev/null +++ b/assets/sample.health.symptoms.json @@ -0,0 +1,64 @@ +[ + { + "id": null, + "name": "gr0", + "symptoms": [ + { + "id": "b669503f-938b-11ea-8f2a-0a58a9feac2a", + "name": "No symptoms" + } + ] + }, + { + "id": "0952bb51-937b-11ea-8f2a-0a58a9feac2a", + "name": "gr1", + "symptoms": [ + { + "id": "b41b12cc-93be-11ea-ae23-0a58a9feac2a", + "name": "Fever" + }, + { + "id": "8f83787b-93c9-11ea-ae23-0a58a9feac2a", + "name": "Chills" + }, + { + "id": "191df3ae-93ca-11ea-ae23-0a58a9feac2a", + "name": "Shaking or Shivering" + }, + { + "id": "9ee1831e-93ca-11ea-ae23-0a58a9feac2a", + "name": "Muscle or joint pain" + }, + { + "id": "acda4f1e-93ca-11ea-ae23-0a58a9feac2a", + "name": "Headache" + }, + { + "id": "bad0cc3c-93ca-11ea-ae23-0a58a9feac2a", + "name": "Sore Throat" + }, + { + "id": "d5afe77f-93ca-11ea-ae23-0a58a9feac2a", + "name": "Loss of taste and/or smell" + } + ] + }, + { + "id": "0952df75-937b-11ea-8f2a-0a58a9feac2a", + "name": "gr2", + "symptoms": [ + { + "id": "e35c8441-93ca-11ea-ae23-0a58a9feac2a", + "name": "Cough" + }, + { + "id": "f3b23b65-93ca-11ea-ae23-0a58a9feac2a", + "name": "Shortness of breath" + }, + { + "id": "05239c9e-93cb-11ea-ae23-0a58a9feac2a", + "name": "Difficulty breathing" + } + ] + } +] diff --git a/assets/strings.en.json b/assets/strings.en.json new file mode 100644 index 00000000..07d9b159 --- /dev/null +++ b/assets/strings.en.json @@ -0,0 +1,796 @@ +{ + "app.title":"Safer Illinois", + "app.offline.message.title":"You appear to be offline", + + "dialog.yes.title":"Yes", + "dialog.yes.hint":"", + "dialog.no.title":"No", + "dialog.no.hint":"", + "dialog.ok.title":"OK", + "dialog.ok.hint":"", + "dialog.cancel.title":"Cancel", + "dialog.cancel.hint":"", + "dialog.continue.title":"Continue", + "dialog.continue.hint":"", + "dialog.close.title":"Close", + "dialog.close.hint":"", + + "tabbar.home.title":"Home", + "tabbar.home.hint":"Home Page", + "tabbar.explore.title":"Explore", + "tabbar.explore.hint":"Explore Page", + "tabbar.more.title":"More", + "tabbar.more.hint":"More Page", + "tabbar.browse.title":"Browse", + "tabbar.browse.hint":"Browse Page", + "tabbar.wallet.title":"Wallet", + "tabbar.wallet.hint":"Wallet Page", + + "headerbar.home.title":"Home", + "headerbar.home.hint":"Home Page", + "headerbar.menu.title":"Menu", + "headerbar.menu.hint":"", + "headerbar.search.title":"Search", + "headerbar.search.hint":"Type a search term", + "headerbar.settings.title":"Settings", + "headerbar.settings.hint":"", + "headerbar.search.placehlder":"What you are looking for?", + "headerbar.back.title":"Back", + "headerbar.back.hint":"", + "headerbar.close.title":"Close", + "headerbar.close.hint":"", + "headerbar.saved.title":"Saved", + "headerbar.saved.hint":"", + "headerbar.teams.title":"Teams", + + "toggle_button.status.checked": "checked", + "toggle_button.status.unchecked": "unchecked", + "toggle_button.status.checkbox": "checkbox", + + "panel.menu.button.events.title":"Events", + "panel.menu.button.events.hint":"", + "panel.menu.button.dining.title":"Dining", + "panel.menu.button.dining.hint":"", + "panel.menu.button.athletics.title":"Athletics", + "panel.menu.button.athletics.hint":"", + "panel.menu.button.settings.title":"Settings", + "panel.menu.button.settings.hint":"", + "panel.menu.button.illini_cash.title":"Illini Cash", + "panel.menu.button.illini_cash.hint":"", + "panel.menu.button.create_event.title":"Create an event", + "panel.menu.button.create_event.hint":"", + "panel.menu.button.order_history.title":"Order History", + "panel.menu.button.order_history.hint":"", + "panel.menu.button.sign_out.title":"Sign Out", + "panel.menu.button.sign_out.hint":"", + "panel.menu.button.sign_in.title":"Sign In", + "panel.menu.button.sign_in.hint":"", + "panel.menu.button.create_account.title":"Create account", + "panel.menu.button.create_account.hint":"", + "panel.menu.label.sign_in_as":"Signed in as", + "panel.menu.label.verified_as":"Verified as", + "panel.menu.label.connected_netid":"Connected NetID", + "panel.menu.label.confirm_sign_out":"Are you sure you want to sign out?", + + "panel.onboarding.button.continue.title":"Continue", + "panel.onboarding.button.continue.hint":"", + + "panel.onboarding.get_started.image.welcome.title":"Welcome to Illinois", + "panel.onboarding.get_started.image.welcome.hint":"", + "panel.onboarding.get_started.button.get_started.title":"Get Started", + "panel.onboarding.get_started.button.get_started.hint":"", + "panel.onboarding.get_started.image.safer_in_illinois.title":"Safer in Illinois", + "panel.onboarding.get_started.image.powered.title":"Powered by Rokwire", + + "panel.onboarding.location.label.title":"Turn on Location Services", + "panel.onboarding.location.label.title.hint":"Header 1", + "panel.onboarding.location.label.description":"Required for exposure notifications to work on your phone", + "panel.onboarding.location.button.allow.title":"Share my Location", + "panel.onboarding.location.button.allow.hint":"", + "panel.onboarding.location.button.dont_allow.title":"Not right now", + "panel.onboarding.location.button.dont_allow.hint":"Skip sharing location", + "panel.onboarding.location.label.access_granted":"You have already granted access to this app.", + "panel.onboarding.location.label.access_denied":"You have already denied access to this app.", + + "panel.onboarding.bluetooth.label.title":"Enable Bluetooth", + "panel.onboarding.bluetooth.label.title.hint":"Header 1", + "panel.onboarding.bluetooth.label.description":"Use Bluetooth to alert you to potential exposure to COVID-19.", + "panel.onboarding.bluetooth.button.allow.title":"Enable Bluetooth", + "panel.onboarding.bluetooth.button.allow.hint":"", + "panel.onboarding.bluetooth.button.dont_allow.title":"Not right now", + "panel.onboarding.bluetooth.button.dont_allow.hint":"Skip enabling Bluetooth", + "panel.onboarding.bluetooth.label.access_granted":"You have already granted access to this app.", + "panel.onboarding.bluetooth.label.access_denied":"You have already denied access to this app.", + + "panel.onboarding.notifications.label.title":"Info when you need it", + "panel.onboarding.notifications.label.title.hint":"Header 1", + "panel.onboarding.notifications.label.description":"Get notified about COVID-19 info", + "panel.onboarding.notifications.button.allow.title":"Receive Notifications", + "panel.onboarding.notifications.button.allow.hint":"", + "panel.onboarding.notifications.button.dont_allow.title":"Not right now", + "panel.onboarding.notifications.button.dont_allow.hint":"Skip receiving notifications", + "panel.onboarding.notifications.label.access_granted":"Your settings have been changed.", + + "panel.onboarding.roles.label.title":"Who are you?", + "panel.onboarding.roles.label.title.hint":"Header 1", + "panel.onboarding.roles.label.description":"Select all that apply", + "panel.onboarding.roles.button.student.title":"University Student", + "panel.onboarding.roles.button.student.hint":"", + "panel.onboarding.roles.button.employee.title":"Employee/Affiliate", + "panel.onboarding.roles.button.employee.hint":"", + "panel.onboarding.roles.button.resident.title":"Illinois Resident", + "panel.onboarding.roles.button.resident.hint":"", + "panel.onboarding.roles.button.continue.enabled.title":"Confirm", + "panel.onboarding.roles.button.continue.disabled.title":"Select one", + "panel.onboarding.roles.button.continue.hint":"", + + "panel.onboarding.login.netid.label.title":"Connect your NetID", + "panel.onboarding.login.phone.label.title":"Verify your phone number", + "panel.onboarding.login.netid.label.title.hint":"Header 1", + "panel.onboarding.login.phone.label.title.hint":"Header 1", + "panel.onboarding.login.netid.label.description":"Log in with your NetID", + "panel.onboarding.login.phone.label.description":"This saves your preferences so you can have the same experience on more than one device.", + "panel.onboarding.login.label.login_failed":"Unable to login. Please try again later", + "panel.onboarding.login.netid.button.continue.title":"Log in with NetID", + "panel.onboarding.login.phone.button.continue.title":"Verify My Phone Number", + "panel.onboarding.login.netid.button.continue.hint":"", + "panel.onboarding.login.phone.button.continue.hint":"", + "panel.onboarding.login.netid.button.dont_continue.title":"Not right now", + "panel.onboarding.login.phone.button.dont_continue.title":"Not right now", + "panel.onboarding.login.netid.button.dont_continue.hint":"Skip verification", + "panel.onboarding.login.phone.button.dont_continue.hint":"Skip verification", + + "panel.onboarding.verify_phone.title":"Verify your phone number", + "panel.onboarding.verify_phone.title.hint":"", + "panel.onboarding.verify_phone.description":"To verify your phone number, choose your preferred contact channel, and we'll send you a one-time authentication code.", + "panel.onboarding.verify_phone.description.hint":"", + "panel.onboarding.verify_phone.phone_number.label":"Phone number", + "panel.onboarding.verify_phone.phone_number.hint":"", + "panel.onboarding.verify_phone.text_me.label":"Text me", + "panel.onboarding.verify_phone.text_me.hint":"", + "panel.onboarding.verify_phone.call_me.label":"Call me", + "panel.onboarding.verify_phone.call_me.hint":"", + "panel.onboarding.verify_phone.button.next.label":"Next", + "panel.onboarding.verify_phone.button.next.hint":"Tap to continue", + "panel.onboarding.verify_phone.validation.phone_number.text":"Please, type your phone number", + "panel.onboarding.verify_phone.validation.channel_selection.text":"Please, select verification method", + "panel.onboarding.verify_phone.validation.server_error.text":"Please enter a valid phone number", + + "panel.onboarding.confirm_phone.title":"Confirm your code", + "panel.onboarding.confirm_phone.title.hint":"", + "panel.onboarding.confirm_phone.description.send":"A one time code has been sent to %s. Enter your code below to continue.", + "panel.onboarding.confirm_phone.description.send.hint":"", + "panel.onboarding.confirm_phone.code.label":"One-time code", + "panel.onboarding.confirm_phone.code.hint":"", + "panel.onboarding.confirm_phone.not_received.text.label":"Didn't receive a text?", + "panel.onboarding.confirm_phone.not_received.text.hint":"", + "panel.onboarding.confirm_phone.not_received.call.label":"Didn't receive a call?", + "panel.onboarding.confirm_phone.not_received.call.hint":"", + "panel.onboarding.confirm_phone.button.confirm.label":"Confirm phone number", + "panel.onboarding.confirm_phone.button.confirm.hint":"", + "panel.onboarding.confirm_phone.validation.phone_number.text":"Please, fill your code", + "panel.onboarding.confirm_phone.validation.server_error.text":"Failed to verify code", + + "panel.onboarding.upgrade.required.label.title":"Upgrade Required", + "panel.onboarding.upgrade.required.label.description":"%s app version %s requires an upgrade to version %s or later.", + "panel.onboarding.upgrade.available.label.title":"Upgrade Available", + "panel.onboarding.upgrade.available.label.description":"%s app version %s has newer version %s available.", + "panel.onboarding.upgrade.button.upgrade.title":"Upgrade", + "panel.onboarding.upgrade.button.upgrade.hint":"", + "panel.onboarding.upgrade.button.not_now.title":"Not right now", + "panel.onboarding.upgrade.button.not_now.hint":"", + "panel.onboarding.upgrade.button.dont_show.title":"Don't show again", + "panel.onboarding.upgrade.button.dont_show.hint":"", + + "panel.onboarding.base.not_now.hint":"", + "panel.onboarding.base.not_now.title":"Not right now", + + "panel.settings.sports.heading":"Settings", + "panel.settings.categories.header.title":"YOUR CATEGORIES", + "panel.settings.categories.heading":"Your Categories", + "panel.settings.categories.description":"Tap to follow the categories that interest you most", + + "panel.settings.feedback.label.title": "Provide Feedback", + + "panel.settings.privacy_statement.label.title": "Privacy Statement", + + "panel.settings.label.offline.feedback": "Providing a feedback is not available while offline.", + + "panel.settings.home.settings.header":"Settings", + "panel.settings.home.connect.not_logged_in.title":"Connect to Illinois", + "panel.settings.home.connect.not_logged_in.netid.description.part_1": "Are you a ", + "panel.settings.home.connect.not_logged_in.netid.description.part_2": "student", + "panel.settings.home.connect.not_logged_in.netid.description.part_3": " or ", + "panel.settings.home.connect.not_logged_in.netid.description.part_4": "faculty member", + "panel.settings.home.connect.not_logged_in.netid.description.part_5": "? Log in with your NetID to see Illinois information specific to you, like your Illini Cash and meal plan.", + "panel.settings.home.connect.not_logged_in.netid.title": "Connect your NetID", + "panel.settings.home.connect.not_logged_in.phone.description.part_1": "Don't have a NetID", + "panel.settings.home.connect.not_logged_in.phone.description.part_2": "? Verify your phone number to save your preferences and have the same experience on more than one device.", + "panel.settings.home.connect.not_logged_in.phone.title": "Verify Your Phone Number", + "panel.settings.home.customizations.title":"Customizations", + "panel.settings.home.net_id.title":"Illinois NetID", + "panel.settings.home.phone_ver.title":"Phone Verification", + "panel.settings.home.notifications.title": "Notifications", + "panel.settings.home.user_info.title.sufix":"Welcome to Illinois", + "panel.settings.home.account.title": "Your Account", + "panel.settings.home.account.personal_info.title":"Personal Info", + "panel.settings.home.account.options.payment":"Payment", + "panel.settings.home.customizations.role.title":"Who you are", + "panel.settings.home.customizations.manage_interests.title":"Manage your interests", + "panel.settings.home.customizations.food_filters.title":"Food filters", + "panel.settings.home.customizations.display_times_in_central_time.title": "Display all times in Central Time", + "panel.settings.home.net_id.message":"Connected as ", + "panel.settings.home.net_id.button.disconnect":"Disconnect your NetID", + "panel.settings.home.net_id.button.connect":"Connect your NetID", + "panel.settings.home.phone_ver.message":"Verified as ", + "panel.settings.home.phone_ver.button.connect":"Verify Your Phone Number", + "panel.settings.home.phone_ver.button.disconnect":"Disconnect your Phone", + "panel.settings.home.logout.message":"Are you sure you want to sign out?", + "panel.settings.home.logout.button.yes":"Yes", + "panel.settings.home.logout.no":"No", + "panel.settings.home.security.reset_password":"ResetPassword", + "panel.settings.home.security.face_id":"Use Face ID", + "panel.settings.home.notifications.reminders":"Event reminders", + "panel.settings.home.notifications.athletics_updates":"Athletics updates", + "panel.settings.home.notifications.dining":"Dining specials", + "panel.settings.home.notifications.covid19":"COVID-19 notifications", + "panel.settings.home.privacy.title" :"Privacy", + "panel.settings.home.privacy.edit_my_privacy.title":"Edit My Privacy", + "panel.settings.home.privacy.privacy_statement.title":"Privacy Statement", + "panel.settings.home.button.debug.title":"Debug", + "panel.settings.home.button.debug.hint":"", + "panel.settings.home.button.test.title":"Test", + "panel.settings.home.button.test.hint":"", + "panel.settings.home.feedback.title": "We need your ideas!", + "panel.settings.home.feedback.description": "Enjoying the app? Missing something? Tap on the bottom to submit your idea.", + "panel.settings.home.button.feedback.title": "Submit Feedback", + "panel.settings.home.button.feedback.hint": "", + "panel.settings.home.covid19.exposure_notifications": "Exposure Notifications", + "panel.settings.home.covid19.provider_test_result": "Health Provider Test Results", + "panel.settings.home.covid19.title": "COVID-19", + "panel.settings.home.covid19.qr_code.button.title": "QR Code", + "panel.settings.home.covid19.transfer.button.title": "Transfer", + "panel.settings.home.covid19.qr_code.description.title": "COVID-19 Secret QR code", + "panel.settings.home.covid19.transfer.description.title": "Transfer the COVID-19 secret from your other phone", + "panel.settings.home.covid19.alert.qr_code.scan.failed.msg": "Failed to read QR code.", + "panel.settings.home.covid19.alert.qr_code.transfer.succeeded.msg": "COVID-19 secret transferred successfully.", + "panel.settings.home.covid19.alert.qr_code.transfer.failed.msg": "Failed to transfer COVID-19 secret.", + "panel.settings.home.covid19.alert.reset.prompt": "Doing this will provide you a new COVID-19 Secret QRcode but your previous COVID-19 event history will be lost, continue?", + "panel.settings.home.covid19.alert.reset.failed": "Failed to reset the COVID-19 Secret QRcode", + "panel.settings.home.covid19.text.user.fail": "Unable to retrieve user COVID-19 settings.", + "panel.settings.home.covid19.text.keys.checking": "Checking COVID-19 keys...", + "panel.settings.home.covid19.text.keys.missing.public": "Missing COVID-19 public key", + "panel.settings.home.covid19.text.keys.missing.private": "Missing COVID-19 private key", + "panel.settings.home.covid19.text.keys.mismatch": "COVID-19 keys not paired", + "panel.settings.home.covid19.text.keys.paired": "COVID-19 keys valid and paired", + "panel.settings.home.covid19.text.keys.reset": "Reset the COVID-19 keys pair.", + "panel.settings.home.covid19.text.keys.transfer_or_reset": "Transfer the COVID-19 private key from your other phone or reset the COVID-19 keys pair.", + "panel.settings.home.covid19.text.keys.qr_code": "Show your COVID-19 secret QR code.", + "panel.settings.home.covid19.button.retry.title": "Retry", + "panel.settings.home.covid19.button.reset.title": "Reset", + "panel.settings.home.covid19.button.load.title": "Load", + "panel.settings.home.covid19.button.scan.title": "Scan", + "panel.settings.home.covid19.button.qr_code.title": "QR Code", + + "panel.settings.label.offline.phone_ver":"Verify Your Phone Number is not available while offline.", + + "panel.profile_info.header.title":"PERSONAL INFO", + "panel.profile_info.net_id.title":"NetID", + "panel.profile_info.full_name.title":"Full Name", + "panel.profile_info.first_name.title":"First Name", + "panel.profile_info.middle_name.title":"Middle Name", + "panel.profile_info.last_name.title":"Last Name", + "panel.profile_info.email_address.title":"Email Address", + "panel.profile_info.phone_number.title":"Phone Number", + "panel.profile_info.button.sign_out.title":"Sign Out", + "panel.profile_info.button.sign_out.hint":"", + "panel.profile_info.label.remove_my_info.title":"Remove My Info", + "panel.profile_info.button.remove_my_information.title":"Remove My Information", + "panel.profile_info.button.remove_my_information.hint":"", + "panel.profile_info.dialog.remove_my_information.title":"By answering YES all your personal information and preferences will be deleted from our systems. This action can not be recovered. After deleting the information we will return you to the first screen when you installed the app so you can start again or delete the app.", + "panel.profile_info.dialog.remove_my_information.subtitle": "Are you sure?", + "panel.profile_info.dialog.remove_my_information.yes.title": "Yes", + "panel.profile_info.dialog.remove_my_information.no.title": "No", + + "panel.covid19_passport.header.title": "COVID-19", + "panel.covid19_passport.button.close.title": "Close", + "panel.covid19_passport.button.info_center.title": "Your COVID-19 info center", + "panel.covid19_passport.label.status.empty":"No available status for this County", + "panel.covid19_passport.label.counties.empty":"No counties available", + "panel.covid19_passport.label.county.empty.hint":"Select a county...", + "panel.covid19_passport.label.access.heading": "Building Access", + "panel.covid19_passport.label.access.granted": "GRANTED", + "panel.covid19_passport.label.access.denied": "DENIED", + "panel.covid19_passport.message.missing_id_info": "No Illini ID information found. You may have an expired i-card. Please contact the ID Center.", + + "panel.covid19_guidelines.header.title": "County Guidelines", + "panel.covid19_guidelines.description.title": "Help stop the spread of COVID-19 by following these current guidelines.", + "panel.covid19_guidelines.status.title": "These are based on your %s status in the following county:", + "panel.covid19_guidelines.label.county.empty":"Select a county...", + "panel.covid19_guidelines.no.status":"There are no specific guidelines for your status in this county.", + + "panel.covid19home.header.title": "Safer Illinois Home", + "panel.covid19home.top_heading.title": "Stay Healthy", + "panel.covid19home.label.next_step.title": "NEXT STEP", + "panel.covid19home.label.schedule_after.title": "Schedule after %s", + "panel.covid19home.button.find_test_locations.title": "Find test locations", + "panel.covid19home.button.find_test_locations.hint": "", + "panel.covid19home.button.country_guidelines.title": "County\nGuidelines", + "panel.covid19home.button.country_guidelines.hint": "", + "panel.covid19home.button.care_team.title": "Your\nCare Team", + "panel.covid19home.button.care_team.hint": "", + "panel.covid19home.button.campus_updates.title": "Campus Updates", + "panel.covid19home.button.campus_updates.hint": "", + "panel.covid19home.button.report_test.title": "Report a Test Result", + "panel.covid19home.button.report_test.hint": "", + "panel.covid19home.button.test_history.title": "Your Testing History", + "panel.covid19home.button.test_history.hint": "", + "panel.covid19home.label.health.title":"Your Health", + "panel.covid19home.label.resources.title":"Resources", + "panel.covid19home.label.check_in.title":"Symptom Check-in", + "panel.covid19home.label.check_in.description":"Self-report any symptoms to see if you should get tested or stay home.", + "panel.covid19home.label.result.title":"Add Test Result", + "panel.covid19home.label.result.description":"To keep your status up-to-date.", + "panel.covid19home.label.status.title":"Current Status:", + "panel.covid19home.label.status.na":"Not Available", + "panel.covid19home.button.show_status_card.title":"Show Status Card", + "panel.covid19home.label.campus_updates.title":"Campus Updates", + "panel.covid19home.button.about.title":"About", + "panel.covid19home.label.most_recent_event.title": "MOST RECENT EVENT", + "panel.covid19home.label.provider.self_reported": "Self reported", + "panel.covid19home.label.action_required.title": "Action Required", + "panel.covid19home.label.contact_trace.title": "Contact Trace", + "panel.covid19home.label.reported_symptoms.title": "Self Reported Symptoms", + + "panel.covid19_test_locations.header.title": "Test Locations", + "panel.covid19_test_locations.label.contact.title": "Contact", + "panel.covid19_test_locations.distance.text": "mi away get directions", + "panel.covid19_test_locations.distance.unknown": "unknown distance", + "panel.covid19_test_locations.work_time.unknown": "Unknown working time", + "panel.covid19_test_locations.work_time.open_until":"Open until", + "panel.covid19_test_locations.work_time.closed_until": "Closed until", + "panel.covid19_test_locations.all_providers.text": "All Providers", + "panel.covid19_test_locations.call.hint": "Call", + + "panel.covid19.header.title": "COVID-19", + "panel.covid19.latest_update.title": "Latest Update", + "panel.covid19.latest_update.read_more.title": "Read more", + "panel.covid19.health_status.title": "Your Health", + "panel.covid19.news.title": "COVID-19 News", + "panel.covid19.faq.title":"FAQ", + "panel.covid19.faq.description":"Answers to your most common questions:", + "panel.covid19.faq.update.text":"Updated %s", + "panel.covid19.faq.question.hint":"Double tap to show questions", + "panel.covid19.faq.title.label":"Frequently asked questions", + "panel.covid19.resources.poor_accessibility.hint": "This link takes you to a website outside of the Safer Illinois app", + "panel.covid19.label.current_status.label": "Current status:", + "panel.covid19.button.show_status_card.title": "Show Status Card", + "panel.covid19.button.show_status_card.hint": "", + "panel.covid19.button.country_guidelines.title": "County Guidelines", + "panel.covid19.button.country_guidelines.hint": "", + "panel.covid19.button.campus_updates.title": "Campus Updates", + "panel.covid19.button.campus_updates.hint": "", + "panel.covid19.button.health_history.title": "View Health History", + "panel.covid19.button.health_history.hint": "", + "panel.covid19.finish_setup.title": "Finish setup", + "panel.covid19.finish_setup_description.title": "To use your official status", + "panel.covid19.button.verify_identity.title": "Verify your Identity", + "panel.covid19.unverified.title": "Unverified", + "panel.covid19.button.covid_wellness_center.title": "COVID-19 Wellness Answer Center", + "panel.covid19.button.covid_wellness_center.hint": "", + + "panel.covid19_wellness_center.header.title": "COVID-19 Wellness Center", + "panel.covid19_wellness_center.label.description": "If you having issues with the app or getting a test result, contact the COVID Wellness Answer Center for assistance.", + "panel.covid19_wellness_center.label.email": "Email the Covid Wellness Answer Center at ", + "panel.covid19_wellness_center.label.phone": "Phone the Answer Center at ", + + "panel.covid19_news.header.title": "COVID-19", + "panel.covid19_news.news.posted.label": "Posted on %s", + + "panel.covid19_campus_updates.header.title": "Campus updates", + "panel.covid19_campus_updates.sub_title.title": "University of Illinois COVID-19 updates", + + "panel.covid19.qr_code.title": "COVID-19 QR Code", + "panel.covid19.qr_code.description.heading.1": "If you use more than one device with the Safer Illinois app, use this QR code to transfer the necessary secret to decode your COVID-19 health information.", + "panel.covid19.qr_code.description.heading.2": "Save this QR code so that If you lose or replace your phone, you can retrieve your COVID-19 health information on your new phone.", + "panel.covid19.qr_code.button.save.title": "Save", + "panel.covid19.qr_code.alert.no_qr_code.msg": "There is no QR Code", + "panel.covid19.qr_code.alert.save.success.msg": "Successfully saved qr code in gallery", + "panel.covid19.qr_code.alert.save.fail.msg": "Failed to save qr code in gallery", + "panel.covid19.qr_code.code.hint": "QR code image", + "panel.covid19.qr_code.description.on_boarding.heading.1": "Save this QR code so that If you lose or replace your phone, you can retrieve your COVID-19 health information on your new phone.", + "panel.covid19.qr_code.description.on_boarding.heading.2": "You can always skip this step for now and do it later in Settings", + "panel.covid19.qr_code.button.skip.title.": "Skip", + "panel.covid19.qr_code.button.skip.hint.": "Skip saving", + + "panel.covid19.transfer.title": "Transfer Encryption Key", + "panel.covid19.transfer.label.qr_image_label": "Safer Illinois COVID-19 Code", + "panel.covid19.transfer.label.save_error": "Unable to save the QR code.", + "panel.covid19.transfer.button.continue.hint": "", + "panel.covid19.transfer.primary.heading.title": "Your COVID-19 Encryption Key", + "panel.covid19.transfer.primary.button.save.title": "Save Your Encryption Key", + "panel.covid19.transfer.primary.button.save.hint": "", + "panel.covid19.transfer.secondary.heading.title": "Missing COVID-19 Encryption Key", + "panel.covid19.transfer.secondary.button.scan.heading": "If you are adding a second device:", + "panel.covid19.transfer.secondary.button.scan.description": "If you still have access to your primary device, you can directly scan the COVID-19 Encryption Key QR code from that device.", + "panel.covid19.transfer.secondary.button.scan.title": "Scan Your QR Code", + "panel.covid19.transfer.secondary.button.retrieve.heading": "If you are using a replacement device:", + "panel.covid19.transfer.secondary.button.retrieve.description": "If you no longer have access to your primary device, but saved your QR code to a cloud photo service, you can transfer your COVID-19 Encryption Key by retrieving it from your photos.", + "panel.covid19.transfer.secondary.button.retrieve.title": "Retrieve Your QR Code", + "panel.covid19.transfer.alert.no_qr_code.msg": "There is no QR Code", + "panel.covid19.transfer.alert.save.success.msg": "Successfully saved qr code in ", + "panel.covid19.transfer.alert.save.success.pictures": "Pictures", + "panel.covid19.transfer.alert.save.success.gallery": "Gallery", + "panel.covid19.transfer.alert.save.fail.msg": "Failed to save qr code in ", + "panel.covid19.transfer.alert.qr_code.scan.failed.msg": "Failed to read QR code.", + "panel.covid19.transfer.alert.qr_code.invalid.msg": "Invalid QR code.", + "panel.covid19.transfer.alert.qr_code.not_match.msg": "COVID-19 secret key does not match existing public RSA key.", + "panel.covid19.transfer.alert.qr_code.transfer.succeeded.msg": "COVID-19 secret transferred successfully.", + "panel.covid19.transfer.alert.qr_code.transfer.failed.msg": "Failed to transfer COVID-19 secret.", + + "panel.debug.header.title": "Debug", + + "panel.debug_messaging.header.title": "Messaging", + + "panel.web.offline.message": "You need to be online in order to perform this operation. Please check your Internet connection.", + + "panel.health.onboarding.covid19.intro.label.title": "Join the fight against COVID-19", + "panel.health.onboarding.covid19.intro.label.description": "Track and manage your health to help keep our Illinois community safe", + "panel.health.onboarding.covid19.intro.button.continue.title": "Continue", + "panel.health.onboarding.covid19.intro.button.continue.hint": "", + + "panel.health.onboarding.covid19.how_it_works.heading.title": "How it works", + "panel.health.onboarding.covid19.how_it_works.line1.title": "Testing and limiting exposure are key to slowing the spread of COVID-19.", + "panel.health.onboarding.covid19.how_it_works.line2.title": "You can use this app to:", + "panel.health.onboarding.covid19.how_it_works.line3.title": "Self-diagnose your COVID-19 symptoms and in doing so update your status.", + "panel.health.onboarding.covid19.how_it_works.line4.title": "Automatically receive test results from your healthcare provider.", + "panel.health.onboarding.covid19.how_it_works.line5.title": "Allow your phone to send exposure notifications when you’ve been in proximity to people who test positive.", + "panel.health.onboarding.covid19.how_it_works.button.next.title": "Next", + "panel.health.onboarding.covid19.how_it_works.button.next.hint": "", + + "panel.health.onboarding.covid19.consent.label.title": "Consent for COVID-19 features", + "panel.health.onboarding.covid19.consent.label.description": "Exposure Notifications", + "panel.health.onboarding.covid19.consent.label.content1": "If you consent to exposure notifications, you allow your phone to send an anonymous Bluetooth signal to nearby Safer Illinois app users who are also using this feature. Your phone will receive and record a signal from their phones as well. If one of those users tests positive for COVID-19 in the next 14 days, the app will alert you to your potential exposure and advise you on next steps. Your identity and health status will remain anonymous, as will the identity and health status of all other users.", + "panel.health.onboarding.covid19.consent.check_box.label.participate": "I consent to participate in the Exposure Notification System (requires Bluetooth to be ON).", + "panel.health.onboarding.covid19.consent.check_box.label.allow":"I consent to allow my healthcare provider to provide my test results.", + "panel.health.onboarding.covid19.consent.label.content2": "Automatic Test Results", + "panel.health.onboarding.covid19.consent.label.content3": "I consent to connect test results from my healthcare provider with the Safer Illinois app.", + "panel.health.onboarding.covid19.consent.label.content4": "Your participation in these COVID-19 features is voluntary, and you can stop at any time", + "panel.health.onboarding.covid19.consent.button.consent.title": "Next", + "panel.health.onboarding.covid19.consent.button.consent.hint": "", + "panel.health.onboarding.covid19.consent.button.scroll_to_continue.title": "Scroll to Continue", + "panel.health.onboarding.covid19.consent.label.error.login":"Unable to login in Health", + + "panel.health.onboarding.covid19.resident_info.label.title": "Verify your identity with a government-issued ID", + "panel.health.onboarding.covid19.resident_info.label.description": "After verifying you will receive a color-coded health status based on your county guidelines, symptoms, and any COVID-19 related tests.", + "panel.health.onboarding.covid19.resident_info.button.passport.title": "Passport", + "panel.health.onboarding.covid19.resident_info.button.passport.hint": "", + "panel.health.onboarding.covid19.resident_info.button.drivers_license.title": "Driver's License", + "panel.health.onboarding.covid19.resident_info.button.drivers_license.hint": "", + "panel.health.onboarding.covid19.resident_info.button.verify_later.title": "Verify later", + + "panel.health.onboarding.covid19.review_scan.label.title": "Review your scan", + "panel.health.onboarding.covid19.review_scan.label.name.title": "Name", + "panel.health.onboarding.covid19.review_scan.label.birth_year.title": "Birth Year", + "panel.health.onboarding.covid19.review_scan.message.failed": "Failed to apply scanned data", + "panel.health.onboarding.covid19.review_scan.button.rescan.title": "Re-scan", + "panel.health.onboarding.covid19.review_scan.button.rescan.hint": "", + "panel.health.onboarding.covid19.review_scan.button.use_scan.title": "Use This Scan", + "panel.health.onboarding.covid19.review_scan.button.use_scan.hint": "", + + "panel.health.onboarding.covid19.county.label.title": "What counties do you live and work in?", + "panel.health.onboarding.covid19.county.label.description": "Select all that apply", + "panel.health.onboarding.covid19.county.button.add_county.label": "Add Another County", + "panel.health.onboarding.covid19.county.button.add_county.hint": "", + "panel.health.onboarding.covid19.county.button.next.title": "Next", + "panel.health.onboarding.covid19.county.button.next.hint": "", + "panel.health.onboarding.covid19.county.select.label": "SELECT A COUNTY", + "panel.health.onboarding.covid19.county.dropdown.select.default.label": "Select a county...", + "panel.health.onboarding.covid19.county.alert.unique.message": "Please, select different county in each field!", + + "panel.health.onboarding.covid19.providers.label.title": "Who are your current healthcare providers?", + "panel.health.onboarding.covid19.providers.label.description": "Select all that apply", + "panel.health.onboarding.covid19.providers.button.next.title": "Next", + "panel.health.onboarding.covid19.providers.button.next.hint": "", + + "panel.health.onboarding.covid19.final.label.title": "You’re all set!", + "panel.health.onboarding.covid19.final.label.description": "You've been verified, and a status card has been added to your profile.", + "panel.health.onboarding.covid19.final.label.bottom.description":"You can now use this app as your companion in the fight against COVID-19.", + "panel.health.onboarding.covid19.final.label.unverified.description": "You can now use this app as your companion in the fight against COVID-19.", + "panel.health.onboarding.covid19.final.label.unverified.bottom.description":"To access your COVID-19 status, you will need to upload a Government ID. You can add this any time in the COVID-19 settings.", + "panel.health.onboarding.covid19.final.button.continue.title": "Get started", + "panel.health.onboarding.covid19.final.button.continue.hint": "", + + "panel.health.onboarding.covid19.login.netid.label.title":"Connect your NetID", + "panel.health.onboarding.covid19.login.phone.label.title":"Verify your phone number", + "panel.health.onboarding.covid19.login.netid.label.title.hint":"Header 1", + "panel.health.onboarding.covid19.login.phone.label.title.hint":"Header 1", + "panel.health.onboarding.covid19.login.netid.label.description":"Log in with your NetID to use academic and housing specific features.", + "panel.health.onboarding.covid19.phone.label.description":"This saves your preferences so you can have the same experience on more than one device.", + "panel.health.onboarding.covid19.login.label.login_failed":"Unable to login. Please try again later", + "panel.health.onboarding.covid19.login.netid.button.continue.title":"Log in with NetID", + "panel.health.onboarding.covid19.login.phone.button.continue.title":"Verify My Phone Number", + "panel.health.onboarding.covid19.login.netid.button.continue.hint":"", + "panel.health.onboarding.covid19.login.phone.button.continue.hint":"", + "panel.health.onboarding.covid19.login.netid.button.dont_continue.title":"Not right now", + "panel.health.onboarding.covid19.login.phone.button.dont_continue.title":"Not right now", + "panel.health.onboarding.covid19.login.netid.button.dont_continue.hint":"Skip verification", + "panel.health.onboarding.covid19.login.phone.button.dont_continue.hint":"Skip verification", + "panel.health.onboarding.covid19.login.label.error.login":"Unable to login in Health", + + "panel.health.covid19.about.heading.title":"About", + + "panel.health.covid19.add_test.heading.title":"Add Test Result", + "panel.health.covid19.add_test.label.where_question":"Where was the test taken?", + "panel.health.covid19.add_test.label.information": "Why is this information needed?", + "panel.health.covid19.add_test.label.provider.title":"Healthcare Provider", + "panel.health.covid19.add_test.label.provider.empty_hint":"Select a provider", + "panel.health.covid19.add_test.button.retreive.title":"Retrieve Results", + "panel.health.covid19.add_test.button.enter_manually.title":"Manually Enter", + "panel.health.covid19.add_test.label.info.retrieved.text1": "Results ", + "panel.health.covid19.add_test.label.info.retrieved.text2": "retrieved ", + "panel.health.covid19.add_test.label.info.retrieved.text3": "from your healthcare provider are instantly verified. Any changes to your health status will be reflected instantly.", + "panel.health.covid19.add_test.label.info.manually.text1": "Results ", + "panel.health.covid19.add_test.label.info.manually.text2": "entered manually ", + "panel.health.covid19.add_test.label.info.manually.text3": "will be reviewed and verified by a public healthcare provider. Once verified, status changes may occur.", + "panel.health.covid19.add_test.label.manual_tests_disabled":"Test results from this health care provider will automatically appear if you have consented to Health Provider Test Results in settings and you are connected with your NetID.", + + "panel.health.covid19.care_team.heading.title": "Your Care Team", + "panel.health.covid19.care_team.label.question": "We’re here to help.", + "panel.health.covid19.care_team.label.description": "Reach out to someone on your COVID-19 care team - we're here to help.", + "panel.health.covid19.care_team.label.status": "Current Status:", + "panel.health.covid19.care_team.label.emergency.text1": "In case of an emergency, ", + "panel.health.covid19.care_team.label.emergency.text2": "always call 911.", + "panel.health.covid19.care_team.team.title.mc_kinley": "Call McKinley Health", + "panel.health.covid19.care_team.team.contact.mc_kinley": "1-217-333-2700", + "panel.health.covid19.care_team.team.semantic_contact.mc_kinley": "12173332700", + "panel.health.covid19.care_team.team.description.mc_kinley": "Reach out to someone on the “Dial a Nurse Line” to discuss your symptoms and options for clinical care.", + "panel.health.covid19.care_team.team.title.osf": "OSF Healthcare", + "panel.health.covid19.care_team.team.contact.osf": "1-833-673-5669", + "panel.health.covid19.care_team.team.semantic_contact.osf": "18336735669", + "panel.health.covid19.care_team.team.description.osf": "We’ve partnered with OSF OnCall Connect program and the Illinois Department of Healthcare and Family Services to support you getting through COVID-19. Call the Nurse Hotline at 1-833-OSF-KNOW (833-673-5669) to learn more about the program, which includes delivery of a care kit and digital visits to monitor you over a 16-day period.", + "panel.health.covid19.care_team.label.more_info.title": "More about the OSF OnCall Connect program", + "panel.health.covid19.care_team.label.more_info.description": "Members of the OSF OnCall Connect care team are trained OSF HealthCare Mission Partners who connect with you to provide support and work with you and health care providers as you recover from COVID-19, decreasing the risk of further exposure. OSF OnCall Connect team members check on you daily and should your condition worsen, you will be referred to the Acute COVID@Home program where you will receive monitoring equipment that allows us to evaluate your blood pressure, heart rate and pulse ox.", + "panel.health.covid19.care_team.label.more_info.hint": "Double tap to show more info", + "panel.health.covid19.care_team.label.call.hint": "Call ", + "panel.health.covid19.care_team.label.more_info.link": "Learn more", + + "panel.health.covid19.debug.keys.heading.title.":"COVID-19 Keys", + "panel.health.covid19.debug.keys.label.public_key":"RSA Public Key:", + "panel.health.covid19.debug.keys.label.private_key":"RSA Private Key:", + "panel.health.covid19.debug.keys.label.aes_key":"AES Key:", + "panel.health.covid19.debug.keys.label.blob":"Blob:", + "panel.health.covid19.debug.keys.label.encripted_aes":"Encrypted AES Key:", + "panel.health.covid19.debug.keys.label.encripted_blob":"Encrypted Blob:", + "panel.health.covid19.debug.keys.label.decripted_aes":"Decrypted AES Key:", + "panel.health.covid19.debug.keys.label.decripted_blob":"Decrypted Blob:", + "panel.health.covid19.debug.keys.button.refres.title":"Refresh RSA Keys", + "panel.health.covid19.debug.keys.button.generate_aes.title":"Generate AES Key", + "panel.health.covid19.debug.keys.button.encript.title":"Encrypt", + "panel.health.covid19.debug.keys.button.decript.title":"Decrypt", + "panel.health.covid19.debug.keys.label.error.refres.title":"Refresh Failed", + + "panel.health.covid19.debug.trace.heading.title":"COVID-19 Contact Trace", + "panel.health.covid19.debug.trace.label.contact":"Trace COVID-19 Contact", + "panel.health.covid19.debug.trace.label.date":"Date", + "panel.health.covid19.debug.trace.label.duration":"Duration", + "panel.health.covid19.debug.trace.button.submit.title":"Submit Test Result", + "panel.health.covid19.debug.trace.message.date.text":"Please select a date", + "panel.health.covid19.debug.trace.message.duration.text": "Please enter an integer duration", + "panel.health.covid19.debug.trace.error.submit.text": "Failed to submit contact trace data.", + + "panel.health.covid19.history.header.title":"Your COVID-19 Event History", + "panel.health.covid19.history.label.empty.title":"No History", + "panel.health.covid19.history.label.description":"View your COVID-19 event history.", + "panel.health.covid19.history.label.provider.hint":"provider: ", + "panel.health.covid19.history.label.empty.provider":"Unknown", + "panel.health.covid19.history.label.self_reported.title":"Self Reported Symptoms", + "panel.health.covid19.history.label.self_reported.symptoms":"symptoms: ", + "panel.health.covid19.history.label.contact_trace.title":"Contact Trace", + "panel.health.covid19.history.label.contact_trace.details":"contact trace: ", + "panel.health.covid19.history.label.action.title":"Action Required", + "panel.health.covid19.history.label.action.details":"action: ", + "panel.health.covid19.history.label.result.title":"Result: ", + "panel.health.covid19.history.label.location.title":"Test Location", + "panel.health.covid19.history.label.technician_name.title":"Technician Name", + "panel.health.covid19.history.label.technician_id.title":"Technician ID", + "panel.health.covid19.history.label.more_info.title":"More Info", + "panel.health.covid19.history.label.provider.self_reported": "Self reported", + "panel.health.covid19.history.label.verified": "Verified", + "panel.health.covid19.history.label.verification_pending": "Verification Pending", + "panel.health.covid19.history.message.clear_failed": "Failed to clear COVID-19 event history", + "panel.health.covid19.history.button.repost_history.title": "Request my latest test again", + "panel.health.covid19.history.button.repost_history.hint": "", + "panel.health.covid19.history.message.request_tests": "Your request has been submitted. You should receive your latest test within an hour", + + "panel.health.covid19.qr_code.label.qr_image_label": "Safer Illinois COVID-19 Code", + "panel.health.covid19.qr_code.label.save_error": "Unable to save the QR code.", + "panel.health.covid19.qr_code.button.continue.hint": "", + "panel.health.covid19.qr_code.primary.heading.title": "Your COVID-19 Encryption Key", + "panel.health.covid19.qr_code.primary.description.1": "For your privacy, your healthcare data used for COVID-19 features is encrypted. The encryption key is stored locally on your phone to keep it secure. \n\nTo use the COVID-19 features on another device, you will need to manually transfer this encryption key using the QR code below.", + "panel.health.covid19.qr_code.primary.button.save.title": "Save Your Encryption Key", + "panel.health.covid19.qr_code.primary.button.save.hint": "", + "panel.health.covid19.qr_code.primary.description.2": "In the event your current device is lost or damaged, we suggest you save a copy of this QR code to a cloud photo storage service, so that it can be retrieved on your replacement device. \n\nYou can access and save this key on this device at any time by accessing \"Transfer Your COVID-19 Encryption Key\" from the COVID-19 info center.", + "panel.health.covid19.qr_code.secondary.heading.title": "Looks like you’ve used this feature before on another device", + "panel.health.covid19.qr_code.secondary.description.1": "Do you want to transfer your QR encyrption key to this device to retreive your previous health information?\n\nSelect which one applies to you below. You can always transfer a QR encryption key to this device at a later time using the “Transfer Your COVID-19 Encyrption Key” in the COVID-19 info center or in your app settings.", + "panel.health.covid19.qr_code.secondary.button.scan.heading": "If you are adding a second device:", + "panel.health.covid19.qr_code.secondary.button.scan.description": "If you still have access to your primary device, you can directly scan the COVID-19 Encryption Key QR code from that device.", + "panel.health.covid19.qr_code.secondary.button.scan.title": "Scan Your QR Code", + "panel.health.covid19.qr_code.secondary.button.retrieve.heading": "If you are using a replacement device:", + "panel.health.covid19.qr_code.secondary.button.retrieve.description": "If you no longer have access to your primary device, but saved your QR code to a cloud photo service, you can transfer your COVID-19 Encryption Key by retrieving it from your photos.", + "panel.health.covid19.qr_code.secondary.button.retrieve.title": "Retrieve Your QR Code", + "panel.health.covid19.qr_code.reset.button.heading": "Reset my COVID-19 Secret QRcode:", + "panel.health.covid19.qr_code.reset.button.title": "Reset my COVID-19 Secret QRcode", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.title": "Reset my COVID-19 Secret QRcode", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.description": "Doing this will provide you a new COVID-19 Secret QRcode but your previous COVID-19 event history will be lost, continue?", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.confirm": "Are you sure?", + + "panel.health.covid19.alert.no_qr_code.msg": "There is no QR Code", + "panel.health.covid19.alert.save.success.msg": "Successfully saved qr code in ", + "panel.health.covid19.alert.save.success.pictures": "Pictures", + "panel.health.covid19.alert.save.success.gallery": "Gallery", + "panel.health.covid19.alert.save.fail.msg": "Failed to save qr code in gallery", + "panel.health.covid19.alert.qr_code.scan.failed.msg": "Failed to read QR code.", + "panel.health.covid19.alert.qr_code.invalid.msg": "Invalid QR code.", + "panel.health.covid19.alert.qr_code.not_match.msg": "COVID-19 secret key does not match existing public RSA key.", + "panel.health.covid19.qr_code.alert.qr_code.transfer.succeeded.msg": "COVID-19 secret transferred successfully.", + "panel.health.covid19.alert.qr_code.transfer.failed.msg": "Failed to transfer COVID-19 secret.", + "panel.health.covid19.qr_code.button.continue.title": "Continue", + "panel.health.covid19.qr_code.button.transfer_later.title": "Transfer Later", + + "panel.health.report_test.heading.title":"Manually Enter Result", + "panel.health.report_test.label.date":"TEST DATE AND TIME", + "panel.health.report_test.label.provider":"HEALTHCARE PROVIDER", + "panel.health.report_test.label.date.location":"TEST LOCATION", + "panel.health.report_test.label.location.empty":"Select location…", + "panel.health.report_test.label.type":"TEST TYPE", + "panel.health.report_test.label.result":"RESULT", + "panel.health.report_test.label.result.empty":"Select test result…", + "panel.health.report_test.label.image":"ADD TEST RESULT", + "panel.health.report_test.label.image.hint":"Upload an image of your test result.", + "panel.health.report_test.button.add_image.title":"Upload Image", + "panel.health.report_test.button.add_test.title":"Add Test", + "panel.health.report_test.button.close.title":"Close", + "panel.health.report_test.button.retake.title":"Retake", + "panel.health.report_test.label.select_photo":"Select photo", + "panel.health.report_test.label.select_photo.description":"Please take a photo of the test result or select it from the gallery", + "panel.health.report_test.button.take_photo":"Take photo", + "panel.health.report_test.button.select_gallery":"Select from gallery", + "panel.health.report_test.button.cancel":"Cancel", + "panel.health.report_test.error.create.message":"Unable to create test", + "panel.health.report_test.missing.image.message":"Please upload image", + "panel.health.report_test.future_date.forbidden.message": "You cannot submit a test in the future", + + "widget.health.onboarding.indicator9.label.hint": "Covid-19 Onboarding process", + + "widget.card.button.favorite.on.title":"Add To Favorites", + "widget.card.button.favorite.on.hint":"", + "widget.card.button.favorite.off.title":"Remove From Favorites", + "widget.card.button.favorite.off.hint":"", + "widget.card.label.interests":"Because of your interest in:", + "widget.card.label.converge":"match", + + "panel.health.status_update.heading.title":"Status Update", + "panel.health.status_update.label.status_change":"Based on your results, your status has changed from ", + "panel.health.status_update.label.status_change.to":" to ", + "panel.health.status_update.label.next_steps":"NEXT STEPS", + "panel.health.status_update.label.asap":"ASAP", + "panel.health.status_update.button.find_location.title":"Find location", + "panel.health.status_update.label.loading":"Hang tight while we update your status", + "panel.health.status_update.button.continue.title":"See Next Steps", + "panel.health.status_update.button.continue.hint":"", + "panel.health.status_update.info_dialog.label1": "Status color definitions can change depending on different counties.", + "panel.health.status_update.info_dialog.label2": "Status colors for ", + "panel.health.status_update.info_dialog.label3": "Default status for new users is set to Orange.", + "panel.health.status_update.info_dialog.label4": "An up-to-date on-campus negative test result will reset your COVID-19 status to Yellow, and Building Entry will change to Granted.", + "panel.health.status_update.label.reason.title":"STATUS CHANGED BECAUSE:", + "panel.health.status_update.label.reason.result":"Result:", + "panel.health.status_update.label.reason.symptoms.title":"You reported new symptoms", + "panel.health.status_update.label.reason.exposed.title": "You were exposed to someone who was likely infected", + "panel.health.status_update.label.reason.exposure.detail": "Duration of exposure: ", + "panel.health.status_update.label.reason.action.title": "You were required an action by health authorities", + "panel.health.status_update.label.reason.action.detail": "Action Required: ", + + "panel.health.next_steps.button.continue.title.find_locatio": "Find location", + "panel.health.next_steps.button.continue.title.care_team": "Get in Touch with Care Team", + "panel.health.next_steps.label.next_steps": "NEXT STEPS", + "panel.health.next_steps.label.asap": "ASAP", + + "panel.health.symptoms.heading.title":"Are you experiencing any of these symptoms?", + "panel.health.symptoms.label.error.loading":"Failed to load symptoms.", + "panel.health.symptoms.button.submit.title":"Submit", + "panel.health.symptoms.label.success.submit.message":"Your symptoms have been processed.", + "panel.health.symptoms.label.error.submit":"Failed to submit symptoms.", + + "widget.home_campus_tools.label.campus_tools":"Campus Resources", + "widget.home_campus_tools.button.events.title":"Events", + "widget.home_campus_tools.button.events.hint":"", + "widget.home_campus_tools.button.dining.title":"Dining", + "widget.home_campus_tools.button.dining.hint":"", + "widget.home_campus_tools.button.athletics.title":"Athletics", + "widget.home_campus_tools.button.athletics.hint":"", + "widget.home_campus_tools.button.illini_cash.title":"Illini Cash", + "widget.home_campus_tools.button.illini_cash.hint":"", + "widget.home_campus_tools.button.my_illini.title": "My Illini", + "widget.home_campus_tools.header.my_illini.title": "My Illini", + "widget.home_campus_tools.button.my_illini.hint": "", + "widget.home_campus_tools.button.covid19.title":"COVID-19", + "widget.home_campus_tools.button.covid19.hint":"", + + "widget.home_covid19_info.label.covid19": "COVID-19 Info", + "widget.home_covid19_info.label.info.title": "Help keep Illinois safe", + "widget.home_covid19_info.label.info.description": "Join the fight against COVID-19 by tracking and managing your health.", + + "widget.covid19_news_card.read_more.hint":"Double tap to read more", + + "model.explore.time.today": "Today", + "model.explore.time.tomorrow": "Tomorrow", + "model.explore.time.at": "at", + "model.explore.time.all_day": "All Day", + + "model.user.role.student.title": "Student", + "model.user.role.employee.title": "Employee", + "model.user.role.resident.title": "Illinois Resident", + + "model.covid19.status.color.green": "Safe", + "model.covid19.status.color.yellow": "Caution", + "model.covid19.status.color.orange": "Likely Infected", + "model.covid19.status.color.red": "Infected", + + "model.covid19.step.initial": "Take a SHIELD Saliva Test when you return to campus.", + + "logic.date_time.greeting.morning": "Good morning", + "logic.date_time.greeting.afternoon": "Good afternoon", + "logic.date_time.greeting.evening": "Good evening", + + "logic.polls.unable_to_load_poll": "Unable to load poll", + "logic.polls.no_polls_with_pin": "There are no polls with this 4 Digit Poll #", + "logic.polls.multiple_polls_with_pin": "There are multiple opened polls with this 4 Digit Poll #", + + "logic.general.internal_error": "Internal Error Occured", + "logic.general.invalid_response": "Invalid server responce", + "logic.general.response_error": "Response Error: %s %s", + + "app.exit_dialog.message":"Are you sure you want to exit?", + + "app.common.heading.hint":"Header ", + "app.common.heading.one.hint":"Header 1", + "app.common.heading.two.hint":"Header 2", + "app.common.heading.three.hint":"Header 3", + + "app.common.label.cancelled":"Cancelled", + "app.common.label.other": "Other", + "app.common.label.county": "County", + "app.common.label.read_more": "Read more", + + "app.common.yes":"Yes", + "app.common.no":"No", + + "com.illinois.features2.entry.disable_location_awareness":"Disable location awareness", + "com.illinois.features2.entry.dont_store_location":"Don't store your location", + "com.illinois.features2.entry.remove_preferences":"Remove your preferences", + "com.illinois.features2.entry.log_out":"Log you off system", + "com.illinois.features2.entry.disable_notifications":"Turn off notifications", + "com.illinois.features2.entry.remove_credit_card":"Remove your credit card info", + "com.illinois.features2.entry.dont_share_location":"Don’t share your data with other users", + + "com.illinois.covid19.status.long.orange": "Orange, Test Required", + "com.illinois.covid19.status.long.green": "Green, Recent Antibodies", + "com.illinois.covid19.status.long.yellow": "Yellow, Recent Negative Test", + "com.illinois.covid19.status.long.red": "Red, Positive Test", + "com.illinois.covid19.status.long.no change": "Unchanged", + "com.illinois.covid19.status.type.orange": "Orange", + "com.illinois.covid19.status.type.green": "Green", + "com.illinois.covid19.status.type.yellow": "Yellow", + "com.illinois.covid19.status.type.red": "Red", + "com.illinois.covid19.status.type.no change": "Unchanged", + "com.illinois.covid19.status.description.orange": "Test Required", + "com.illinois.covid19.status.description.green": "Recent Antibodies", + "com.illinois.covid19.status.description.yellow": "Recent Negative Test", + "com.illinois.covid19.status.description.red": "Positive Test", + "com.illinois.covid19.status.description.no change": "", + "com.illinois.covid19.status.info.description.orange": "Orange: First time user, Past due for test, Self-reported symptoms, Received exposure notification or Quarantined", + "com.illinois.covid19.status.info.description.green": "Green: Recent antibodies", + "com.illinois.covid19.status.info.description.yellow": "Yellow: Recent negative test", + "com.illinois.covid19.status.info.description.red": "Red: Positive test" +} diff --git a/assets/strings.es.json b/assets/strings.es.json new file mode 100644 index 00000000..5eefafec --- /dev/null +++ b/assets/strings.es.json @@ -0,0 +1,796 @@ +{ + "app.title":"Illinois más seguro", + "app.offline.message.title":"Parece que estás desconectado", + + "dialog.yes.title":"Sí", + "dialog.yes.hint":"", + "dialog.no.title":"No", + "dialog.no.hint":"", + "dialog.ok.title":"OK", + "dialog.ok.hint":"", + "dialog.cancel.title":"Cancelar", + "dialog.cancel.hint":"", + "dialog.continue.title":"Seguir", + "dialog.continue.hint":"", + "dialog.close.title":"Cerrar", + "dialog.close.hint":"", + + "tabbar.home.title":"Inicio", + "tabbar.home.hint":"Página de inicio", + "tabbar.explore.title":"Explorar", + "tabbar.explore.hint":"Explorar página", + "tabbar.more.title":"Más", + "tabbar.more.hint":"Más página", + "tabbar.browse.title":"Vistazo", + "tabbar.browse.hint":"Vistazo página", + "tabbar.wallet.title":"Cartera", + "tabbar.wallet.hint":"Página de Cartera", + + "headerbar.home.title":"Inicio", + "headerbar.home.hint":"Página de inicio", + "headerbar.menu.title":"Menú", + "headerbar.menu.hint":"", + "headerbar.search.title":"Buscar", + "headerbar.search.hint":"Escriba un término de búsqueda", + "headerbar.settings.title":"Configuración", + "headerbar.settings.hint":"", + "headerbar.search.placehlder":"¿Qué buscas?", + "headerbar.back.title":"Atrás", + "headerbar.back.hint":"", + "headerbar.close.title":"Cerrar", + "headerbar.close.hint":"", + "headerbar.saved.title":"Guardado", + "headerbar.saved.hint":"", + "headerbar.teams.title":"Equipos", + + "toggle_button.status.checked": "marcado", + "toggle_button.status.unchecked": "sin marcar", + "toggle_button.status.checkbox": "casilla de verificación", + + "panel.menu.button.events.title":"Eventos", + "panel.menu.button.events.hint":"", + "panel.menu.button.dining.title":"Comida", + "panel.menu.button.dining.hint":"", + "panel.menu.button.athletics.title":"Atletismo", + "panel.menu.button.athletics.hint":"", + "panel.menu.button.settings.title":"Configuración", + "panel.menu.button.settings.hint":"", + "panel.menu.button.illini_cash.title":"Illini Cash", + "panel.menu.button.illini_cash.hint":"", + "panel.menu.button.create_event.title":"Crear un evento", + "panel.menu.button.create_event.hint":"", + "panel.menu.button.order_history.title":"Historial de pedidos", + "panel.menu.button.order_history.hint":"", + "panel.menu.button.sign_out.title":"Cerrar sesión", + "panel.menu.button.sign_out.hint":"", + "panel.menu.button.sign_in.title":"Iniciar sesión", + "panel.menu.button.sign_in.hint":"", + "panel.menu.button.create_account.title":"Crear cuenta", + "panel.menu.button.create_account.hint":"", + "panel.menu.label.sign_in_as":"Ingresado como", + "panel.menu.label.verified_as":"Verificado como ", + "panel.menu.label.connected_netid":"NetID conectado", + "panel.menu.label.confirm_sign_out":"¿Está seguro de que desea cerrar sesión?", + + "panel.onboarding.button.continue.title":"Continuar", + "panel.onboarding.button.continue.hint":"", + + "panel.onboarding.get_started.image.welcome.title":"Bienvenido a Illinois", + "panel.onboarding.get_started.image.welcome.hint":"", + "panel.onboarding.get_started.button.get_started.title":"Comenzar", + "panel.onboarding.get_started.button.get_started.hint":"", + "panel.onboarding.get_started.image.safer_in_illinois.title":"Safer in Illinois", + "panel.onboarding.get_started.image.powered.title":"Powered by Rokwire", + + "panel.onboarding.location.label.title":"Turn on Location Services", + "panel.onboarding.location.label.hint":"Encabezado 1", + "panel.onboarding.location.label.description":"Se requiere para que las notificaciones de exposición funcionen en su teléfono", + "panel.onboarding.location.button.allow.title":"Compartir mi Ubicación", + "panel.onboarding.location.button.allow.hint":"", + "panel.onboarding.location.button.dont_allow.title":"No en este momento", + "panel.onboarding.location.button.dont_allow.hint":"Omitir ubicación para compartir", + "panel.onboarding.location.label.access_granted":"Ya ha otorgado acceso a esta aplicación", + "panel.onboarding.location.label.access_denied":"Ya ha denegado el acceso a esta aplicación", + + "panel.onboarding.bluetooth.label.title":"Habilitar Bluetooth", + "panel.onboarding.bluetooth.label.title.hint":"Encabezado 1", + "panel.onboarding.bluetooth.label.description":"Usar Bluetooth para alertarlo sobre la posible exposición al COVID-19.", + "panel.onboarding.bluetooth.button.allow.title":"Habilitar Bluetooth", + "panel.onboarding.bluetooth.button.allow.hint":"", + "panel.onboarding.bluetooth.button.dont_allow.title":"No en este momento", + "panel.onboarding.bluetooth.button.dont_allow.hint":"Omitir habilitar Bluetooth", + "panel.onboarding.bluetooth.label.access_granted":"Ya ha otorgado acceso a esta aplicación", + "panel.onboarding.bluetooth.label.access_denied":"Ya ha denegado el acceso a esta aplicación", + + "panel.onboarding.notifications.label.title":"Información cuando la necesitas", + "panel.onboarding.notifications.label.hint":"Encabezado 1", + "panel.onboarding.notifications.label.description":"Reciba notificaciones sobre la información de COVID-19", + "panel.onboarding.notifications.button.allow.title":"Recibir notificaciones", + "panel.onboarding.notifications.button.allow.hint":"", + "panel.onboarding.notifications.button.dont_allow.title":"No en este momento", + "panel.onboarding.notifications.button.dont_allow.hint":"Omitir recibir notificaciones", + "panel.onboarding.notifications.label.access_granted":"Su configuración ha sido cambiada.", + + "panel.onboarding.roles.label.title":"Quién eres", + "panel.onboarding.roles.label.title.hint":"Encabezado 1", + "panel.onboarding.roles.label.description":"Seleccione todos los que correspondan", + "panel.onboarding.roles.button.student.title":"Estudiante universitario", + "panel.onboarding.roles.button.student.hint":"", + "panel.onboarding.roles.button.employee.title":"Empleado/Afiliado", + "panel.onboarding.roles.button.employee.hint":"", + "panel.onboarding.roles.button.resident.title":"Residente de Illinois", + "panel.onboarding.roles.button.resident.hint":"", + "panel.onboarding.roles.button.continue.enabled.title":"Confirmar", + "panel.onboarding.roles.button.continue.disabled.title":"Seleccione uno", + "panel.onboarding.roles.button.continue.hint":"", + + "panel.onboarding.login.netid.label.title":"Conecte su NetID", + "panel.onboarding.login.phone.label.title":"Verifique su número de teléfono", + "panel.onboarding.login.netid.label.title.hint":"Encabezado 1", + "panel.onboarding.login.phone.label.title.hint":"Encabezado 1", + "panel.onboarding.login.netid.label.description":"Inicie sesión con su NetID", + "panel.onboarding.login.phone.label.description":"Esto guarda sus preferencias para que pueda tener la misma experiencia en más de un dispositivo", + "panel.onboarding.login.label.login_failed":"No se puede iniciar sesión. Vuelva a intentarlo más tarde", + "panel.onboarding.login.netid.button.continue.title":"Inicie sesión con NetID", + "panel.onboarding.login.phone.button.continue.title":"Verificar mi número de teléfono", + "panel.onboarding.login.netid.button.continue.hint":"", + "panel.onboarding.login.phone.button.continue.hint":"", + "panel.onboarding.login.netid.button.dont_continue.title":"No en este momento", + "panel.onboarding.login.phone.button.dont_continue.title":"No en este momento", + "panel.onboarding.login.netid.button.dont_continue.hint":"Omitir verificación", + "panel.onboarding.login.phone.button.dont_continue.hint":"Omitir verificación", + + "panel.onboarding.verify_phone.title":"Verifique su número de teléfono", + "panel.onboarding.verify_phone.title.hint":"", + "panel.onboarding.verify_phone.description":"Para verificar su número de teléfono, elija su canal de contacto preferido y le enviaremos un código de autenticación único", + "panel.onboarding.verify_phone.description.hint":"", + "panel.onboarding.verify_phone.phone_number.label":"Número de teléfono", + "panel.onboarding.verify_phone.phone_number.hint":"", + "panel.onboarding.verify_phone.text_me.label":"Envíame un mensaje de texto", + "panel.onboarding.verify_phone.text_me.hint":"", + "panel.onboarding.verify_phone.call_me.label":"Llámame", + "panel.onboarding.verify_phone.call_me.hint":"", + "panel.onboarding.verify_phone.button.next.label":"Siguiente", + "panel.onboarding.verify_phone.button.next.hint":"Toque para continuar", + "panel.onboarding.verify_phone.validation.phone_number.text":"Por favor, escriba su número de teléfono", + "panel.onboarding.verify_phone.validation.channel_selection.text":"Por favor, seleccione el método de verificación", + "panel.onboarding.verify_phone.validation.server_error.text":"Por favor ingrese un número de teléfono válido", + + "panel.onboarding.confirm_phone.title":"Confirme su código", + "panel.onboarding.confirm_phone.title.hint":"", + "panel.onboarding.confirm_phone.description.send":"Se ha enviado un código único a %s. Ingrese su código a continuación para continuar.", + "panel.onboarding.confirm_phone.description.send.hint":"", + "panel.onboarding.confirm_phone.code.label":"Código de una sola vez", + "panel.onboarding.confirm_phone.code.hint":"", + "panel.onboarding.confirm_phone.not_received.text.label":"¿No recibió un mensaje de texto?", + "panel.onboarding.confirm_phone.not_received.text.hint":"", + "panel.onboarding.confirm_phone.not_received.call.label":"¿No recibió una llamada?", + "panel.onboarding.confirm_phone.not_received.call.hint":"", + "panel.onboarding.confirm_phone.button.confirm.label":"Confirmar número de teléfono", + "panel.onboarding.confirm_phone.button.confirm.hint":"", + "panel.onboarding.confirm_phone.validation.phone_number.text":"Por favor, complete su código", + "panel.onboarding.confirm_phone.validation.server_error.text":"Error al verificar el código", + + "panel.onboarding.upgrade.required.label.title":"Actualización requerida", + "panel.onboarding.upgrade.required.label.description":"%s versión de la aplicación %s requiere una actualización a la versión %s o posterior", + "panel.onboarding.upgrade.available.label.title":"Actualización disponible", + "panel.onboarding.upgrade.available.label.description":"%s la versión de la aplicación %s tiene la versión más nueva %s disponible", + "panel.onboarding.upgrade.button.upgrade.title":"Actualizar", + "panel.onboarding.upgrade.button.upgrade.hint":"", + "panel.onboarding.upgrade.button.not_now.title":"No en este momento", + "panel.onboarding.upgrade.button.not_now.hint":"", + "panel.onboarding.upgrade.button.dont_show.title":"No mostrar de nuevo", + "panel.onboarding.upgrade.button.dont_show.hint":"", + + "panel.onboarding.base.not_now.hint":"", + "panel.onboarding.base.not_now.title":"No en este momento", + + "panel.settings.sports.heading":"Configuraciónes", + "panel.settings.categories.header.title":"SUS CATEGORíAS", + "panel.settings.categories.heading":"Sus categorías", + "panel.settings.categories.description":"Toque para seguir las categorías que más le interesen", + + "panel.settings.feedback.label.title": "Proporcionar comentarios", + + "panel.settings.privacy_statement.label.title": "Declaración de privacidad", + + "panel.settings.label.offline.feedback": "Proporcionar un comentario no está disponible sin conexión.", + + "panel.settings.home.settings.header":"Configuración", + "panel.settings.home.connect.not_logged_in.title":"Conéctate a Illinois", + "panel.settings.home.connect.not_logged_in.netid.description.part_1": "Eres un ", + "panel.settings.home.connect.not_logged_in.netid.description.part_2": "estudiante", + "panel.settings.home.connect.not_logged_in.netid.description.part_3": " o ", + "panel.settings.home.connect.not_logged_in.netid.description.part_4": "miembro de facultad", + "panel.settings.home.connect.not_logged_in.netid.description.part_5": "? Inicie sesión con su NetID para ver la información de Illinois específica para usted, como su plan Illini Cash y de comidas.", + "panel.settings.home.connect.not_logged_in.netid.title": "Conecte su NetID", + "panel.settings.home.connect.not_logged_in.phone.description.part_1": "No tengo una NetID", + "panel.settings.home.connect.not_logged_in.phone.description.part_2": "? Verifique su número de teléfono para guardar sus preferencias y tenga la misma experiencia en más de un dispositivo.", + "panel.settings.home.connect.not_logged_in.phone.title": "Verifica tu numero de telefono", + "panel.settings.home.customizations.title":"Personalizaciones", + "panel.settings.home.net_id.title":"Illinois NetID", + "panel.settings.home.phone_ver.title":"Verificación del teléfono", + "panel.settings.home.notifications.title": "Notificaciones", + "panel.settings.home.user_info.title.sufix":"Bienvenido a Illinois", + "panel.settings.home.account.title": "Su cuenta", + "panel.settings.home.account.personal_info.title":"Información personal", + "panel.settings.home.account.options.payment":"Pago", + "panel.settings.home.customizations.role.title":"Quién eres", + "panel.settings.home.customizations.manage_interests.title":"Administre sus intereses", + "panel.settings.home.customizations.food_filters.title":"Filtros de alimentos", + "panel.settings.home.customizations.display_times_in_central_time.title":"Mostrar todas las horas en hora central", + "panel.settings.home.net_id.message":"Conectado como", + "panel.settings.home.net_id.button.disconnect":"Desconecte su NetID", + "panel.settings.home.net_id.button.connect":"Conecte su NetID", + "panel.settings.home.phone_ver.message":"Verificado como", + "panel.settings.home.phone_ver.button.connect":"Verifique su número de teléfono", + "panel.settings.home.phone_ver.button.disconnect":"Desconecta tu teléfono", + "panel.settings.home.logout.message":"¿Está seguro de que desea cerrar sesión?", + "panel.settings.home.logout.button.yes":"Sí", + "panel.settings.home.logout.no":"No", + "panel.settings.home.security.reset_password":"Restablecer la contraseña", + "panel.settings.home.security.face_id":"Usar Face ID", + "panel.settings.home.notifications.reminders":"Recordatorios de eventos", + "panel.settings.home.notifications.athletics_updates":"Actualizaciones de atletismo", + "panel.settings.home.notifications.dining":"Especiales de comidas", + "panel.settings.home.notifications.covid19":"Notificaciones COVID-19", + "panel.settings.home.privacy.title":"Privacidad", + "panel.settings.home.privacy.edit_my_privacy.title":"Editar mi privacidad", + "panel.settings.home.privacy.privacy_statement.title":"Declaración de privacidad", + "panel.settings.home.button.debug.title":"Depurar", + "panel.settings.home.button.debug.hint":"", + "panel.settings.home.button.test.title":"Prueba", + "panel.settings.home.button.test.hint":"", + "panel.settings.home.feedback.title": "¡Necesitamos tus ideas!", + "panel.settings.home.feedback.description": "¿Estás disfrutando la aplicación? ¿Echando de menos algo? Toque en la parte inferior para enviar su idea", + "panel.settings.home.button.feedback.title": "Enviar comentarios", + "panel.settings.home.button.feedback.hint": "", + "panel.settings.home.covid19.exposure_notifications": "Notificaciones de exposición", + "panel.settings.home.covid19.provider_test_result": "Resultados de la prueba del proveedor de salud", + "panel.settings.home.covid19.title": "COVID-19", + "panel.settings.home.covid19.qr_code.button.title": "Código QR", + "panel.settings.home.covid19.transfer.button.title": "Transferir", + "panel.settings.home.covid19.qr_code.description.title": "Código QR secreto de COVID-19", + "panel.settings.home.covid19.transfer.description.title": "Transfiera el secreto COVID-19 desde su otro teléfono", + "panel.settings.home.covid19.alert.qr_code.scan.failed.msg": "No se pudo leer el código QR.", + "panel.settings.home.covid19.alert.qr_code.transfer.succeeded.msg": "El secreto COVID-19 se transfirió con éxito.", + "panel.settings.home.covid19.alert.qr_code.transfer.failed.msg": "Error al transferir el secreto COVID-19.", + "panel.settings.home.covid19.alert.reset.prompt": "Hacer esto le proporcionará un nuevo código QR secreto de COVID-19, pero su historial de eventos de COVID-19 anterior se perderá, ¿continuar?", + "panel.settings.home.covid19.alert.reset.failed": "No se pudo restablecer el código QR secreto", + "panel.settings.home.covid19.text.user.fail": "No se puede recuperar la configuración de COVID-19 del usuario.", + "panel.settings.home.covid19.text.keys.checking": "Comprobando las teclas COVID-19 ...", + "panel.settings.home.covid19.text.keys.missing.public": "Falta la clave pública COVID-19", + "panel.settings.home.covid19.text.keys.missing.private": "Falta la clave privada COVID-19", + "panel.settings.home.covid19.text.keys.mismatch": "Teclas COVID-19 no emparejadas", + "panel.settings.home.covid19.text.keys.paired": "Claves COVID-19 válidas y emparejadas", + "panel.settings.home.covid19.text.keys.reset": "Reinicie el par de llaves COVID-19.", + "panel.settings.home.covid19.text.keys.transfer_or_reset": "Transfiera la clave privada COVID-19 desde su otro teléfono o restablezca el par de claves COVID-19.", + "panel.settings.home.covid19.text.keys.qr_code": "Muestre su código QR secreto COVID-19.", + "panel.settings.home.covid19.button.retry.title": "Reintentar", + "panel.settings.home.covid19.button.reset.title": "Reiniciar", + "panel.settings.home.covid19.button.load.title": "Carga", + "panel.settings.home.covid19.button.scan.title": "Escanear", + "panel.settings.home.covid19.button.qr_code.title": "Código QR", + + "panel.settings.label.offline.phone_ver":"Verifique que su número de teléfono no esté disponible sin conexión.", + + "panel.profile_info.header.title":"INFORMACIÓN PERSONAL", + "panel.profile_info.net_id.title":"NetID", + "panel.profile_info.full_name.title":"Nombre completo", + "panel.profile_info.first_name.title":"Nombre", + "panel.profile_info.middle_name.title":"Segundo nombre", + "panel.profile_info.last_name.title":"Apellido", + "panel.profile_info.email_address.title":"Correo Electrónico", + "panel.profile_info.phone_number.title":"Número de teléfono", + "panel.profile_info.button.sign_out.title":"Cerrar sesión", + "panel.profile_info.button.sign_out.hint":"", + "panel.profile_info.label.remove_my_info.title":"Eliminar mi información", + "panel.profile_info.button.remove_my_information.title":"Eliminar mi información", + "panel.profile_info.button.remove_my_information.hint":"", + "panel.profile_info.dialog.remove_my_information.title":"Al responder SÍ, toda su información personal y sus preferencias se eliminarán de nuestros sistemas. Esta acción no se puede recuperar. Después de eliminar la información, volveremos a la primera pantalla cuando instaló la aplicación para que pueda comenzar de nuevo o eliminar la aplicación ", + "panel.profile_info.dialog.remove_my_information.subtitle":"¿Está seguro?", + "panel.profile_info.dialog.remove_my_information.yes.title":"Sí", + "panel.profile_info.dialog.remove_my_information.no.title": "No", + + "panel.covid19_passport.header.title": "COVID-19", + "panel.covid19_passport.button.close.title": "Cerrar", + "panel.covid19_passport.button.info_center.title": "Su centro de información COVID-19", + "panel.covid19_passport.label.status.empty":"No hay estado disponible para este condado", + "panel.covid19_passport.label.counties.empty":"No hay condados disponibles", + "panel.covid19_passport.label.county.empty.hint":"Seleccione un condado ...", + "panel.covid19_passport.label.access.heading": "Acceso al edificio", + "panel.covid19_passport.label.access.granted": "CONCEDIDO", + "panel.covid19_passport.label.access.denied": "NEGADO", + "panel.covid19_passport.message.missing_id_info": "No se encontró información de identificación de Illini. Es posible que tenga una i-card vencida. Comuníquese con el Centro de identificación.", + + "panel.covid19_guidelines.header.title": "Pautas del condado", + "panel.covid19_guidelines.description.title": "Ayude a detener la propagación de COVID-19 siguiendo estas pautas actuales.", + "panel.covid19_guidelines.status.title": "Estos se basan en su estado% s en el siguiente condado:", + "panel.covid19_guidelines.label.county.empty":"Seleccione un condado ...", + "panel.covid19_guidelines.no.status":"No hay pautas específicas para su estado en este condado.", + + "panel.covid19home.header.title": "COVID-19", + "panel.covid19home.top_heading.title": "No se encontró información de identificación de Illini. Es posible que tenga una i-card vencida. Comuníquese con el Centro de identificación.", + "panel.covid19home.label.next_step.title": "PRÓXIMO PASO", + "panel.covid19home.label.schedule_after.title": "Programar después de% s", + "panel.covid19home.button.find_test_locations.title": "Encuentra ubicaciones de prueba", + "panel.covid19home.button.find_test_locations.hint": "", + "panel.covid19home.button.country_guidelines.title": "Pautas\ndel condado", + "panel.covid19home.button.country_guidelines.hint": "", + "panel.covid19home.button.care_team.title": "Your\nCare Team", + "panel.covid19home.button.care_team.hint": "", + "panel.covid19home.button.campus_updates.title": "Actualizaciones del campus", + "panel.covid19home.button.campus_updates.hint": "", + "panel.covid19home.button.report_test.title": "Reportar un resultado de prueba", + "panel.covid19home.button.report_test.hint": "", + "panel.covid19home.button.test_history.title": "Su historial de pruebas", + "panel.covid19home.button.test_history.hint": "", + "panel.covid19home.label.health.title":"Tu salud", + "panel.covid19home.label.resources.title":"Recursos", + "panel.covid19home.label.check_in.title":"Registro de síntomas", + "panel.covid19home.label.check_in.description":"Autoinforme cualquier síntoma para ver si debe hacerse la prueba o quedarse en casa", + "panel.covid19home.label.result.title":"Agregar resultado de prueba", + "panel.covid19home.label.result.description":"Para mantener su estado actualizado", + "panel.covid19home.label.status.title":"Current Status:", + "panel.covid19home.label.status.na":"No disponible", + "panel.covid19home.button.show_status_card.title":"Mostrar tarjeta de estado", + "panel.covid19home.label.campus_updates.title":"Actualizaciones del campus", + "panel.covid19home.button.about.title":"Acerca de", + "panel.covid19home.label.most_recent_event.title": "EVENTO MAS RECIENTE", + "panel.covid19home.label.provider.self_reported": "Autoinforme", + "panel.covid19home.label.action_required.title": "Acción requerida", + "panel.covid19home.label.contact_trace.title": "Seguimiento de contacto", + "panel.covid19home.label.reported_symptoms.title": "Síntomas autoinformados", + + "panel.covid19_test_locations.header.title": "Lugares de prueba", + "panel.covid19_test_locations.label.contact.title": "Contacto", + "panel.covid19_test_locations.distance.text": "mi distancia y obtener direcciones", + "panel.covid19_test_locations.distance.unknown": "distancia desconocida", + "panel.covid19_test_locations.work_time.unknown": "Tiempo de trabajo desconocido", + "panel.covid19_test_locations.work_time.open_until":"Abierto hasta", + "panel.covid19_test_locations.work_time.closed_until": "Cerrado hasta", + "panel.covid19_test_locations.all_providers.text": "Todos los proveedores", + "panel.covid19_test_locations.call.hint": "Llamada", + + "panel.covid19.header.title": "COVID-19", + "panel.covid19.latest_update.title": "Última actualización", + "panel.covid19.latest_update.read_more.title": "Leer mas", + "panel.covid19.health_status.title": "Tu Saludo", + "panel.covid19.news.title": "Noticias COVID-19", + "panel.covid19.faq.title":"Preguntas más frecuentes", + "panel.covid19.faq.description":"Respuestas a sus preguntas más comunes:", + "panel.covid19.faq.update.text":"actualizado %s", + "panel.covid19.faq.question.hint":"Toca dos veces para mostrar preguntas", + "panel.covid19.faq.title.label":"Preguntas frecuentes", + "panel.covid19.resources.poor_accessibility.hint": "Este enlace lo lleva a un sitio web fuera de la aplicación Safer Illinois", + "panel.covid19.label.current_status.label": "Estado actual:", + "panel.covid19.button.show_status_card.title": "Mostrar tarjeta de estado", + "panel.covid19.button.show_status_card.hint": "", + "panel.covid19.button.country_guidelines.title": "Pautas del condado", + "panel.covid19.button.country_guidelines.hint": "", + "panel.covid19.button.campus_updates.title": "Actualizaciones del campus", + "panel.covid19.button.campus_updates.hint": "", + "panel.covid19.button.health_history.title": "Ver historial de salud", + "panel.covid19.button.health_history.hint": "", + "panel.covid19.finish_setup.title": "Finalizar configuración", + "panel.covid19.finish_setup_description.title": "Para usar su estado oficial", + "panel.covid19.button.verify_identity.title": "Verifica tu identidad", + "panel.covid19.unverified.title": "Inconfirmado", + "panel.covid19.button.covid_wellness_center.title": "Centro de respuestas de bienestar COVID-19", + "panel.covid19.button.covid_wellness_center.hint": "", + + "panel.covid19_wellness_center.header.title": "Centro de bienestar COVID-19", + "panel.covid19_wellness_center.label.description": "Si tiene problemas con la aplicación o obtiene un resultado de prueba, comuníquese con el Centro de respuestas de bienestar de COVID para obtener ayuda.", + "panel.covid19_wellness_center.label.email": "Envíe un correo electrónico al Centro de respuestas de bienestar de Covid al", + "panel.covid19_wellness_center.label.phone": "Llame al Centro de respuestas al", + + "panel.covid19_news.header.title": "COVID-19", + "panel.covid19_news.news.posted.label": "Publicado en %s", + + "panel.covid19_campus_updates.header.title": "Actualizaciones del campus", + "panel.covid19_campus_updates.sub_title.title": "Universidad de Illinois COVID-19 actualizaciones", + + "panel.covid19.qr_code.title": "Código QR de COVID-19", + "panel.covid19.qr_code.description.heading.1": "Si usa más de un dispositivo con la aplicación Safer Illinois, use este código QR para transferir el secreto necesario para decodificar su información de salud COVID-19.", + "panel.covid19.qr_code.description.heading.2": "Guarde este código QR para que si pierde o reemplaza su teléfono, puede recuperar su información de salud COVID-19 en su nuevo teléfono.", + "panel.covid19.qr_code.button.save.title": "Salvar", + "panel.covid19.qr_code.alert.no_qr_code.msg": "No hay código QR", + "panel.covid19.qr_code.alert.save.success.msg": "Código qr guardado correctamente en la galería", + "panel.covid19.qr_code.alert.save.fail.msg": "Error al guardar el código qr en la galería", + "panel.covid19.qr_code.code.hint": "QR code image", + "panel.covid19.qr_code.description.on_boarding.heading.1": "Guarde este código QR para que, si pierde o reemplaza su teléfono, pueda recuperar su información de salud COVID-19 en su nuevo teléfono.", + "panel.covid19.qr_code.description.on_boarding.heading.2": "Siempre puede omitir este paso por ahora y hacerlo más tarde en Configuración", + "panel.covid19.qr_code.button.skip.title.": "Omitir", + "panel.covid19.qr_code.button.skip.hint.": "Omitir guardar", + + "panel.covid19.transfer.title": "Transferir clave de cifrado", + "panel.covid19.transfer.label.qr_image_label": "Safer Illinois COVID-19 Código", + "panel.covid19.transfer.label.save_error": "No se pudo guardar el código QR.", + "panel.covid19.transfer.button.continue.hint": "", + "panel.covid19.transfer.primary.heading.title": "Su clave de cifrado COVID-19", + "panel.covid19.transfer.primary.button.save.title": "Guarde su clave de cifrado", + "panel.covid19.transfer.primary.button.save.hint": "", + "panel.covid19.transfer.secondary.heading.title": "Falta la clave de cifrado COVID-19", + "panel.covid19.transfer.secondary.button.scan.heading": "Si está agregando un segundo dispositivo:", + "panel.covid19.transfer.secondary.button.scan.description": "Si aún tiene acceso a su dispositivo principal, puede escanear directamente el código QR de la clave de cifrado COVID-19 desde ese dispositivo.", + "panel.covid19.transfer.secondary.button.scan.title": "Escanee su código QR", + "panel.covid19.transfer.secondary.button.retrieve.heading": "Si está utilizando un dispositivo de reemplazo:", + "panel.covid19.transfer.secondary.button.retrieve.description": "Si ya no tiene acceso a su dispositivo principal, pero guardó su código QR en un servicio de fotos en la nube, puede transferir su clave de cifrado COVID-19 recuperándola de sus fotos.", + "panel.covid19.transfer.secondary.button.retrieve.title": "Recupera tu código QR", + "panel.covid19.transfer.alert.no_qr_code.msg": "No hay código QR", + "panel.covid19.transfer.alert.save.success.msg": "Código qr guardado correctamente en", + "panel.covid19.transfer.alert.save.success.pictures": "Imágenes", + "panel.covid19.transfer.alert.save.success.gallery": "Galería", + "panel.covid19.transfer.alert.save.fail.msg": "No se pudo guardar el código qr en", + "panel.covid19.transfer.alert.qr_code.scan.failed.msg": "No se pudo leer el código QR.", + "panel.covid19.transfer.alert.qr_code.invalid.msg": "Código QR no es válido.", + "panel.covid19.transfer.alert.qr_code.not_match.msg": "La clave secreta COVID-19 no coincide con la clave RSA pública existente.", + "panel.covid19.transfer.alert.qr_code.transfer.succeeded.msg": "El secreto de COVID-19 se transfirió con éxito.", + "panel.covid19.transfer.alert.qr_code.transfer.failed.msg": "No se pudo transferir el secreto de COVID-19.", + + "panel.debug.header.title": "Depurar", + + "panel.debug_messaging.header.title": "Mensajes", + + "panel.web.offline.message": "Debe estar en línea para realizar esta operación. Por favor revise su conexion a internet.", + + "panel.health.onboarding.covid19.intro.label.title": "Únete a la lucha contra COVID-19", + "panel.health.onboarding.covid19.intro.label.description": "Rastree y administre su salud para ayudar a mantener segura a nuestra comunidad de Illinois", + "panel.health.onboarding.covid19.intro.button.continue.title": "Seguir", + "panel.health.onboarding.covid19.intro.button.continue.hint": "", + + "panel.health.onboarding.covid19.how_it_works.heading.title": "Cómo funciona", + "panel.health.onboarding.covid19.how_it_works.line1.title": "Las pruebas y la limitación de la exposición son clave para frenar la propagación de COVID-19.", + "panel.health.onboarding.covid19.how_it_works.line2.title": "Proporcione cualquier síntoma de COVID-19 que esté experimentando, reciba o ingrese automáticamente los resultados de las pruebas de su proveedor de atención médica, y permita que su teléfono le envíe notificaciones de exposición a usted y a las personas con las que ha estado en contacto durante los últimos 14 días.", + "panel.health.onboarding.covid19.how_it_works.line3.title": "Autodiagnostica los síntomas de COVID-19 y, al hacerlo, actualiza tu estado.", + "panel.health.onboarding.covid19.how_it_works.line4.title": "Reciba automáticamente los resultados de las pruebas de su proveedor de atención médica.", + "panel.health.onboarding.covid19.how_it_works.line5.title": "Permita que su teléfono envíe notificaciones de exposición cuando haya estado cerca de personas que dieron positivo en la prueba.", + "panel.health.onboarding.covid19.how_it_works.button.next.title": "Próximo", + "panel.health.onboarding.covid19.how_it_works.button.next.hint": "", + + "panel.health.onboarding.covid19.consent.label.title": "Consentimientos especiales para las características de COVID-19", + "panel.health.onboarding.covid19.consent.label.description": "Notificaciones de exposición", + "panel.health.onboarding.covid19.consent.label.content1": "Si acepta las notificaciones de exposición, permite que su teléfono envíe una señal anónima de Bluetooth a los usuarios cercanos de la aplicación Safer Illinois que también usan esta función. Su teléfono también recibirá y grabará una señal de sus teléfonos. Si uno de esos usuarios da positivo por COVID-19 en los próximos 14 días, la aplicación lo alertará sobre su posible exposición y le aconsejará sobre los próximos pasos. Su identidad y estado de salud permanecerán anónimos, al igual que la identidad y el estado de salud de todos los demás usuarios.", + "panel.health.onboarding.covid19.consent.check_box.label.participate": "Doy mi consentimiento para participar en el Sistema de notificación de exposición (requiere que Bluetooth esté activado).", + "panel.health.onboarding.covid19.consent.check_box.label.allow":"Doy mi consentimiento para permitir que mi proveedor de atención médica proporcione los resultados de mi prueba.", + "panel.health.onboarding.covid19.consent.label.content2": "Resultados automáticos de prueba", + "panel.health.onboarding.covid19.consent.label.content3": "Doy mi consentimiento para conectar los resultados de las pruebas de mi proveedor de atención médica con la aplicación Safer Illinois.", + "panel.health.onboarding.covid19.consent.label.content4": "Su participación es voluntaria y puede detenerse en cualquier momento", + "panel.health.onboarding.covid19.consent.button.consent.title": "Próximo", + "panel.health.onboarding.covid19.consent.button.consent.hint": "", + "panel.health.onboarding.covid19.consent.button.scroll_to_continue.title": "Desplácese para continuar", + "panel.health.onboarding.covid19.consent.label.error.login":"No se puede iniciar sesión en Health", + + "panel.health.onboarding.covid19.resident_info.label.title": "Verifique su identidad con una identificación emitida por el gobierno", + "panel.health.onboarding.covid19.resident_info.label.description": "Después de verificar, recibirá un estado de salud codificado por colores según las pautas, los síntomas y cualquier prueba relacionada con COVID-19 de su condado.", + "panel.health.onboarding.covid19.resident_info.button.passport.title": "Pasaporte", + "panel.health.onboarding.covid19.resident_info.button.passport.hint": "", + "panel.health.onboarding.covid19.resident_info.button.drivers_license.title": "Licencia de conducir", + "panel.health.onboarding.covid19.resident_info.button.drivers_license.hint": "", + "panel.health.onboarding.covid19.resident_info.button.verify_later.title": "Verificar luego", + + "panel.health.onboarding.covid19.review_scan.label.title": "Revisa tu escaneo", + "panel.health.onboarding.covid19.review_scan.label.name.title": "Revisa tu nombre", + "panel.health.onboarding.covid19.review_scan.label.birth_year.title": "Año de nacimiento", + "panel.health.onboarding.covid19.review_scan.message.failed": "Error al aplicar los datos escaneados", + "panel.health.onboarding.covid19.review_scan.button.rescan.title": "Vuelva a escanear", + "panel.health.onboarding.covid19.review_scan.button.rescan.hint": "", + "panel.health.onboarding.covid19.review_scan.button.use_scan.title": "Use este escaneo", + "panel.health.onboarding.covid19.review_scan.button.use_scan.hint": "", + + "panel.health.onboarding.covid19.county.label.title": "¿En qué condados vive y trabaja?", + "panel.health.onboarding.covid19.county.label.description": "Seleccione todas las que correspondan", + "panel.health.onboarding.covid19.county.button.add_county.label": "Agregar otro condado", + "panel.health.onboarding.covid19.county.button.add_county.hint": "", + "panel.health.onboarding.covid19.county.button.next.title": "Próximo", + "panel.health.onboarding.covid19.county.button.next.hint": "", + "panel.health.onboarding.covid19.county.select.label": "SELECCIONA UN CONDADO", + "panel.health.onboarding.covid19.county.dropdown.select.default.label": "Seleccione un condado ...", + "panel.health.onboarding.covid19.county.alert.unique.message": "¡Por favor, seleccione un condado diferente en cada campo!", + + "panel.health.onboarding.covid19.providers.label.title": "¿Quiénes son sus proveedores de atención médica actuales?", + "panel.health.onboarding.covid19.providers.label.description": "Seleccione todas las que correspondan", + "panel.health.onboarding.covid19.providers.button.next.title": "Próximo", + "panel.health.onboarding.covid19.providers.button.next.hint": "", + + "panel.health.onboarding.covid19.final.label.title": "¡Ya está todo listo!", + "panel.health.onboarding.covid19.final.label.description": "Has sido verificado y se ha agregado una tarjeta de estado a tu perfil.", + "panel.health.onboarding.covid19.final.label.bottom.description":"Ahora puede usar esta aplicación como su compañero en la lucha contra COVID-19.", + "panel.health.onboarding.covid19.final.label.unverified.description": "Ahora puede usar esta aplicación como su compañero en la lucha contra COVID-19.", + "panel.health.onboarding.covid19.final.label.unverified.bottom.description":"Para acceder a su estado COVID-19, deberá cargar una identificación gubernamental. Puede agregar esto en cualquier momento en la configuración de COVID-19.", + "panel.health.onboarding.covid19.final.button.continue.title": "Empezar", + "panel.health.onboarding.covid19.final.button.continue.hint": "", + + "panel.health.onboarding.covid19.login.netid.label.title":"Conecte su NetID", + "panel.health.onboarding.covid19.login.phone.label.title":"Verifica tu numero de telefono", + "panel.health.onboarding.covid19.login.netid.label.title.hint":"Encabezado 1", + "panel.health.onboarding.covid19.login.phone.label.title.hint":"Encabezado 1", + "panel.health.onboarding.covid19.login.netid.label.description":"Inicie sesión con su NetID para usar las características académicas y específicas del dormitorio.", + "panel.health.onboarding.covid19.phone.label.description":"Esto guarda sus preferencias para que pueda tener la misma experiencia en más de un dispositivo.", + "panel.health.onboarding.covid19.login.label.login_failed":"Incapaz de iniciar sesión. Por favor, inténtelo de nuevo más tarde", + "panel.health.onboarding.covid19.login.netid.button.continue.title":"Inicie sesión con NetID", + "panel.health.onboarding.covid19.login.phone.button.continue.title":"Verificar mi número de teléfono", + "panel.health.onboarding.covid19.login.netid.button.continue.hint":"", + "panel.health.onboarding.covid19.login.phone.button.continue.hint":"", + "panel.health.onboarding.covid19.login.netid.button.dont_continue.title":"No ahora", + "panel.health.onboarding.covid19.login.phone.button.dont_continue.title":"No ahora", + "panel.health.onboarding.covid19.login.netid.button.dont_continue.hint":"Omitir verificación", + "panel.health.onboarding.covid19.login.phone.button.dont_continue.hint":"Omitir verificación", + "panel.health.onboarding.covid19.login.label.error.login":"No se puede iniciar sesión en Health", + + "panel.health.covid19.about.heading.title":"Acerca de", + + "panel.health.covid19.add_test.heading.title":"Agregar resultado de prueba", + "panel.health.covid19.add_test.label.where_question":"¿Dónde se tomó la prueba?", + "panel.health.covid19.add_test.label.information": "¿Por qué se necesita esta información?", + "panel.health.covid19.add_test.label.provider.title":"Proveedor de atención sanitaria", + "panel.health.covid19.add_test.label.provider.empty_hint":"Selecciona un proveedor", + "panel.health.covid19.add_test.button.retreive.title":"Recuperar resultados", + "panel.health.covid19.add_test.button.enter_manually.title":"Ingresar manualmente", + "panel.health.covid19.add_test.label.info.retrieved.text1": "Resultados ", + "panel.health.covid19.add_test.label.info.retrieved.text2": "recuperado ", + "panel.health.covid19.add_test.label.info.retrieved.text3": "De su proveedor de atención médica se verifican al instante. Cualquier cambio en su estado de salud se reflejará instantáneamente.", + "panel.health.covid19.add_test.label.info.manually.text1": "Resultados ", + "panel.health.covid19.add_test.label.info.manually.text2": "ingresado manualmente ", + "panel.health.covid19.add_test.label.info.manually.text3": "será revisado y verificado por un proveedor de atención médica pública. Una vez verificado, pueden ocurrir cambios de estado.", + "panel.health.covid19.add_test.label.manual_tests_disabled":"Test results from this health care provider will automatically appear if you have consented to Health Provider Test Results in settings and you are connected with your NetID.", + + "panel.health.covid19.care_team.heading.title":"Su equipo de atención", + "panel.health.covid19.care_team.label.question":"¿Necesitas algo?", + "panel.health.covid19.care_team.label.description":"Comuníquese con alguien de su equipo de atención de COVID-19; estamos aquí para ayudarlo.", + "panel.health.covid19.care_team.label.status":"Estado actual:", + "panel.health.covid19.care_team.label.emergency.text1": "En caso de emergencia, ", + "panel.health.covid19.care_team.label.emergency.text2": "siempre llame al 911.", + "panel.health.covid19.care_team.team.title.mc_kinley": "Llame a McKinley Health", + "panel.health.covid19.care_team.team.contact.mc_kinley": "1-217-333-2700", + "panel.health.covid19.care_team.team.semantic_contact.mc_kinley": "12173332700", + "panel.health.covid19.care_team.team.description.mc_kinley": "Comuníquese con alguien en la línea de \"Marque una enfermera\" para hablar sobre sus síntomas y opciones de atención clínica.", + "panel.health.covid19.care_team.team.title.osf": "OSF Healthcare", + "panel.health.covid19.care_team.team.contact.osf": "1-833-673-5669", + "panel.health.covid19.care_team.team.semantic_contact.osf": "18336735669", + "panel.health.covid19.care_team.team.description.osf": "We’ve partnered with OSF OnCall Connect program and the Illinois Department of Healthcare and Family Services to support you getting through COVID-19. Call the Nurse Hotline at 1-833-OSF-KNOW (833-673-5669) to learn more about the program, which includes delivery of a care kit and digital visits to monitor you over a 16-day period.", + "panel.health.covid19.care_team.label.more_info.title": "Más información sobre el Programa de trabajadores de salud pandémicos", + "panel.health.covid19.care_team.label.more_info.description": "Un trabajador de salud pandémica (PHW) es un socio capacitado de OSF HealthCare Mission que está conectado con usted para brindarle apoyo y actuar como una conexión directa entre usted y los proveedores de atención médica a medida que se recupera de COVID-19, lo que disminuye el riesgo de una mayor exposición. Si bien los PHW no son enfermeras registradas, las enfermeras clínicas y los médicos estarán disponibles si su afección empeora o si tiene preguntas clínicas específicas. ", + "panel.health.covid19.care_team.label.more_info.hint": "Tocar dos veces para mostrar más información", + "panel.health.covid19.care_team.label.call.hint": "Llamada ", + "panel.health.covid19.care_team.label.more_info.link": "Learn more", + + "panel.health.covid19.debug.keys.heading.title.":"Claves COVID-19", + "panel.health.covid19.debug.keys.label.public_key":"Clave Publica RSA:", + "panel.health.covid19.debug.keys.label.private_key":"Clave Privada RSA:", + "panel.health.covid19.debug.keys.label.aes_key":"Clave AES:", + "panel.health.covid19.debug.keys.label.blob":"Gota:", + "panel.health.covid19.debug.keys.label.encripted_aes":"Clave AES cifrada:", + "panel.health.covid19.debug.keys.label.encripted_blob":"Blob cifrado:", + "panel.health.covid19.debug.keys.label.decripted_aes":"Clave AES descifrada:", + "panel.health.covid19.debug.keys.label.decripted_blob":"Blob descifrado:", + "panel.health.covid19.debug.keys.button.refres.title":"Actualizar claves RSA", + "panel.health.covid19.debug.keys.button.generate_aes.title":"Generar clave AES", + "panel.health.covid19.debug.keys.button.encript.title":"Encriptar", + "panel.health.covid19.debug.keys.button.decript.title":"Descifrar", + "panel.health.covid19.debug.keys.label.error.refres.title":"Actualzación fallida", + + "panel.health.covid19.debug.trace.heading.title":"Seguimiento de contacto COVID-19", + "panel.health.covid19.debug.trace.label.contact":"Seguimiento de contacto COVID-19", + "panel.health.covid19.debug.trace.label.date":"Fecha", + "panel.health.covid19.debug.trace.label.duration":"Duración", + "panel.health.covid19.debug.trace.button.submit.title":"Enviar resultado de prueba", + "panel.health.covid19.debug.trace.message.date.text":"Por favor seleccione una fecha", + "panel.health.covid19.debug.trace.message.duration.text": "Por favor, introduzca una duración entera", + "panel.health.covid19.debug.trace.error.submit.text": "Error al enviar datos de seguimiento de contacto.", + + "panel.health.covid19.history.header.title":"Su historial de eventos de COVID-19", + "panel.health.covid19.history.label.empty.title":"No historia", + "panel.health.covid19.history.label.description":"Ver su historial de eventos de COVID-19.", + "panel.health.covid19.history.label.provider.hint":"proveedor: ", + "panel.health.covid19.history.label.empty.provider":"Desconocido", + "panel.health.covid19.history.label.self_reported.title":"Síntomas autoinformados", + "panel.health.covid19.history.label.self_reported.symptoms":"síntomas: ", + "panel.health.covid19.history.label.contact_trace.title":"Seguimiento de contacto", + "panel.health.covid19.history.label.contact_trace.details":"seguimiento de contacto: ", + "panel.health.covid19.history.label.action.title":"Acción requerida", + "panel.health.covid19.history.label.action.details":"acción: ", + "panel.health.covid19.history.label.result.title":"Resultado: ", + "panel.health.covid19.history.label.location.title":"Lugar de prueba", + "panel.health.covid19.history.label.technician_name.title":"Nombre del técnico", + "panel.health.covid19.history.label.technician_id.title":"ID del técnico", + "panel.health.covid19.history.label.more_info.title":"Más información", + "panel.health.covid19.history.label.provider.self_reported": "Auto reportado", + "panel.health.covid19.history.label.verified": "Verificado", + "panel.health.covid19.history.label.verification_pending": "Verificación pendiente", + "panel.health.covid19.history.message.clear_failed": "No se pudo borrar el historial de eventos de COVID-19", + "panel.health.covid19.history.button.repost_history.title": "Solicitar mi última prueba nuevamente", + "panel.health.covid19.history.button.repost_history.hint": "", + "panel.health.covid19.history.message.request_tests": "Su solicitud ha sido enviada. Debería recibir su última prueba en una hora", + + "panel.health.covid19.qr_code.label.qr_image_label": "Safer Illinois COVID-19 Código", + "panel.health.covid19.qr_code.label.save_error": "No se pudo guardar el código QR.", + "panel.health.covid19.qr_code.button.continue.hint": "", + "panel.health.covid19.qr_code.primary.heading.title": "Su clave de cifrado COVID-19", + "panel.health.covid19.qr_code.primary.description.1": "Para su privacidad, sus datos de atención médica utilizados para las funciones de COVID-19 están encriptados. La clave de cifrado se almacena localmente en su teléfono para mantenerlo seguro. \n\nPara usar las funciones de COVID-19 en otro dispositivo, deberá transferir manualmente esta clave de cifrado utilizando el código QR a continuación.", + "panel.health.covid19.qr_code.primary.button.save.title": "Guarde su clave de cifrado", + "panel.health.covid19.qr_code.primary.button.save.hint": "", + "panel.health.covid19.qr_code.primary.description.2": "En caso de que su dispositivo actual se pierda o se dañe, le sugerimos que guarde una copia de este código QR en un servicio de almacenamiento de fotos en la nube, para que pueda recuperarlo en su dispositivo de reemplazo. \n\nPuede acceder y guardar esta clave en este dispositivo en cualquier momento accediendo a \"Transferir su clave de cifrado COVID-19 \" desde el centro de información COVID-19.", + "panel.health.covid19.qr_code.secondary.heading.title": "Parece que ya usaste esta función en otro dispositivo", + "panel.health.covid19.qr_code.secondary.description.1": "¿Quiere transferir su clave de cifrado QR a este dispositivo para recuperar su información de salud anterior? \n\nSeleccione cuál se aplica a usted a continuación. Siempre puede transferir una clave de cifrado QR a este dispositivo en un momento posterior utilizando la opción \"Transferir su clave de cifrado COVID-19\" en el centro de información COVID-19 o en la configuración de su aplicación.", + "panel.health.covid19.qr_code.secondary.button.scan.heading": "Si está agregando un segundo dispositivo:", + "panel.health.covid19.qr_code.secondary.button.scan.description": "Si aún tiene acceso a su dispositivo principal, puede escanear directamente el código QR de la clave de cifrado COVID-19 desde ese dispositivo.", + "panel.health.covid19.qr_code.secondary.button.scan.title": "Escanee su código QR", + "panel.health.covid19.qr_code.secondary.button.retrieve.heading": "Si está utilizando un dispositivo de reemplazo:", + "panel.health.covid19.qr_code.secondary.button.retrieve.description": "Si ya no tiene acceso a su dispositivo principal, pero guardó su código QR en un servicio de fotos en la nube, puede transferir su clave de cifrado COVID-19 recuperándola de sus fotos.", + "panel.health.covid19.qr_code.secondary.button.retrieve.title": "Recupera tu código QR", + "panel.health.covid19.qr_code.reset.button.heading": "Restablecer mi código QR secreto COVID-19:", + "panel.health.covid19.qr_code.reset.button.title": "Restablecer mi código QR secreto COVID-19", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.title": "Restablecer mi código QR secreto COVID-19", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.description": "Hacer esto le proporcionará un nuevo código QR secreto de COVID-19, pero se perderá su historial de eventos de COVID-19 anterior, ¿continuar?", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.confirm": "Estas seguro", + + "panel.health.covid19.alert.no_qr_code.msg": "No hay código QR", + "panel.health.covid19.alert.save.success.msg": "Código qr guardado correctamente en", + "panel.health.covid19.alert.save.success.pictures": "Imágenes", + "panel.health.covid19.alert.save.success.gallery": "Galería", + "panel.health.covid19.alert.save.fail.msg": "No se pudo guardar el código qr en la galería", + "panel.health.covid19.alert.qr_code.scan.failed.msg": "No se pudo leer el código QR.", + "panel.health.covid19.alert.qr_code.invalid.msg": "Código QR no válido.", + "panel.health.covid19.alert.qr_code.not_match.msg": "La clave secreta COVID-19 no coincide con la clave RSA pública existente.", + "panel.health.covid19.qr_code.alert.qr_code.transfer.succeeded.msg": "El secreto de COVID-19 se transfirió con éxito.", + "panel.health.covid19.alert.qr_code.transfer.failed.msg": "No se pudo transferir el secreto de COVID-19.", + "panel.health.covid19.qr_code.button.continue.title": "Seguir", + "panel.health.covid19.qr_code.button.transfer_later.title": "Transferir más tarde", + + "panel.health.report_test.heading.title":"Introducir manualmente el resultado", + "panel.health.report_test.label.date":"TEST DATE AND TIME", + "panel.health.report_test.label.provider":"PROVEEDOR DE ATENCIÓN SANITARIA", + "panel.health.report_test.label.date.location":"UBICACIÓN DE PRUEBA", + "panel.health.report_test.label.location.empty":"Seleccionar ubicación ...", + "panel.health.report_test.label.type":"TIPO DE PRUEBA", + "panel.health.report_test.label.result":"RESULTADO", + "panel.health.report_test.label.result.empty":"Seleccione el resultado de la prueba ...", + "panel.health.report_test.label.image":"AGREGAR RESULTADO DE PRUEBA", + "panel.health.report_test.label.image.hint":"Sube una imagen del resultado de tu prueba.", + "panel.health.report_test.button.add_image.title":"Sube una imagen", + "panel.health.report_test.button.add_test.title":"Agregar prueba", + "panel.health.report_test.button.close.title":"Cerca", + "panel.health.report_test.button.retake.title":"Volver a tomar", + "panel.health.report_test.label.select_photo":"Seleccione Foto", + "panel.health.report_test.label.select_photo.description":"Tome una foto del resultado de la prueba o selecciónela de la galería", + "panel.health.report_test.button.take_photo":"Tomar foto", + "panel.health.report_test.button.select_gallery":"Seleccionar de la galería", + "panel.health.report_test.button.cancel":"Cancelar", + "panel.health.report_test.error.create.message":"No se puede crear la prueba", + "panel.health.report_test.missing.image.message":"Por favor sube la imagen", + "panel.health.report_test.future_date.forbidden.message": "No puede enviar una prueba en el futuro", + + "widget.health.onboarding.indicator9.label.hint": "Proceso de incorporación de Covid-19", + + "widget.card.button.favorite.on.title":"Agregar a favoritos", + "widget.card.button.favorite.on.hint":"", + "widget.card.button.favorite.off.title":"Eliminar de favoritos", + "widget.card.button.favorite.off.hint":"", + "widget.card.label.interests":"Debido a su interés en:", + "widget.card.label.converge":"partido", + + "panel.health.status_update.heading.title":"Actualización de estado", + "panel.health.status_update.label.status_change":"Según sus resultados, su estado ha cambiado de", + "panel.health.status_update.label.status_change.to":" a ", + "panel.health.status_update.label.next_steps":"PRÓXIMOS PASOS", + "panel.health.status_update.label.asap":"Lo antes posible", + "panel.health.status_update.button.find_location.title":"Encontrar ubicacion", + "panel.health.status_update.label.loading":"Agárrate fuerte mientras actualizamos tu estado", + "panel.health.status_update.button.continue.title":"Ver los siguientes pasos", + "panel.health.status_update.button.continue.hint":"", + "panel.health.status_update.info_dialog.label1": "Las definiciones de color de estado pueden cambiar según los diferentes condados.", + "panel.health.status_update.info_dialog.label2": "Colores de estado para", + "panel.health.status_update.info_dialog.label3": "Default status for new users is set to Orange.", + "panel.health.status_update.info_dialog.label4": "An up-to-date on-campus negative test result will reset your COVID-19 status to Yellow, and Building Entry will change to Granted.", + "panel.health.status_update.label.reason.title":"ESTADO CAMBIÓ PORQUE:", + "panel.health.status_update.label.reason.result":"Resultado", + "panel.health.status_update.label.reason.symptoms.title":"Reportaste nuevos síntomas", + "panel.health.status_update.label.reason.exposed.title": "Estuvo expuesto a alguien que probablemente estaba infectado", + "panel.health.status_update.label.reason.exposure.detail": "Duración de exposición:", + "panel.health.status_update.label.reason.action.title": "Las autoridades sanitarias le solicitaron una acción", + "panel.health.status_update.label.reason.action.detail": "Acción requerida:", + + "panel.health.next_steps.button.continue.title.find_locatio": "Encontrar ubicacion", + "panel.health.next_steps.button.continue.title.care_team": "Póngase en contacto con Care Team", + "panel.health.next_steps.label.next_steps": "PRÓXIMOS PASOS", + "panel.health.next_steps.label.asap": "Lo antes posible", + + "panel.health.symptoms.heading.title":"¿Estás experimentando alguno de estos síntomas?", + "panel.health.symptoms.label.error.loading":"Error al cargar los síntomas.", + "panel.health.symptoms.button.submit.title":"Enviar", + "panel.health.symptoms.label.success.submit.message":"Sus síntomas han sido procesados.", + "panel.health.symptoms.label.error.submit":"Error al enviar los síntomas.", + + "widget.home_campus_tools.label.campus_tools":"Recursos del campus", + "widget.home_campus_tools.button.events.title":"Eventos", + "widget.home_campus_tools.button.events.hint":"", + "widget.home_campus_tools.button.dining.title":"Comida", + "widget.home_campus_tools.button.dining.hint":"", + "widget.home_campus_tools.button.athletics.title":"Atletismo", + "widget.home_campus_tools.button.athletics.hint":"", + "widget.home_campus_tools.button.illini_cash.title":"Illini Cash", + "widget.home_campus_tools.button.illini_cash.hint":"", + "widget.home_campus_tools.button.my_illini.title":"My Illini", + "widget.home_campus_tools.header.my_illini.title": "Mi Illini", + "widget.home_campus_tools.button.my_illini.hint": "", + "widget.home_campus_tools.button.covid19.title":"COVID-19", + "widget.home_campus_tools.button.covid19.hint":"", + + "widget.home_covid19_info.label.covid19": "COVID-19 Información", + "widget.home_covid19_info.label.info.title": "Ayuda a mantener seguro a Illinois", + "widget.home_covid19_info.label.info.description": "Únete a la lucha contra COVID-19 siguiendo y gestionando tu salud", + + "widget.covid19_news_card.read_more.hint":"Toca dos veces para leer más", + + "model.explore.time.today": "Hoy", + "model.explore.time.tomorrow": "Mañana", + "model.explore.time.at": "a", + "model.explore.time.all_day": "Todo el dia", + + "model.user.role.student.title": "Alumno", + "model.user.role.employee.title": "Empleado", + "model.user.role.resident.title": "Residente", + + "model.covid19.status.color.green": "Seguro", + "model.covid19.status.color.yellow": "Precaución", + "model.covid19.status.color.orange": "Probablemente infectado", + "model.covid19.status.color.red": "Infectado", + + "model.covid19.step.initial": "Realice una prueba COVID-19 con su proveedor de atención médica", + + "logic.date_time.greeting.morning": "Buenos días", + "logic.date_time.greeting.afternoon": "Buenas tardes", + "logic.date_time.greeting.evening": "Buena noches", + + "logic.polls.unable_to_load_poll": "No se puede cargar la encuesta", + "logic.polls.no_polls_with_pin": "No hay encuestas con esta encuesta de 4 dígitos #", + "logic.polls.multiple_polls_with_pin": "Hay varias encuestas abiertas con esta encuesta de 4 dígitos #", + + "logic.general.internal_error": "Error interno ocurrido", + "logic.general.invalid_response": "Servidor inválido", + "logic.general.response_error": "Error de respuesta: %s %s", + + "app.exit_dialog.message":"¿Está seguro de que desea salir?", + + "app.common.heading.hint":"Encabezado", + "app.common.heading.one.hint":"Encabezado 1", + "app.common.heading.two.hint":"Encabezado 2", + "app.common.heading.three.hint":"Encabezado 3", + + "app.common.label.cancelled":"Cancelado", + "app.common.label.other": "Otro", + "app.common.label.county": "Condado", + "app.common.label.read_more": "Read more", + + "app.common.yes":"Yes", + "app.common.no":"No", + + "com.illinois.features2.entry.disable_location_awareness":"Deshabilitar reconocimiento de ubicación", + "com.illinois.features2.entry.dont_store_location":"No guardes tu ubicación", + "com.illinois.features2.entry.remove_preferences":"Elimina tus preferencias", + "com.illinois.features2.entry.log_out":"Cerrar sesión en el sistema", + "com.illinois.features2.entry.disable_notifications":"Desactivar las notificaciones", + "com.illinois.features2.entry.remove_credit_card":"Eliminar la información de su tarjeta de crédito", + "com.illinois.features2.entry.dont_share_location":"No comparta sus datos con otros usuarios.", + + "com.illinois.covid19.status.long.orange": "Orange, Test Required", + "com.illinois.covid19.status.long.green": "Anticuerpos verdes recientes", + "com.illinois.covid19.status.long.yellow": "Amarillo, prueba negativa reciente", + "com.illinois.covid19.status.long.red": "Prueba roja, positiva", + "com.illinois.covid19.status.long.no change": "Sin alterar", + "com.illinois.covid19.status.type.orange": "Naranja", + "com.illinois.covid19.status.type.green": "Verde", + "com.illinois.covid19.status.type.yellow": "Amarillo", + "com.illinois.covid19.status.type.red": "Rojo", + "com.illinois.covid19.status.type.no change": "Sin alterar", + "com.illinois.covid19.status.description.orange": "Test Required", + "com.illinois.covid19.status.description.green": "Anticuerpos recientes", + "com.illinois.covid19.status.description.yellow": "Prueba negativa reciente", + "com.illinois.covid19.status.description.red": "Prueba positiva", + "com.illinois.covid19.status.description.no change": "", + "com.illinois.covid19.status.info.description.orange": "Orange: First time user, Past due for test, Self-reported symptoms, Received exposure notification or Quarantined", + "com.illinois.covid19.status.info.description.green": "Green: Recent antibodies", + "com.illinois.covid19.status.info.description.yellow": "Yellow: Recent negative test", + "com.illinois.covid19.status.info.description.red": "Red: Positive test" +} diff --git a/assets/strings.zh.json b/assets/strings.zh.json new file mode 100644 index 00000000..f3fcf6d2 --- /dev/null +++ b/assets/strings.zh.json @@ -0,0 +1,796 @@ +{ + "app.title":"伊利诺伊", + "app.offline.message.title":"伊利諾伊州更安全", + + "dialog.yes.title":"是的", + "dialog.yes.hint":"", + "dialog.no.title":"不", + "dialog.no.hint":"", + "dialog.ok.title":"OK", + "dialog.ok.hint":"", + "dialog.cancel.title":"取消", + "dialog.cancel.hint":"", + "dialog.continue.title":"继续", + "dialog.continue.hint":"", + "dialog.close.title":"关闭", + "dialog.close.hint":"", + + "tabbar.home.title":"主页", + "tabbar.home.hint":"主页", + "tabbar.explore.title":"探索", + "tabbar.explore.hint":"探索页面", + "tabbar.more.title":"更多", + "tabbar.more.hint":"更多页面", + "tabbar.browse.title":"浏览", + "tabbar.browse.hint":"浏览頁面", + "tabbar.wallet.title":"钱包", + "tabbar.wallet.hint":"钱包页面", + + "headerbar.home.title":"主页", + "headerbar.home.hint":"主页", + "headerbar.menu.title":"菜单", + "headerbar.menu.hint":"", + "headerbar.search.title":"搜索", + "headerbar.search.hint":"输入搜索词", + "headerbar.settings.title":"设定", + "headerbar.settings.hint":"", + "headerbar.search.placehlder":"您正在寻找什么?", + "headerbar.back.title":"返回", + "headerbar.back.hint":"", + "headerbar.close.title":"关闭", + "headerbar.close.hint":"", + "headerbar.saved.title":"已保存", + "headerbar.saved.hint":"", + "headerbar.teams.title":"队伍", + + "toggle_button.status.checked":"选中", + "toggle_button.status.unchecked":"未选中", + "toggle_button.status.checkbox":"复选框", + + "panel.menu.button.events.title":"活动", + "panel.menu.button.events.hint":"", + "panel.menu.button.dining.title":"用餐", + "panel.menu.button.dining.hint":"", + "panel.menu.button.athletics.title":"体育", + "panel.menu.button.athletics.hint":"", + "panel.menu.button.settings.title":"设置", + "panel.menu.button.settings.hint":"", + "panel.menu.button.illini_cash.title":"Illini现金", + "panel.menu.button.illini_cash.hint":"", + "panel.menu.button.create_event.title":"创建活动", + "panel.menu.button.create_event.hint":"", + "panel.menu.button.order_history.title":"订单历史记录", + "panel.menu.button.order_history.hint":"", + "panel.menu.button.sign_out.title":"退出", + "panel.menu.button.sign_out.hint":"", + "panel.menu.button.sign_in.title":"登录", + "panel.menu.button.sign_in.hint":"", + "panel.menu.button.create_account.title":"创建帐户", + "panel.menu.button.create_account.hint":"", + "panel.menu.label.sign_in_as":"登录为", + "panel.menu.label.verified_as":"核实为", + "panel.menu.label.connected_netid":"连接NetID", + "panel.menu.label.confirm_sign_out":"您确定要退出吗?", + + "panel.onboarding.button.continue.title":"继续", + "panel.onboarding.button.continue.hint":"", + + "panel.onboarding.get_started.image.welcome.title":"欢迎来到伊利诺伊州", + "panel.onboarding.get_started.image.welcome.hint":"", + "panel.onboarding.get_started.button.get_started.title":"使用入门", + "panel.onboarding.get_started.button.get_started.hint":"", + "panel.onboarding.get_started.image.safer_in_illinois.title":"在伊利诺伊州更安全", + "panel.onboarding.get_started.image.powered.title":"Powered by Rokwire", + + "panel.onboarding.location.label.title":"打开位置服务", + "panel.onboarding.location.label.title.hint":"标题1", + "panel.onboarding.location.label.description":"曝光通知在手機上正常工作所必需", + "panel.onboarding.location.button.allow.title":"分享我的位置", + "panel.onboarding.location.button.allow.hint":"", + "panel.onboarding.location.button.dont_allow.title":"暂时不", + "panel.onboarding.location.button.dont_allow.hint":"跳过共享位置", + "panel.onboarding.location.label.access_granted":"您已经授予对此应用程序的访问权限。", + "panel.onboarding.location.label.access_denied":"您已经拒绝访问此应用程序。", + + "panel.onboarding.bluetooth.label.title":"启用蓝牙", + "panel.onboarding.bluetooth.label.title.hint":"标题1", + "panel.onboarding.bluetooth.label.description":"使用藍牙提醒您可能接觸到COVID-19.", + "panel.onboarding.bluetooth.button.allow.title":"启用蓝牙", + "panel.onboarding.bluetooth.button.allow.hint":"", + "panel.onboarding.bluetooth.button.dont_allow.title":"不是现在", + "panel.onboarding.bluetooth.button.dont_allow.hint":"跳过启用蓝牙", + "panel.onboarding.bluetooth.label.access_granted":"您已授予访问此应用程序的权限", + "panel.onboarding.bluetooth.label.access_denied":"您已授予访问此应用程序的权限", + + "panel.onboarding.notifications.label.title":"需要的信息", + "panel.onboarding.notifications.label.hint":"标题1", + "panel.onboarding.notifications.label.description":"您將收到COVID-19信息", + "panel.onboarding.notifications.button.allow.title":"接收通知", + "panel.onboarding.notifications.button.allow.hint":"", + "panel.onboarding.notifications.button.dont_allow.title":"暂时不", + "panel.onboarding.notifications.button.dont_allow.hint":"跳过接收通知", + "panel.onboarding.notifications.label.access_granted":"您的设置已更改.", + + "panel.onboarding.roles.label.title":"你是", + "panel.onboarding.roles.label.title.hint":"标题1", + "panel.onboarding.roles.label.description":"选择所有适用项", + "panel.onboarding.roles.button.student.title":"大学生", + "panel.onboarding.roles.button.student.hint":"", + "panel.onboarding.roles.button.employee.title":"員工/會員", + "panel.onboarding.roles.button.employee.hint":"", + "panel.onboarding.roles.button.resident.title":"伊利諾伊州居民", + "panel.onboarding.roles.button.resident.hint":"", + "panel.onboarding.roles.button.continue.enabled.title":"確認", + "panel.onboarding.roles.button.continue.disabled.title":"選擇一個", + "panel.onboarding.roles.button.continue.hint":"", + + "panel.onboarding.login.netid.label.title":"连接您的NetID", + "panel.onboarding.login.phone.label.title":"验证您的电话号码", + "panel.onboarding.login.netid.label.title.hint":"标题1", + "panel.onboarding.login.phone.label.title.hint":"标题1", + "panel.onboarding.login.netid.label.description":"用您的NETID登录", + "panel.onboarding.login.phone.label.description":"这将保存您的首选项,因此您可以在多台设备上获得相同的体验。", + "panel.onboarding.login.label.login_failed":"无法登录。请稍后再试", + "panel.onboarding.login.netid.button.continue.title":"使用NetID登录", + "panel.onboarding.login.phone.button.continue.title":"验证我的电话号码", + "panel.onboarding.login.netid.button.continue.hint":"", + "panel.onboarding.login.phone.button.continue.hint":"", + "panel.onboarding.login.netid.button.dont_continue.title":"暂时不", + "panel.onboarding.login.phone.button.dont_continue.title":"暂时不", + "panel.onboarding.login.netid.button.dont_continue.hint":"跳过验证", + "panel.onboarding.login.phone.button.dont_continue.hint":"跳过验证", + + "panel.onboarding.verify_phone.title":"验证您的电话号码", + "panel.onboarding.verify_phone.title.hint":"", + "panel.onboarding.verify_phone.description":"要验证您的电话号码,请选择您首选的联系渠道,我们将向您发送一次身份验证码。", + "panel.onboarding.verify_phone.description.hint":"", + "panel.onboarding.verify_phone.phone_number.label":"电话号码", + "panel.onboarding.verify_phone.phone_number.hint":"", + "panel.onboarding.verify_phone.text_me.label":"发短信给我", + "panel.onboarding.verify_phone.text_me.hint":"", + "panel.onboarding.verify_phone.call_me.label":"给我打电话", + "panel.onboarding.verify_phone.call_me.hint":"", + "panel.onboarding.verify_phone.button.next.label":"下一步", + "panel.onboarding.verify_phone.button.next.hint":"点击以继续", + "panel.onboarding.verify_phone.validation.phone_number.text":"请输入您的电话号码", + "panel.onboarding.verify_phone.validation.channel_selection.text":"请选择验证方法", + "panel.onboarding.verify_phone.validation.server_error.text":"请输入有效的电话号码", + + "panel.onboarding.confirm_phone.title":"确认您的代码", + "panel.onboarding.confirm_phone.title.hint":"", + "panel.onboarding.confirm_phone.description.send":"一次性代碼已發送到%s。 在下面輸入您的代碼以繼續。", + "panel.onboarding.confirm_phone.description.send.hint":"", + "panel.onboarding.confirm_phone.code.label":"一次性代码", + "panel.onboarding.confirm_phone.code.hint":"", + "panel.onboarding.confirm_phone.not_received.text.label":"未收到短信吗?", + "panel.onboarding.confirm_phone.not_received.text.hint":"", + "panel.onboarding.confirm_phone.not_received.call.label":"未接电话吗?", + "panel.onboarding.confirm_phone.not_received.call.hint":"", + "panel.onboarding.confirm_phone.button.confirm.label":"确认电话号码", + "panel.onboarding.confirm_phone.button.confirm.hint":"", + "panel.onboarding.confirm_phone.validation.phone_number.text":"请填写您的代码", + "panel.onboarding.confirm_phone.validation.server_error.text":"无法验证代码", + + "panel.onboarding.upgrade.required.label.title":"需要升级", + "panel.onboarding.upgrade.required.label.description":"%s版本号%s需要升级到%s或者更新的版本。", + "panel.onboarding.upgrade.available.label.title":"升级可用", + "panel.onboarding.upgrade.available.label.description":"%s版本号%s有一个新版本号%s可用。", + "panel.onboarding.upgrade.button.upgrade.title":"升级", + "panel.onboarding.upgrade.button.upgrade.hint":"", + "panel.onboarding.upgrade.button.not_now.title":"现在不", + "panel.onboarding.upgrade.button.not_now.hint":"", + "panel.onboarding.upgrade.button.dont_show.title":"不再显示", + "panel.onboarding.upgrade.button.dont_show.hint":"", + + "panel.onboarding.base.not_now.hint":"", + "panel.onboarding.base.not_now.title":"暂时不", + + "panel.settings.sports.heading":"设置", + "panel.settings.categories.header.title":"你的分类", + "panel.settings.categories.heading":"您的类别", + "panel.settings.categories.description":"点击以遵循您最感兴趣的类别", + + "panel.settings.feedback.label.title":"提供反馈", + + "panel.settings.privacy_statement.label.title":"隐私声明", + + "panel.settings.label.offline.feedback":"离线时无法提供反馈。", + + "panel.settings.home.settings.header":"设置", + "panel.settings.home.connect.not_logged_in.title":"連接到伊利諾伊州", + "panel.settings.home.connect.not_logged_in.netid.description.part_1": "您是不是一位", + "panel.settings.home.connect.not_logged_in.netid.description.part_2": "學生", + "panel.settings.home.connect.not_logged_in.netid.description.part_3": "要么", + "panel.settings.home.connect.not_logged_in.netid.description.part_4": "教員", + "panel.settings.home.connect.not_logged_in.netid.description.part_5": "? 使用您的NetID登錄以查看特定於您的伊利諾伊州信息,例如您的Illini Cash和膳食計劃", + "panel.settings.home.connect.not_logged_in.netid.title": "連接您的NetID", + "panel.settings.home.connect.not_logged_in.phone.description.part_1": "沒有NetID", + "panel.settings.home.connect.not_logged_in.phone.description.part_2": "? 驗證您的電話號碼以保存您的首選項,並在多台設備上獲得相同的體驗", + "panel.settings.home.connect.not_logged_in.phone.title": "驗證您的電話號碼", + "panel.settings.home.customizations.title":"自定义", + "panel.settings.home.net_id.title":"伊利諾伊州NetID", + "panel.settings.home.phone_ver.title":"电话验证", + "panel.settings.home.notifications.title":"通知", + "panel.settings.home.user_info.title.sufix":"欢迎来到伊利诺伊州", + "panel.settings.home.account.title": "您的帳戶", + "panel.settings.home.account.personal_info.title":"个人信息", + "panel.settings.home.account.options.payment":"支付", + "panel.settings.home.customizations.role.title":"你是", + "panel.settings.home.customizations.manage_interests.title":"管理您的兴趣", + "panel.settings.home.customizations.food_filters.title":"食物过滤器", + "panel.settings.home.customizations.display_times_in_central_time.title":"以中部时间显示所有时间", + "panel.settings.home.net_id.message":"已连接为", + "panel.settings.home.net_id.button.disconnect":"断开您的NetID", + "panel.settings.home.net_id.button.connect":"连接您的NetID", + "panel.settings.home.phone_ver.message":"已验证为", + "panel.settings.home.phone_ver.button.connect":"验证您的电话号码", + "panel.settings.home.phone_ver.button.disconnect":"断开你的电话", + "panel.settings.home.logout.message":"确定要退出吗?", + "panel.settings.home.logout.button.yes":"是的", + "panel.settings.home.logout.no":"不", + "panel.settings.home.security.reset_password":"重置密码", + "panel.settings.home.security.face_id":"使用人脸ID", + "panel.settings.home.notifications.reminders":"活动提醒", + "panel.settings.home.notifications.athletics_updates":"体育更新", + "panel.settings.home.notifications.dining":"特价餐厅", + "panel.settings.home.notifications.covid19":"COVID-19通知", + "panel.settings.home.privacy.title":"隐私", + "panel.settings.home.privacy.edit_my_privacy.title":"编辑我的隐私", + "panel.settings.home.privacy.privacy_statement.title":"隐私声明", + "panel.settings.home.button.debug.title":"调试", + "panel.settings.home.button.debug.hint":"", + "panel.settings.home.button.test.title":"测试", + "panel.settings.home.button.test.hint":"", + "panel.settings.home.feedback.title": " 我们需要你的主意!", + "panel.settings.home.feedback.description": " 喜欢这个应用程序吗?我们遗漏了什么?点击底部提交您的想法。", + "panel.settings.home.button.feedback.title": "提交反馈", + "panel.settings.home.button.feedback.hint": "", + "panel.settings.home.covid19.exposure_notifications": "接触通知", + "panel.settings.home.covid19.provider_test_result": "健康提供者测试结果 ", + "panel.settings.home.covid19.title": "COVID-19", + "panel.settings.home.covid19.qr_code.button.title": "二維碼", + "panel.settings.home.covid19.transfer.button.title": "傳遞", + "panel.settings.home.covid19.qr_code.description.title": "COVID-19秘密QR碼", + "panel.settings.home.covid19.transfer.description.title": "從您的其他電話轉移COVID-19機密", + "panel.settings.home.covid19.alert.qr_code.scan.failed.msg": "無法讀取QR碼.", + "panel.settings.home.covid19.alert.qr_code.transfer.succeeded.msg": "COVID-19機密已成功傳輸.", + "panel.settings.home.covid19.alert.qr_code.transfer.failed.msg": "無法傳輸COVID-19機密.", + "panel.settings.home.covid19.alert.reset.prompt": "这样做将为您提供一个新的COVID-19秘密QRcode,但您以前的COVID-19事件历史记录将丢失,是否继续?", + "panel.settings.home.covid19.alert.reset.failed": "重置COVID-19机密QRcode失败", + "panel.settings.home.covid19.text.user.fail": "无法检索用户COVID-19设置.", + "panel.settings.home.covid19.text.keys.checking": "正在检查COVID-19密钥...", + "panel.settings.home.covid19.text.keys.missing.public": "缺少COVID-19公钥", + "panel.settings.home.covid19.text.keys.missing.private": "缺少COVID-19私钥", + "panel.settings.home.covid19.text.keys.mismatch": "COVID-19钥匙未配对", + "panel.settings.home.covid19.text.keys.paired": "COVID-19钥匙有效并配对", + "panel.settings.home.covid19.text.keys.reset": "重置COVID-19密钥对.", + "panel.settings.home.covid19.text.keys.transfer_or_reset": "从您的另一部手机转移COVID-19私钥或重置COVID-19密钥对.", + "panel.settings.home.covid19.text.keys.qr_code": "出示你的COVID-19秘密二维码.", + "panel.settings.home.covid19.button.retry.title": "重试", + "panel.settings.home.covid19.button.reset.title": "重置", + "panel.settings.home.covid19.button.load.title": "加载", + "panel.settings.home.covid19.button.scan.title": "扫描", + "panel.settings.home.covid19.button.qr_code.title": "QR Code", + + "panel.settings.label.offline.phone_ver":"离线时无法验证电话号码。", + + "panel.profile_info.header.title":"个人信息", + "panel.profile_info.net_id.title":"NetID", + "panel.profile_info.full_name.title":"全名", + "panel.profile_info.first_name.title":"名字", + "panel.profile_info.middle_name.title":"中间名", + "panel.profile_info.last_name.title":"姓氏", + "panel.profile_info.email_address.title":"电子邮件地址", + "panel.profile_info.phone_number.title":"电话号码", + "panel.profile_info.button.sign_out.title":"退出", + "panel.profile_info.button.sign_out.hint":"", + "panel.profile_info.label.remove_my_info.title":"删除我的信息", + "panel.profile_info.button.remove_my_information.title":"删除我的信息", + "panel.profile_info.button.remove_my_information.hint":"", + "panel.profile_info.remove_my_information.title":"回答是,您的所有个人信息和偏好将从我们的系统中删除。此操作无法恢复。删除信息后,我们将在安装应用程序时将您带回到第一个屏幕,以便您可以再次启动或删除该应用程序。", + "panel.profile_info.dialog.remove_my_information.subtitle":"确定吗?", + "panel.profile_info.dialog.remove_my_information.yes.title":"是的", + "panel.profile_info.dialog.remove_my_information.no.title":"不", + + "panel.covid19_passport.header.title": "COVID-19", + "panel.covid19_passport.button.close.title": "關", + "panel.covid19_passport.button.info_center.title": "您的COVID-19信息中心", + "panel.covid19_passport.label.status.empty":"此县没有可用状态", + "panel.covid19_passport.label.counties.empty":"沒有可用縣", + "panel.covid19_passport.label.county.empty.hint":"选择一个县...", + "panel.covid19_passport.label.access.heading": "建筑使用权利", + "panel.covid19_passport.label.access.granted": "授予", + "panel.covid19_passport.label.access.denied": "否认", + "panel.covid19_passport.message.missing_id_info": "找不到Illini ID信息. 您的I-Card可能已过期。请与ID中心联系.", + + "panel.covid19_guidelines.header.title": "縣準則", + "panel.covid19_guidelines.description.title": "遵循這些當前準則,有助於阻止COVID-19的傳播。", + "panel.covid19_guidelines.status.title": "这些是基于您在以下县的%s状态:", + "panel.covid19_guidelines.label.county.empty":"选择一个县...", + "panel.covid19_guidelines.no.status":"沒有關於您在該縣的身份的特定指南。", + + "panel.covid19home.header.title": "COVID-19", + "panel.covid19home.top_heading.title": "保持健康", + "panel.covid19home.label.next_step.title": "下一步", + "panel.covid19home.label.schedule_after.title": "在%s之后计划", + "panel.covid19home.button.find_test_locations.title": "查找测试位置", + "panel.covid19home.button.find_test_locations.hint": "", + "panel.covid19home.button.country_guidelines.title": "县指南", + "panel.covid19home.button.country_guidelines.hint": "", + "panel.covid19home.button.care_team.title": "您的\n护理团队", + "panel.covid19home.button.care_team.hint": "", + "panel.covid19home.button.campus_updates.title": "校园更新", + "panel.covid19home.button.campus_updates.hint": "", + "panel.covid19home.button.report_test.title": "报告测试结果", + "panel.covid19home.button.report_test.hint": "", + "panel.covid19home.button.test_history.title": "您的测试历史", + "panel.covid19home.button.test_history.hint": "", + "panel.covid19home.label.health.title":"您的健康", + "panel.covid19home.label.resources.title":"资源", + "panel.covid19home.label.check_in.title":"症状签入“", + "panel.covid19home.label.check_in.description":"自行报告任何症状,以查看是否应接受检查或待在家中", + "panel.covid19home.label.result.title":"添加测试结果", + "panel.covid19home.label.result.description":"保持最新状态", + "panel.covid19home.label.status.title":"当前状态:", + "panel.covid19home.label.status.na":"無法使用", + "panel.covid19home.button.show_status_card.title":"显示状态卡", + "panel.covid19home.label.campus_updates.title":"校园更新", + "panel.covid19home.button.about.title":"关于", + "panel.covid19home.label.most_recent_event.title": "最近的事件", + "panel.covid19home.label.provider.self_reported": "自我报告", + "panel.covid19home.label.action_required.title": "所需操作", + "panel.covid19home.label.contact_trace.title": "接触痕迹", + "panel.covid19home.label.reported_symptoms.title": "自述症状", + + "panel.covid19_test_locations.header.title": "测试位置", + "panel.covid19_test_locations.label.contact.title": "联系人", + "panel.covid19_test_locations.distance.text": "寻路", + "panel.covid19_test_locations.distance.unknown": "未知距离", + "panel.covid19_test_locations.work_time.unknown": "未知工作时间", + "panel.covid19_test_locations.work_time.open_until":"打开到", + "panel.covid19_test_locations.work_time.closed_until": "关闭到", + "panel.covid19_test_locations.all_providers.text": "所有提供者", + "panel.covid19_test_locations.call.hint": "呼叫", + + "panel.covid19.header.title": "COVID-19", + "panel.covid19.latest_update.title": "最近更新", + "panel.covid19.latest_update.read_more.title": "閱讀更多", + "panel.covid19.news.title": "COVID-19新聞", + "panel.covid19.health_status.title": "你的健康", + "panel.covid19.faq.title":"问题解答", + "panel.covid19.faq.description":"回答您最常见的问题:", + "panel.covid19.faq.update.text":"更新了%s", + "panel.covid19.faq.question.hint":"双击可显示问题", + "panel.covid19.faq.title.label":"常见问题", + "panel.covid19.resources.poor_accessibility.hint": "該鏈接將您帶到伊利諾伊州更安全應用之外的網站", + "panel.covid19.label.current_status.label": "当前状态:", + "panel.covid19.button.show_status_card.title": "显示状态卡", + "panel.covid19.button.show_status_card.hint": "", + "panel.covid19.button.country_guidelines.title": "县指南", + "panel.covid19.button.country_guidelines.hint": "", + "panel.covid19.button.campus_updates.title": "校园更新", + "panel.covid19.button.campus_updates.hint": "", + "panel.covid19.button.health_history.title": "查看健康历史记录", + "panel.covid19.button.health_history.hint": "", + "panel.covid19.finish_setup.title": "完成安装", + "panel.covid19.finish_setup_description.title": "使用您的官方身份", + "panel.covid19.button.verify_identity.title": "验证您的身份", + "panel.covid19.unverified.title": "未验证", + "panel.covid19.button.covid_wellness_center.title": "COVID-19健康回答中心", + "panel.covid19.button.covid_wellness_center.hint": "", + + "panel.covid19_wellness_center.header.title": "COVID-19 健康中心", + "panel.covid19_wellness_center.label.description": "如果你对应用程序有问题或得到测试结果,请联系COVID健康回答中心寻求帮助.", + "panel.covid19_wellness_center.label.email": "发送电子邮件至Covid健康回答中心 ", + "panel.covid19_wellness_center.label.phone": "拨打接听中心的电话 ", + + "panel.covid19_news.header.title": "COVID-19", + "panel.covid19_news.news.posted.label": "發表在%s", + + "panel.covid19_campus_updates.header.title": "校園更新", + "panel.covid19_campus_updates.sub_title.title": "伊利諾伊大學COVID-19更新", + + "panel.covid19.qr_code.title": "COVID-19 QR碼", + "panel.covid19.qr_code.description.heading.1": "如果您在“伊利諾伊州更安全”應用程序中使用多個設備,請使用此QR碼傳輸必要的機密,以解碼您的COVID-19健康信息。", + "panel.covid19.qr_code.description.heading.2": "保存此QR碼,以便在丟失或更換手機時,可以在新手機上檢索COVID-19健康信息.", + "panel.covid19.qr_code.button.save.title": "保存", + "panel.covid19.qr_code.alert.no_qr_code.msg": "沒有二維碼", + "panel.covid19.qr_code.alert.save.success.msg": "已成功將二維碼保存在圖庫中", + "panel.covid19.qr_code.alert.save.fail.msg": "無法將二維碼保存在圖庫中", + "panel.covid19.qr_code.code.hint": "QR code 图像", + "panel.covid19.qr_code.description.on_boarding.heading.1": "保存此二维码,以便如果您丢失或更换手机,您可以在新手机上检索您的COVID-19健康信息.", + "panel.covid19.qr_code.description.on_boarding.heading.2": "您可以暂时跳过此步骤,以后在“设置”中执行", + "panel.covid19.qr_code.button.skip.title.": "跳过", + "panel.covid19.qr_code.button.skip.hint.": "跳过保存", + + "panel.covid19.transfer.title": "传输加密密钥", + "panel.covid19.transfer.label.qr_image_label": "安全伊利诺伊COVID-19码", + "panel.covid19.transfer.label.save_error": "无法保存二维码.", + "panel.covid19.transfer.button.continue.hint": "", + "panel.covid19.transfer.primary.heading.title": "你的COVID-19加密密钥", + "panel.covid19.transfer.primary.button.save.title": "保存加密密钥", + "panel.covid19.transfer.primary.button.save.hint": "", + "panel.covid19.transfer.secondary.heading.title": "缺少COVID-19加密密钥", + "panel.covid19.transfer.secondary.button.scan.heading": "如果你要添加第二台设备:", + "panel.covid19.transfer.secondary.button.scan.description": "如果您仍然可以访问您的主设备,您可以直接扫描该设备上的COVID-19加密密钥QR码.", + "panel.covid19.transfer.secondary.button.scan.title": "扫描你的二维码", + "panel.covid19.transfer.secondary.button.retrieve.heading": "如果您使用的是替换设备:", + "panel.covid19.transfer.secondary.button.retrieve.description": "如果您不再能够访问您的主要设备,但将您的二维码保存到云照片服务,您可以通过从照片中检索来传输您的COVID-19加密密钥.", + "panel.covid19.transfer.secondary.button.retrieve.title": "检索您的二维码", + "panel.covid19.transfer.alert.no_qr_code.msg": "这里没有二维码", + "panel.covid19.transfer.alert.save.success.msg": "二维码保存成功", + "panel.covid19.transfer.alert.save.success.pictures": "图片", + "panel.covid19.transfer.alert.save.success.gallery": "图片库", + "panel.covid19.transfer.alert.save.fail.msg": "二维码保存失败 ", + "panel.covid19.transfer.alert.qr_code.scan.failed.msg": "读取二维码失败.", + "panel.covid19.transfer.alert.qr_code.invalid.msg": "无效二维码.", + "panel.covid19.transfer.alert.qr_code.not_match.msg": "COVID-19密钥与现有公钥RSA密钥不匹配.", + "panel.covid19.transfer.alert.qr_code.transfer.succeeded.msg": "COVID-19机密传输成功.", + "panel.covid19.transfer.alert.qr_code.transfer.failed.msg": "无法传送COVID-19机密.", + + "panel.debug.header.title":"调试", + + "panel.debug_messaging.header.title":"消息", + + "panel.web.offline.message": "您需要在線才能執行此操作。 請檢查您的互聯網連接。", + + "panel.health.onboarding.covid19.intro.label.title": "加入對抗COVID-19的戰鬥", + "panel.health.onboarding.covid19.intro.label.description": "跟踪和管理您的健康狀況,以幫助確保我們的伊利諾伊州社區安全", + "panel.health.onboarding.covid19.intro.button.continue.title": "繼續", + "panel.health.onboarding.covid19.intro.button.continue.hint": "", + + "panel.health.onboarding.covid19.how_it_works.heading.title": "工作原理", + "panel.health.onboarding.covid19.how_it_works.line1.title": "测试和限制暴露是减缓COVID-19扩散的关键.", + "panel.health.onboarding.covid19.how_it_works.line2.title": "您可以使用此应用程序:", + "panel.health.onboarding.covid19.how_it_works.line3.title": "自我诊断你的COVID-19症状,并以此更新你的状态.", + "panel.health.onboarding.covid19.how_it_works.line4.title": "自动接收来自医疗保健提供商的测试结果.", + "panel.health.onboarding.covid19.how_it_works.line5.title": "当你与检测结果呈阳性的人接触时,允许你的手机发送接触警告.", + "panel.health.onboarding.covid19.how_it_works.button.next.title": "下一步", + "panel.health.onboarding.covid19.how_it_works.button.next.hint": "", + + "panel.health.onboarding.covid19.consent.label.title": "COVID-19功能的特别许可", + "panel.health.onboarding.covid19.consent.label.description": "接触通知", + "panel.health.onboarding.covid19.consent.label.content1": "如果您同意接触通知,則允許您的手機向附近的也在使用此功能的Safer Illinois應用程序用戶發送匿名藍牙信號。 您的電話也將接收並記錄來自其電話的信號。 如果其中一個用戶在接下來的14天內檢測出COVID-19呈陽性,則該應用程序將提醒您可能的暴露情況,並為您提供下一步建議。 您的身份和健康狀態以及所有其他用戶的身份和健康狀態將保持匿名。", + "panel.health.onboarding.covid19.consent.check_box.label.participate": "我同意参与接触通知系统(需要打开蓝牙).", + "panel.health.onboarding.covid19.consent.check_box.label.allow":"我同意让我的医疗保健提供者提供我的测试结果.", + "panel.health.onboarding.covid19.consent.label.content2": "自动测试结果", + "panel.health.onboarding.covid19.consent.label.content3": "我同意將我的醫療保健提供者的測試結果與Safer Illinois應用程序聯繫起來。", + "panel.health.onboarding.covid19.consent.label.content4": "您的参与是自愿的,您可以随时停止.", + "panel.health.onboarding.covid19.consent.button.consent.title": "下一個", + "panel.health.onboarding.covid19.consent.button.consent.hint": "", + "panel.health.onboarding.covid19.consent.button.scroll_to_continue.title": "滚动以继续", + "panel.health.onboarding.covid19.consent.label.error.login":"无法健康登录", + + "panel.health.onboarding.covid19.resident_info.label.title": "使用政府頒發的ID驗證您的身份", + "panel.health.onboarding.covid19.resident_info.label.description": "驗證後,您將收到根據您所在縣的準則,症狀以及任何與COVID-19相關的測試的帶有顏色編碼的健康狀況.", + "panel.health.onboarding.covid19.resident_info.button.passport.title": "護照", + "panel.health.onboarding.covid19.resident_info.button.passport.hint": "", + "panel.health.onboarding.covid19.resident_info.button.drivers_license.title": "駕駛執照", + "panel.health.onboarding.covid19.resident_info.button.drivers_license.hint": "", + "panel.health.onboarding.covid19.resident_info.button.verify_later.title": "稍後驗證", + + "panel.health.onboarding.covid19.review_scan.label.title": "查看您的掃描", + "panel.health.onboarding.covid19.review_scan.label.name.title": "名稱", + "panel.health.onboarding.covid19.review_scan.label.birth_year.title": "出生年", + "panel.health.onboarding.covid19.review_scan.message.failed": "無法應用掃描數據", + "panel.health.onboarding.covid19.review_scan.button.rescan.title": "重新掃描", + "panel.health.onboarding.covid19.review_scan.button.rescan.hint": "", + "panel.health.onboarding.covid19.review_scan.button.use_scan.title": "使用此掃描", + "panel.health.onboarding.covid19.review_scan.button.use_scan.hint": "", + + "panel.health.onboarding.covid19.county.label.title": "您在哪個縣工作和生活?", + "panel.health.onboarding.covid19.county.label.description": "選擇所有符合條件的", + "panel.health.onboarding.covid19.county.button.add_county.label": "添加另一個縣", + "panel.health.onboarding.covid19.county.button.add_county.hint": "", + "panel.health.onboarding.covid19.county.button.next.title": "下一個", + "panel.health.onboarding.covid19.county.button.next.hint": "", + "panel.health.onboarding.covid19.county.select.label": "選擇一個縣", + "panel.health.onboarding.covid19.county.dropdown.select.default.label": "選擇一個縣...", + "panel.health.onboarding.covid19.county.alert.unique.message": "請在每個字段中選擇不同的縣!", + + "panel.health.onboarding.covid19.providers.label.title": "您當前的醫療保健提供者是誰?", + "panel.health.onboarding.covid19.providers.label.description": "選擇所有符合條件的", + "panel.health.onboarding.covid19.providers.button.next.title": "下一個", + "panel.health.onboarding.covid19.providers.button.next.hint": "", + + "panel.health.onboarding.covid19.final.label.title": "所有东西都为你准备好了!", + "panel.health.onboarding.covid19.final.label.description": "您已通過驗證,並且狀態卡已添加到您的個人資料中.", + "panel.health.onboarding.covid19.final.label.bottom.description":"现在您可以将此应用程序用作与COVID-19战斗的伙伴.", + "panel.health.onboarding.covid19.final.label.unverified.description": "现在您可以将此应用程序用作与COVID-19战斗的伙伴", + "panel.health.onboarding.covid19.final.label.unverified.bottom.description":"要訪問您的COVID-19狀態, 您需要上傳政府ID. 您可以隨時在COVID-19設置中添加它.", + "panel.health.onboarding.covid19.final.button.continue.title": "開始使用", + "panel.health.onboarding.covid19.final.button.continue.hint": "", + + "panel.health.onboarding.covid19.login.netid.label.title":"连接您的 NetID", + "panel.health.onboarding.covid19.login.phone.label.title":"验证您的电话号码", + "panel.health.onboarding.covid19.login.netid.label.title.hint":"标题 1", + "panel.health.onboarding.covid19.login.phone.label.title.hint":"标题 1", + "panel.health.onboarding.covid19.login.netid.label.description":"使用您的NetID登录以使用学院和宿舍的特定功能.", + "panel.health.onboarding.covid19.phone.label.description":"这将保存您的首选项,以便您可以在多个设备上拥有相同的体验.", + "panel.health.onboarding.covid19.login.label.login_failed":"无法登录。请稍后再试", + "panel.health.onboarding.covid19.login.netid.button.continue.title":"使用NetID登录", + "panel.health.onboarding.covid19.login.phone.button.continue.title":"验证我的电话号码", + "panel.health.onboarding.covid19.login.netid.button.continue.hint":"", + "panel.health.onboarding.covid19.login.phone.button.continue.hint":"", + "panel.health.onboarding.covid19.login.netid.button.dont_continue.title":"现在不行", + "panel.health.onboarding.covid19.login.phone.button.dont_continue.title":"现在不行", + "panel.health.onboarding.covid19.login.netid.button.dont_continue.hint":"跳过验证", + "panel.health.onboarding.covid19.login.phone.button.dont_continue.hint":"跳过验证", + "panel.health.onboarding.covid19.login.label.error.login":"无法健康登录", + + "panel.health.covid19.about.heading.title":"关于", + + "panel.health.covid19.add_test.heading.title":"添加测试结果", + "panel.health.covid19.add_test.label.where_question":"测试是在哪里进行的?", + "panel.health.covid19.add_test.label.information": "为什么需要这些信息?", + "panel.health.covid19.add_test.label.provider.title":"医疗保健提供者", + "panel.health.covid19.add_test.label.provider.empty_hint":"选择提供者", + "panel.health.covid19.add_test.button.retreive.title":"检索结果", + "panel.health.covid19.add_test.button.enter_manually.title":"手工输入", + "panel.health.covid19.add_test.label.info.retrieved.text1": "结果", + "panel.health.covid19.add_test.label.info.retrieved.text2": "已检索", + "panel.health.covid19.add_test.label.info.retrieved.text3": "来自您的医疗保健提供者的信息将立即得到验证。您的健康状况的任何变化都会立即反映出来.", + "panel.health.covid19.add_test.label.info.manually.text1": "结果", + "panel.health.covid19.add_test.label.info.manually.text2": "手动输入", + "panel.health.covid19.add_test.label.info.manually.text3": "将由公共医疗机构进行审查和验证。一旦验证,状态可能会发生更改.", + "panel.health.covid19.add_test.label.manual_tests_disabled":"Test results from this health care provider will automatically appear if you have consented to Health Provider Test Results in settings and you are connected with your NetID.", + + "panel.health.covid19.care_team.heading.title": "您的护理团队", + "panel.health.covid19.care_team.label.question": "我们是来帮忙的.", + "panel.health.covid19.care_team.label.description": "联系你的COVID-19护理小组的人-我们会帮助你的.", + "panel.health.covid19.care_team.label.status": "当前状态:", + "panel.health.covid19.care_team.label.emergency.text1": "在紧急情况下,", + "panel.health.covid19.care_team.label.emergency.text2": "随时拨打911.", + "panel.health.covid19.care_team.team.title.mc_kinley": "呼 叫麦金利健康中心", + "panel.health.covid19.care_team.team.contact.mc_kinley": "1-217-333-2700", + "panel.health.covid19.care_team.team.semantic_contact.mc_kinley": "12173332700", + "panel.health.covid19.care_team.team.description.mc_kinley": "与“拨打护士热线”上的人联系,讨论您的症状和临床护理选择.", + "panel.health.covid19.care_team.team.title.osf": "OSF 医疗", + "panel.health.covid19.care_team.team.contact.osf": "1-833-673-5669", + "panel.health.covid19.care_team.team.semantic_contact.osf": "18336735669", + "panel.health.covid19.care_team.team.description.osf": "We’ve partnered with OSF OnCall Connect program and the Illinois Department of Healthcare and Family Services to support you getting through COVID-19. Call the Nurse Hotline at 1-833-OSF-KNOW (833-673-5669) to learn more about the program, which includes delivery of a care kit and digital visits to monitor you over a 16-day period.", + "panel.health.covid19.care_team.label.more_info.title": "有关大流行卫生工作者计划的更多信息", + "panel.health.covid19.care_team.label.more_info.description": "大流行卫生工作者(PHW)是经过培训的OSF医疗任务合作伙伴,在您从COVID-19恢复时,与您建立联系以提供支持,并在您和医疗保健提供者之间起到直接的联系,从而降低进一步暴露的风险。虽然PHW工作者不是注册护士,但如果您的病情恶化或您有具体的临床问题,临床护士和医生将随时待命 ", + "panel.health.covid19.care_team.label.more_info.hint": "双击可显示更多信息", + "panel.health.covid19.care_team.label.call.hint": "致电 ", + "panel.health.covid19.care_team.label.more_info.link": "Learn more", + + "panel.health.covid19.debug.keys.heading.title.":"COVID-19密钥", + "panel.health.covid19.debug.keys.label.public_key":"RSA公钥:", + "panel.health.covid19.debug.keys.label.private_key":"RSA私钥:", + "panel.health.covid19.debug.keys.label.aes_key":"AES 密钥:", + "panel.health.covid19.debug.keys.label.blob":"Blob:", + "panel.health.covid19.debug.keys.label.encripted_aes":"加密的aes密钥:", + "panel.health.covid19.debug.keys.label.encripted_blob":"加密的Blob:", + "panel.health.covid19.debug.keys.label.decripted_aes":"解密的AES密钥:", + "panel.health.covid19.debug.keys.label.decripted_blob":"解密的Blob:", + "panel.health.covid19.debug.keys.button.refres.title":"刷新RSA密钥", + "panel.health.covid19.debug.keys.button.generate_aes.title":"生成AES密钥", + "panel.health.covid19.debug.keys.button.encript.title":"加密", + "panel.health.covid19.debug.keys.button.decript.title":"解密", + "panel.health.covid19.debug.keys.label.error.refres.title":"刷新失败", + + "panel.health.covid19.debug.trace.heading.title":"COVID-19接触者追踪", + "panel.health.covid19.debug.trace.label.contact":"追踪COVID-19接触者", + "panel.health.covid19.debug.trace.label.date":"日期", + "panel.health.covid19.debug.trace.label.duration":"持续时间", + "panel.health.covid19.debug.trace.button.submit.title":"提交测试结果", + "panel.health.covid19.debug.trace.message.date.text":"请选择日期", + "panel.health.covid19.debug.trace.message.duration.text": "请输入整数持续时间", + "panel.health.covid19.debug.trace.error.submit.text": "提交接触者追踪数据失败.", + + "panel.health.covid19.history.header.title":"你的COVID-19事件记录", + "panel.health.covid19.history.label.empty.title":"没有历史记录", + "panel.health.covid19.history.label.description":"查看您的COVID-19事件历史记录.", + "panel.health.covid19.history.label.provider.hint":"提供者: ", + "panel.health.covid19.history.label.empty.provider":"未知", + "panel.health.covid19.history.label.self_reported.title":"自报症状", + "panel.health.covid19.history.label.self_reported.symptoms":"症状: ", + "panel.health.covid19.history.label.contact_trace.title":"接触者追踪", + "panel.health.covid19.history.label.contact_trace.details":"接触者追踪: ", + "panel.health.covid19.history.label.action.title":"需要採取的行動", + "panel.health.covid19.history.label.action.details":"行動:", + "panel.health.covid19.history.label.result.title":"结果: ", + "panel.health.covid19.history.label.location.title":"测试位置", + "panel.health.covid19.history.label.technician_name.title":"技术人员姓名", + "panel.health.covid19.history.label.technician_id.title":"技术人员 ID", + "panel.health.covid19.history.label.more_info.title":"更多信息", + "panel.health.covid19.history.label.provider.self_reported": "自我报告", + "panel.health.covid19.history.label.verified": "已验证", + "panel.health.covid19.history.label.verification_pending": "等待验证", + "panel.health.covid19.history.message.clear_failed": "无法清除COVID-19事件历史记录", + "panel.health.covid19.history.button.repost_history.title": "再次请求我的最新测试", + "panel.health.covid19.history.button.repost_history.hint": "", + "panel.health.covid19.history.message.request_tests": "您的请求已提.你应该在一小时内收到你的最新测验", + + "panel.health.covid19.qr_code.label.qr_image_label": "安全伊利诺伊COVID-19码", + "panel.health.covid19.qr_code.label.save_error": "无法保存二维码.", + "panel.health.covid19.qr_code.button.continue.hint": "", + "panel.health.covid19.qr_code.primary.heading.title": "你的COVID-19加密密钥", + "panel.health.covid19.qr_code.primary.description.1": "为了您的隐私,您用于COVID-19功能的医疗数据是加密的。加密密钥存储在您的手机本地,以确保其安全。\n\n要在其他设备上使用COVID-19功能,您需要使用下面的二维码手动传输此加密密钥.", + "panel.health.covid19.qr_code.primary.button.save.title": "保存加密密钥", + "panel.health.covid19.qr_code.primary.button.save.hint": "", + "panel.health.covid19.qr_code.primary.description.2": "如果您当前的设备丢失或损坏,我们建议您将此二维码的副本保存到云照片存储服务中,以便在您的替换设备上检索 \n\n您可以在任何时候通过 \"传输你的COVID-19加密密钥\" 从COVID-19信息中心中.", + "panel.health.covid19.qr_code.secondary.heading.title": "看起来你以前在其他设备上使用过此功能", + "panel.health.covid19.qr_code.secondary.description.1": "是否要将QR加密密钥传输到此设备以检索以前的健康信息?\n\n请在下面选择适用于您的选项。您可以在以后使用COVID-19信息中心或您的应用程序设置中的“传送您的COVID-19加密密钥”将QR加密密钥传输到此设备.", + "panel.health.covid19.qr_code.secondary.button.scan.heading": "如果要添加第二台设备:", + "panel.health.covid19.qr_code.secondary.button.scan.description": "如果您仍然可以访问您的主设备,您可以直接扫描该设备上的COVID-19加密密钥二维码.", + "panel.health.covid19.qr_code.secondary.button.scan.title": "扫描二维码", + "panel.health.covid19.qr_code.secondary.button.retrieve.heading": "如果您使用的是替换设备:", + "panel.health.covid19.qr_code.secondary.button.retrieve.description": "如果您不再能够访问您的主要设备,但将您的二维码保存到云照片服务,您可以通过从照片中检索来传输您的COVID-19加密密钥.", + "panel.health.covid19.qr_code.secondary.button.retrieve.title": "检索您的二维码", + "panel.health.covid19.qr_code.reset.button.heading": "重置我的COVID-19机密二维码:", + "panel.health.covid19.qr_code.reset.button.title": "重置我的COVID-19机密二维码", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.title": "重置我的COVID-19机密二维码", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.description": "这样做将为您提供一个新的COVID-19秘密二维码,但您以前的COVID-19事件历史记录将丢失,是否继续?", + "panel.health.covid19.qr_code.dialog.refresh_qr_code.confirm": "您确定吗?", + + "panel.health.covid19.alert.no_qr_code.msg": "没有二维码", + "panel.health.covid19.alert.save.success.msg": "二维码保存成功 ", + "panel.health.covid19.alert.save.success.pictures": "图片", + "panel.health.covid19.alert.save.success.gallery": "图片库", + "panel.health.covid19.alert.save.fail.msg": "未能在图片库中保存二维码", + "panel.health.covid19.alert.qr_code.scan.failed.msg": "读取二维码失败.", + "panel.health.covid19.alert.qr_code.invalid.msg": "无效的二维码.", + "panel.health.covid19.alert.qr_code.not_match.msg": "COVID-19密钥与现有公钥RSA密钥不匹配.", + "panel.health.covid19.qr_code.alert.qr_code.transfer.succeeded.msg": "COVID-19机密传输成功.", + "panel.health.covid19.alert.qr_code.transfer.failed.msg": "无法传送COVID-19机密.", + "panel.health.covid19.qr_code.button.continue.title": "继续", + "panel.health.covid19.qr_code.button.transfer_later.title": "稍后转移", + + "panel.health.report_test.heading.title":"手动输入结果", + "panel.health.report_test.label.date":"测试日期和时间", + "panel.health.report_test.label.provider":"医疗保健提供者", + "panel.health.report_test.label.date.location":"测试位置", + "panel.health.report_test.label.location.empty":"选择位置…", + "panel.health.report_test.label.type":"测试类型", + "panel.health.report_test.label.result":"结果", + "panel.health.report_test.label.result.empty":"选择测试结果…", + "panel.health.report_test.label.image":"添加测试结果", + "panel.health.report_test.label.image.hint":"上载测试结果的图像.", + "panel.health.report_test.button.add_image.title":"上传图片", + "panel.health.report_test.button.add_test.title":"添加测试", + "panel.health.report_test.button.close.title":"关闭", + "panel.health.report_test.button.retake.title":"重拍", + "panel.health.report_test.label.select_photo":"选择照片", + "panel.health.report_test.label.select_photo.description":"请为测试结果拍照或从库中选择", + "panel.health.report_test.button.take_photo":"拍照", + "panel.health.report_test.button.select_gallery":"从库中选择", + "panel.health.report_test.button.cancel":"取消", + "panel.health.report_test.error.create.message":"无法创建测试", + "panel.health.report_test.missing.image.message":"请上传图片", + "panel.health.report_test.future_date.forbidden.message": "今后不能提交测试", + + "widget.health.onboarding.indicator9.label.hint": "Covid-19入职流程", + + "widget.card.button.favorite.on.title":"添加到收藏夹", + "widget.card.button.favorite.on.hint":"", + "widget.card.button.favorite.off.title":"从收藏夹中删除", + "widget.card.button.favorite.off.hint":"", + "widget.card.label.interests":"由于您对以下内容感兴趣:", + "widget.card.label.converge":"比赛", + + "panel.health.status_update.heading.title":"状态更新", + "panel.health.status_update.label.status_change":"根据您的结果,您的状态已从 ", + "panel.health.status_update.label.status_change.to":" 到 ", + "panel.health.status_update.label.next_steps":"下一步", + "panel.health.status_update.label.asap":"尽快", + "panel.health.status_update.button.find_location.title":"查找位置", + "panel.health.status_update.label.loading":"我们正在更新您的状态,请稍等", + "panel.health.status_update.button.continue.title":"查看下一步", + "panel.health.status_update.button.continue.hint":"", + "panel.health.status_update.info_dialog.label1": "状态颜色定义可能根据不同的县而变化.", + "panel.health.status_update.info_dialog.label2": "状态颜色 ", + "panel.health.status_update.info_dialog.label3": "Default status for new users is set to Orange.", + "panel.health.status_update.info_dialog.label4": "An up-to-date on-campus negative test result will reset your COVID-19 status to Yellow, and Building Entry will change to Granted.", + "panel.health.status_update.label.reason.title":"状态已更改,因为:", + "panel.health.status_update.label.reason.result":"结果:", + "panel.health.status_update.label.reason.symptoms.title":"你报告了新的症状", + "panel.health.status_update.label.reason.exposed.title": "你接触过可能被感染的人", + "panel.health.status_update.label.reason.exposure.detail": "暴露时间: ", + "panel.health.status_update.label.reason.action.title": "卫生部门要求你采取行动", + "panel.health.status_update.label.reason.action.detail": "待办行动: ", + + "panel.health.next_steps.button.continue.title.find_locatio": "查找位置", + "panel.health.next_steps.button.continue.title.care_team": "联系护理团队", + "panel.health.next_steps.label.next_steps": "下一步行动", + "panel.health.next_steps.label.asap": "尽快", + + "panel.health.symptoms.heading.title":"你有这些症状吗?", + "panel.health.symptoms.label.error.loading":"未能加载症状.", + "panel.health.symptoms.button.submit.title":"提交", + "panel.health.symptoms.label.success.submit.message":"你的症状已经得到处理.", + "panel.health.symptoms.label.error.submit":"未能提交症状.", + + "widget.home_campus_tools.header.my_illini.title":"我的ILLINI", + "widget.home_campus_tools.label.campus_tools":"校园资源", + "widget.home_campus_tools.button.events.title":"活动", + "widget.home_campus_tools.button.events.hint":"", + "widget.home_campus_tools.button.dining.title":"用餐", + "widget.home_campus_tools.button.dining.hint":"", + "widget.home_campus_tools.button.athletics.title":"体育", + "widget.home_campus_tools.button.athletics.hint":"", + "widget.home_campus_tools.button.illini_cash.title":"Illini现金", + "widget.home_campus_tools.button.illini_cash.hint":"", + "widget.home_campus_tools.button.my_illini.title":"我的Illini", + "widget.home_campus_tools.button.my_illini.hint":"", + "widget.home_campus_tools.button.covid19.title":"COVID-19", + "widget.home_campus_tools.button.covid19.hint":"", + + "widget.home_covid19_info.label.covid19": "COVID-19信息", + "widget.home_covid19_info.label.info.title": "幫助保持伊利諾伊州的安全", + "widget.home_covid19_info.label.info.description": "通過跟踪和管理您的健康來加入對抗COVID-19的鬥爭", + + "widget.covid19_news_card.read_more.hint":"双击可阅读更多内容", + + "model.explore.time.today":"今天", + "model.explore.time.tomorrow":"明天", + "model.explore.time.at":"在", + "model.explore.time.all_day": "一整天", + + "model.user.role.student.title": "學生", + "model.user.role.employee.title": "僱員", + "model.user.role.resident.title": "居民", + + "model.covid19.status.color.green": "安全", + "model.covid19.status.color.yellow": "警告", + "model.covid19.status.color.orange": "可能感染", + "model.covid19.status.color.red": "已感染", + + "model.covid19.step.initial": "與您的醫療保健提供者一起進行COVID-19測試", + + "logic.date_time.greeting.morning": "早上好", + "logic.date_time.greeting.afternoon": "下午好", + "logic.date_time.greeting.evening": "晚上好", + + "logic.polls.unable_to_load_poll": "无法加载投票", + "logic.polls.no_polls_with_pin": "此4位数的投票代码 # 没有投票", + "logic.polls.multiple_polls_with_pin": "有多个公开的投票关联到这个四位数投票代码 #", + + "logic.general.internal_error": "发生内部错误", + "logic.general.invalid_response": "无效的服务器响应", + "logic.general.response_error": "响应错误: %s %s", + + "app.exit_dialog.message":"确定要退出吗?", + + "app.common.heading.hint":"标题", + "app.common.heading.one.hint":"标题1", + "app.common.heading.two.hint":"标题2", + "app.common.heading.three.hint":"标题3", + + "app.common.label.cancelled":"取消", + "app.common.label.other": "其他", + "app.common.label.county": "县", + "app.common.label.read_more": "阅读更多", + + "app.common.yes":"是", + "app.common.no":"否", + + "com.illinois.features2.entry.disable_location_awareness":"禁用地点感知", + "com.illinois.features2.entry.dont_store_location":"不要存储的我的地点", + "com.illinois.features2.entry.remove_preferences":"删除偏好设置", + "com.illinois.features2.entry.log_out":"登出", + "com.illinois.features2.entry.disable_notifications":"关闭提示", + "com.illinois.features2.entry.remove_credit_card":"删除信用卡信息", + "com.illinois.features2.entry.dont_share_location":"不和其他人分享我的数据", + + "com.illinois.covid19.status.long.orange": "Orange, Test Required", + "com.illinois.covid19.status.long.green": "綠色,最近抗體", + "com.illinois.covid19.status.long.yellow": "黃色,最近的陰性測試", + "com.illinois.covid19.status.long.red": "紅色,陽性測試", + "com.illinois.covid19.status.long.no change": "无变化", + "com.illinois.covid19.status.type.orange": "橙色", + "com.illinois.covid19.status.type.green": "绿色", + "com.illinois.covid19.status.type.yellow": "黄色", + "com.illinois.covid19.status.type.red": "红色", + "com.illinois.covid19.status.type.no change": "无变化", + "com.illinois.covid19.status.description.orange": "Test Required", + "com.illinois.covid19.status.description.green": "最近抗體", + "com.illinois.covid19.status.description.yellow": "最近的陰性測試", + "com.illinois.covid19.status.description.red": "正面測試", + "com.illinois.covid19.status.description.no change": "", + "com.illinois.covid19.status.info.description.orange": "Orange: First time user, Past due for test, Self-reported symptoms, Received exposure notification or Quarantined", + "com.illinois.covid19.status.info.description.green": "Green: Recent antibodies", + "com.illinois.covid19.status.info.description.yellow": "Yellow: Recent negative test", + "com.illinois.covid19.status.info.description.red": "Red: Positive test" +} diff --git a/assets/styles.json b/assets/styles.json new file mode 100644 index 00000000..bfd4217e --- /dev/null +++ b/assets/styles.json @@ -0,0 +1,77 @@ +{ + "color": { + "fillColorPrimary" :"#002855", + "fillColorPrimaryTransparent03" :"#4D002855", + "fillColorPrimaryTransparent05" :"#80002855", + "fillColorPrimaryTransparent09" :"#E6002855", + "fillColorPrimaryTransparent015" :"#26002855", + "fillColorPrimaryTransparent80" :"#CC002855", + "textColorPrimary" :"#FFFFFF", + "fillColorPrimaryVariant" :"#0F2040", + "fillColorSecondary" :"#E84A27", + "fillColorSecondaryTransparent05" :"#80E84A27", + "textColorSecondary" :"#FFFFFF", + "fillColorSecondaryVariant" :"#CF3C1B", + "textColorSecondaryVariant" :"#FFFFFF", + + "surface" :"#FFFFFF", + "surfaceAccent" :"#DADDE1", + "background" :"#F5F5F5", + "backgroundVariant" :"#E8E9EA", + "textSurface" :"#404040", + "textSurfaceAccent" :"#404040", + "textBackground" :"#404040", + "textbackgroundVariant" :"#404040", + + "accentColor1" :"#E84A27", + "accentColor2" :"#5FA7A3", + "accentColor3" :"#5182CF", + + "iconColor" :"#E84A27", + + "eventColor" :"#E54B30", + "diningColor" :"#F09842", + "placeColor" :"#62A7A3", + + "white" :"#FFFFFF", + "whiteTransparent01" :"#1AFFFFFF", + "whiteTransparent06" :"#99ffffff", + "blackTransparent06" :"#99000000", + "blackTransparent018" :"#30000000", + + "mediumGray" :"#717372", + "mediumGray1" :"#535353", + "mediumGray2" :"#979797", + "lightGray" :"#EDEDED", + "disabledTextColor" :"#BDBDBD", + "disabledTextColorTwo" :"#868F9D", + + "healthStatusGreen" :"#1ACD00", + "healthStatusYellow" :"#FFCF1C", + "healthStatusOrange" :"#F29835", + "healthStatusRed" :"#FF4F4F", + + "lightBlue" :"#42b9ea" + }, + "font_family": { + "black": "ProximaNovaBlack", + "black_italic": "ProximaNovaBlackIt", + "bold": "ProximaNovaBold", + "bold_italic": "ProximaNovaBoldIt", + "extra_bold": "ProximaNovaExtraBold", + "extra_bold_italic": "ProximaNovaExtraBoldIt", + "light": "ProximaNovaLight", + "light_italic": "ProximaNovaLightIt", + "medium": "ProximaNovaMedium", + "medium_italic": "ProximaNovaMediumIt", + "regular": "ProximaNovaRegular", + "regular_italic": "ProximaNovaRegularIt", + "semi_bold": "ProximaNovaSemiBold", + "semi_bold_italic": "ProximaNovaSemiBoldIt", + "thin": "ProximaNovaThin", + "thin_italic": "ProximaNovaThinIt" + }, + "text_style": { + "header_bar": { "font_family": "ProximaNovaExtraBold", "size": 16.0, "color": "textColorPrimary"} + } +} \ No newline at end of file diff --git a/assets/styles/styles-illinois.json b/assets/styles/styles-illinois.json new file mode 100644 index 00000000..9cbf49e0 --- /dev/null +++ b/assets/styles/styles-illinois.json @@ -0,0 +1,77 @@ +{ + "color": { + "fillColorPrimary" :"#002855", + "fillColorPrimaryTransparent03" :"#4D002855", + "fillColorPrimaryTransparent05" :"#80002855", + "fillColorPrimaryTransparent09" :"#E6002855", + "fillColorPrimaryTransparent015" :"#26002855", + "fillColorPrimaryTransparent80" :"#CC002855", + "textColorPrimary" :"#FFFFFF", + "fillColorPrimaryVariant" :"#0F2040", + "fillColorSecondary" :"#E84A27", + "fillColorSecondaryTransparent05" :"#80E84A27", + "textColorSecondary" :"#FFFFFF", + "fillColorSecondaryVariant" :"#CF3C1B", + "textColorSecondaryVariant" :"#FFFFFF", + + "surface" :"#FFFFFF", + "surfaceAccent" :"#DADDE1", + "background" :"#F5F5F5", + "backgroundVariant" :"#E8E9EA", + "textSurface" :"#404040", + "textSurfaceAccent" :"#404040", + "textBackground" :"#404040", + "textbackgroundVariant" :"#404040", + + "accentColor1" :"#E84A27", + "accentColor2" :"#5FA7A3", + "accentColor3" :"#5182CF", + + "iconColor" :"#E84A27", + + "eventColor" :"#E54B30", + "diningColor" :"#F09842", + "placeColor" :"#62A7A3", + + "white" :"#FFFFFF", + "whiteTransparent01" :"#1AFFFFFF", + "whiteTransparent06" :"#99ffffff", + "blackTransparent06" :"#99000000", + "blackTransparent018" :"#30000000", + + "mediumGray" :"#717372", + "mediumGray1" :"#535353", + "mediumGray2" :"#979797", + "lightGray" :"#EDEDED", + "disabledTextColor" :"#BDBDBD", + "disabledTextColorTwo" :"#868F9D", + + "healthStatusGreen" :"#1ACD00", + "healthStatusYellow" :"#FFCF1C", + "healthStatusOrange" :"#E84A27", + "healthStatusRed" :"#FF4F4F", + + "lightBlue" :"#42b9ea" + }, + "font_family": { + "black": "ProximaNovaBlack", + "black_italic": "ProximaNovaBlackIt", + "bold": "ProximaNovaBold", + "bold_italic": "ProximaNovaBoldIt", + "extra_bold": "ProximaNovaExtraBold", + "extra_bold_italic": "ProximaNovaExtraBoldIt", + "light": "ProximaNovaLight", + "light_italic": "ProximaNovaLightIt", + "medium": "ProximaNovaMedium", + "medium_italic": "ProximaNovaMediumIt", + "regular": "ProximaNovaRegular", + "regular_italic": "ProximaNovaRegularIt", + "semi_bold": "ProximaNovaSemiBold", + "semi_bold_italic": "ProximaNovaSemiBoldIt", + "thin": "ProximaNovaThin", + "thin_italic": "ProximaNovaThinIt" + }, + "text_style": { + "header_bar": { "font_family": "ProximaNovaExtraBold", "size": 16.0, "color": "textColorPrimary"} + } +} \ No newline at end of file diff --git a/assets/styles/styles-purdue.json b/assets/styles/styles-purdue.json new file mode 100644 index 00000000..08906edc --- /dev/null +++ b/assets/styles/styles-purdue.json @@ -0,0 +1,76 @@ +{ + "color": { + "fillColorPrimary" : "#191919", + "fillColorPrimaryTransparent03" : "#4D191919", + "fillColorPrimaryTransparent05" : "#80191919", + "fillColorPrimaryTransparent09" : "#E6191919", + "fillColorPrimaryTransparent015" : "#26191919", + "fillColorPrimaryTransparent80" : "#CC191919", + "textColorPrimary" : "#FFFFFF", + "fillColorPrimaryVariant" : "#000000", + "textColorPrimaryVariant" : "#FFFFFF", + "fillColorSecondary" : "#CFB991", + "fillColorSecondaryTransparent05" : "#80CFB991", + "textColorSecondary" : "#FFFFFF", + "fillColorSecondaryVariant" : "#76592C", + "textColorSecondaryVariant" : "#FFFFFF", + + "surface" : "#FFFFFF", + "surfaceAccent" : "#DADDE1", + "background" : "#F5F5F5", + "backgroundVariant" : "#E8E9EA", + "textSurface" : "#404040", + "textSurfaceAccent" : "#404040", + "textBackground" : "#404040", + "textbackgroundVariant" : "#404040", + + "accentColor1" : "#CFB991", + "accentColor2" : "#CFB991", + "accentColor3" : "#CFB991", + + "iconColor" : "#CFB991", + + "eventColor" : "#CFB991", + "diningColor" : "#CFB991", + "placeColor" : "#CFB991", + + "white" : "#FFFFFF", + "whiteTransparent01" : "#1AFFFFFF", + "whiteTransparent06" : "#99ffffff", + "blackTransparent06" : "#99000000", + "blackTransparent018" : "#30000000", + + "mediumGray" : "#717372", + "lightGray" : "#EDEDED", + "disabledTextColor" : "#BDBDBD", + "disabledTextColorTwo" : "#868F9D", + + "healthStatusGreen" :"#1ACD00", + "healthStatusYellow" :"#FFCF1C", + "healthStatusOrange" :"#E84A27", + "healthStatusRed" :"#FF4F4F", + + "lightBlue" :"#42b9ea" + }, + "font_family": { + "black": "ProximaNovaBlack", + "black_italic": "ProximaNovaBlackIt", + "bold": "ProximaNovaBold", + "bold_italic": "ProximaNovaBoldIt", + "extra_bold": "ProximaNovaExtraBold", + "extra_bold_italic": "ProximaNovaExtraBoldIt", + "light": "ProximaNovaLight", + "light_italic": "ProximaNovaLightIt", + "medium": "ProximaNovaMedium", + "medium_italic": "ProximaNovaMediumIt", + "regular": "ProximaNovaRegular", + "regular_italic": "ProximaNovaRegularIt", + "semi_bold": "ProximaNovaSemiBold", + "semi_bold_italic": "ProximaNovaSemiBoldIt", + "thin": "ProximaNovaThin", + "thin_italic": "ProximaNovaThinIt" + }, + "text_style": { + "header_bar": { "font_family": "ProximaNovaExtraBold", "size": 16.0, "color": "textColorPrimary"} + } +} diff --git a/assets/styles/styles-uic.json b/assets/styles/styles-uic.json new file mode 100644 index 00000000..94f2dca9 --- /dev/null +++ b/assets/styles/styles-uic.json @@ -0,0 +1,77 @@ +{ + "color": { + "fillColorPrimary" :"#001e62", + "fillColorPrimaryTransparent03" :"#4D001e62", + "fillColorPrimaryTransparent05" :"#80001e62", + "fillColorPrimaryTransparent09" :"#E6001e62", + "fillColorPrimaryTransparent015" :"#26001e62", + "fillColorPrimaryTransparent80" :"#CC001e62", + "textColorPrimary" :"#FFFFFF", + "fillColorPrimaryVariant" :"#D50032", + "fillColorSecondary" :"#D50032", + "fillColorSecondaryTransparent05" :"#80D50032", + "textColorSecondary" :"#FFFFFF", + "fillColorSecondaryVariant" :"#B00029", + "textColorSecondaryVariant" :"#FFFFFF", + + "surface" :"#FFFFFF", + "surfaceAccent" :"#DADDE1", + "background" :"#F5F5F5", + "backgroundVariant" :"#E8E9EA", + "textSurface" :"#404040", + "textSurfaceAccent" :"#404040", + "textBackground" :"#404040", + "textbackgroundVariant" :"#404040", + + "accentColor1" :"#D50032", + "accentColor2" :"#00AEC7", + "accentColor3" :"#BB16A3", + + "iconColor" :"#D50032", + + "eventColor" :"#D50032", + "diningColor" :"#FF7500", + "placeColor" :"#00AEC7", + + "white" :"#FFFFFF", + "whiteTransparent01" :"#1AFFFFFF", + "whiteTransparent06" :"#99ffffff", + "blackTransparent06" :"#99000000", + "blackTransparent018" :"#30000000", + + "mediumGray" :"#717372", + "mediumGray1" :"#535353", + "mediumGray2" :"#979797", + "lightGray" :"#EDEDED", + "disabledTextColor" :"#BDBDBD", + "disabledTextColorTwo" :"#868F9D", + + "healthStatusGreen" :"#1ACD00", + "healthStatusYellow" :"#FFCF1C", + "healthStatusOrange" :"#E84A27", + "healthStatusRed" :"#FF4F4F", + + "lightBlue" :"#42b9ea" + }, + "font_family": { + "black": "ProximaNovaBlack", + "black_italic": "ProximaNovaBlackIt", + "bold": "ProximaNovaBold", + "bold_italic": "ProximaNovaBoldIt", + "extra_bold": "ProximaNovaExtraBold", + "extra_bold_italic": "ProximaNovaExtraBoldIt", + "light": "ProximaNovaLight", + "light_italic": "ProximaNovaLightIt", + "medium": "ProximaNovaMedium", + "medium_italic": "ProximaNovaMediumIt", + "regular": "ProximaNovaRegular", + "regular_italic": "ProximaNovaRegularIt", + "semi_bold": "ProximaNovaSemiBold", + "semi_bold_italic": "ProximaNovaSemiBoldIt", + "thin": "ProximaNovaThin", + "thin_italic": "ProximaNovaThinIt" + }, + "text_style": { + "header_bar": { "font_family": "ProximaNovaExtraBold", "size": 16.0, "color": "textColorPrimary"} + } +} \ No newline at end of file diff --git a/assets/timezone2019a.tzf b/assets/timezone2019a.tzf new file mode 100644 index 00000000..3e73678e Binary files /dev/null and b/assets/timezone2019a.tzf differ diff --git a/fonts/proximanova-black.otf b/fonts/proximanova-black.otf new file mode 100755 index 00000000..0728bea1 Binary files /dev/null and b/fonts/proximanova-black.otf differ diff --git a/fonts/proximanova-blackit.otf b/fonts/proximanova-blackit.otf new file mode 100755 index 00000000..87fca717 Binary files /dev/null and b/fonts/proximanova-blackit.otf differ diff --git a/fonts/proximanova-bold.otf b/fonts/proximanova-bold.otf new file mode 100755 index 00000000..d16406eb Binary files /dev/null and b/fonts/proximanova-bold.otf differ diff --git a/fonts/proximanova-boldit.otf b/fonts/proximanova-boldit.otf new file mode 100755 index 00000000..ec72f555 Binary files /dev/null and b/fonts/proximanova-boldit.otf differ diff --git a/fonts/proximanova-extrabold.otf b/fonts/proximanova-extrabold.otf new file mode 100755 index 00000000..cc2b8c8e Binary files /dev/null and b/fonts/proximanova-extrabold.otf differ diff --git a/fonts/proximanova-extraboldit.otf b/fonts/proximanova-extraboldit.otf new file mode 100755 index 00000000..650c93fc Binary files /dev/null and b/fonts/proximanova-extraboldit.otf differ diff --git a/fonts/proximanova-light.otf b/fonts/proximanova-light.otf new file mode 100755 index 00000000..2cf99c4c Binary files /dev/null and b/fonts/proximanova-light.otf differ diff --git a/fonts/proximanova-lightit.otf b/fonts/proximanova-lightit.otf new file mode 100755 index 00000000..23f79ea9 Binary files /dev/null and b/fonts/proximanova-lightit.otf differ diff --git a/fonts/proximanova-medium.otf b/fonts/proximanova-medium.otf new file mode 100755 index 00000000..3f59ea93 Binary files /dev/null and b/fonts/proximanova-medium.otf differ diff --git a/fonts/proximanova-mediumit.otf b/fonts/proximanova-mediumit.otf new file mode 100755 index 00000000..4ff652d8 Binary files /dev/null and b/fonts/proximanova-mediumit.otf differ diff --git a/fonts/proximanova-regular.otf b/fonts/proximanova-regular.otf new file mode 100755 index 00000000..56bbf66e Binary files /dev/null and b/fonts/proximanova-regular.otf differ diff --git a/fonts/proximanova-regularit.otf b/fonts/proximanova-regularit.otf new file mode 100755 index 00000000..2edb54d1 Binary files /dev/null and b/fonts/proximanova-regularit.otf differ diff --git a/fonts/proximanova-semibold.otf b/fonts/proximanova-semibold.otf new file mode 100755 index 00000000..5436663e Binary files /dev/null and b/fonts/proximanova-semibold.otf differ diff --git a/fonts/proximanova-semiboldit.otf b/fonts/proximanova-semiboldit.otf new file mode 100755 index 00000000..81ef5236 Binary files /dev/null and b/fonts/proximanova-semiboldit.otf differ diff --git a/fonts/proximanova-thin.otf b/fonts/proximanova-thin.otf new file mode 100755 index 00000000..4ade8624 Binary files /dev/null and b/fonts/proximanova-thin.otf differ diff --git a/fonts/proximanova-thinit.otf b/fonts/proximanova-thinit.otf new file mode 100755 index 00000000..721cbbc5 Binary files /dev/null and b/fonts/proximanova-thinit.otf differ diff --git a/illini-client.iml b/illini-client.iml new file mode 100644 index 00000000..7d003501 --- /dev/null +++ b/illini-client.iml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/2.0x/add-to-apple-wallet.png b/images/2.0x/add-to-apple-wallet.png new file mode 100644 index 00000000..4507957c Binary files /dev/null and b/images/2.0x/add-to-apple-wallet.png differ diff --git a/images/2.0x/allow-notifications-header.png b/images/2.0x/allow-notifications-header.png new file mode 100644 index 00000000..c03f55b5 Binary files /dev/null and b/images/2.0x/allow-notifications-header.png differ diff --git a/images/2.0x/athletics-baseball-orange.png b/images/2.0x/athletics-baseball-orange.png new file mode 100644 index 00000000..5364edbc Binary files /dev/null and b/images/2.0x/athletics-baseball-orange.png differ diff --git a/images/2.0x/athletics-baseball-white.png b/images/2.0x/athletics-baseball-white.png new file mode 100644 index 00000000..855e6475 Binary files /dev/null and b/images/2.0x/athletics-baseball-white.png differ diff --git a/images/2.0x/athletics-basketball-orange.png b/images/2.0x/athletics-basketball-orange.png new file mode 100644 index 00000000..30d21ffe Binary files /dev/null and b/images/2.0x/athletics-basketball-orange.png differ diff --git a/images/2.0x/athletics-basketball-white.png b/images/2.0x/athletics-basketball-white.png new file mode 100644 index 00000000..9cf08d02 Binary files /dev/null and b/images/2.0x/athletics-basketball-white.png differ diff --git a/images/2.0x/athletics-cross-orange.png b/images/2.0x/athletics-cross-orange.png new file mode 100644 index 00000000..45bc6a97 Binary files /dev/null and b/images/2.0x/athletics-cross-orange.png differ diff --git a/images/2.0x/athletics-cross-white.png b/images/2.0x/athletics-cross-white.png new file mode 100644 index 00000000..a00d77dd Binary files /dev/null and b/images/2.0x/athletics-cross-white.png differ diff --git a/images/2.0x/athletics-football-orange.png b/images/2.0x/athletics-football-orange.png new file mode 100644 index 00000000..f88f021c Binary files /dev/null and b/images/2.0x/athletics-football-orange.png differ diff --git a/images/2.0x/athletics-football-white.png b/images/2.0x/athletics-football-white.png new file mode 100644 index 00000000..f943dee4 Binary files /dev/null and b/images/2.0x/athletics-football-white.png differ diff --git a/images/2.0x/athletics-golf-orange.png b/images/2.0x/athletics-golf-orange.png new file mode 100644 index 00000000..1c2d7abc Binary files /dev/null and b/images/2.0x/athletics-golf-orange.png differ diff --git a/images/2.0x/athletics-golf-white.png b/images/2.0x/athletics-golf-white.png new file mode 100644 index 00000000..3ff21303 Binary files /dev/null and b/images/2.0x/athletics-golf-white.png differ diff --git a/images/2.0x/athletics-gymnastics-orange.png b/images/2.0x/athletics-gymnastics-orange.png new file mode 100644 index 00000000..b17cfdef Binary files /dev/null and b/images/2.0x/athletics-gymnastics-orange.png differ diff --git a/images/2.0x/athletics-gymnastics-white.png b/images/2.0x/athletics-gymnastics-white.png new file mode 100644 index 00000000..56f1514c Binary files /dev/null and b/images/2.0x/athletics-gymnastics-white.png differ diff --git a/images/2.0x/athletics-handball-orange.png b/images/2.0x/athletics-handball-orange.png new file mode 100644 index 00000000..7647280b Binary files /dev/null and b/images/2.0x/athletics-handball-orange.png differ diff --git a/images/2.0x/athletics-handball-white.png b/images/2.0x/athletics-handball-white.png new file mode 100644 index 00000000..e040aec5 Binary files /dev/null and b/images/2.0x/athletics-handball-white.png differ diff --git a/images/2.0x/athletics-soccer-orange.png b/images/2.0x/athletics-soccer-orange.png new file mode 100644 index 00000000..6ab04519 Binary files /dev/null and b/images/2.0x/athletics-soccer-orange.png differ diff --git a/images/2.0x/athletics-soccer-white.png b/images/2.0x/athletics-soccer-white.png new file mode 100644 index 00000000..d392fb8f Binary files /dev/null and b/images/2.0x/athletics-soccer-white.png differ diff --git a/images/2.0x/athletics-softball-orange.png b/images/2.0x/athletics-softball-orange.png new file mode 100644 index 00000000..5364edbc Binary files /dev/null and b/images/2.0x/athletics-softball-orange.png differ diff --git a/images/2.0x/athletics-swim-orange.png b/images/2.0x/athletics-swim-orange.png new file mode 100644 index 00000000..8950ac6b Binary files /dev/null and b/images/2.0x/athletics-swim-orange.png differ diff --git a/images/2.0x/athletics-swim-white.png b/images/2.0x/athletics-swim-white.png new file mode 100644 index 00000000..7fa58d1e Binary files /dev/null and b/images/2.0x/athletics-swim-white.png differ diff --git a/images/2.0x/athletics-tennis-orange.png b/images/2.0x/athletics-tennis-orange.png new file mode 100644 index 00000000..bb50da7f Binary files /dev/null and b/images/2.0x/athletics-tennis-orange.png differ diff --git a/images/2.0x/athletics-tennis-white.png b/images/2.0x/athletics-tennis-white.png new file mode 100644 index 00000000..0cd373d4 Binary files /dev/null and b/images/2.0x/athletics-tennis-white.png differ diff --git a/images/2.0x/athletics-track-orange.png b/images/2.0x/athletics-track-orange.png new file mode 100644 index 00000000..f761b5ca Binary files /dev/null and b/images/2.0x/athletics-track-orange.png differ diff --git a/images/2.0x/athletics-track-white.png b/images/2.0x/athletics-track-white.png new file mode 100644 index 00000000..963d33c1 Binary files /dev/null and b/images/2.0x/athletics-track-white.png differ diff --git a/images/2.0x/athletics-volleyball-orange.png b/images/2.0x/athletics-volleyball-orange.png new file mode 100644 index 00000000..7647280b Binary files /dev/null and b/images/2.0x/athletics-volleyball-orange.png differ diff --git a/images/2.0x/athletics-wrestling-orange.png b/images/2.0x/athletics-wrestling-orange.png new file mode 100644 index 00000000..80cbfd42 Binary files /dev/null and b/images/2.0x/athletics-wrestling-orange.png differ diff --git a/images/2.0x/athletics-wrestling-white.png b/images/2.0x/athletics-wrestling-white.png new file mode 100644 index 00000000..a9f54515 Binary files /dev/null and b/images/2.0x/athletics-wrestling-white.png differ diff --git a/images/2.0x/background-image.png b/images/2.0x/background-image.png new file mode 100644 index 00000000..4cc419f7 Binary files /dev/null and b/images/2.0x/background-image.png differ diff --git a/images/2.0x/background-onboarding-squares-dark.png b/images/2.0x/background-onboarding-squares-dark.png new file mode 100644 index 00000000..30b5e9a1 Binary files /dev/null and b/images/2.0x/background-onboarding-squares-dark.png differ diff --git a/images/2.0x/background-onboarding-squares-light.png b/images/2.0x/background-onboarding-squares-light.png new file mode 100644 index 00000000..48761305 Binary files /dev/null and b/images/2.0x/background-onboarding-squares-light.png differ diff --git a/images/2.0x/background-onboarding-squares.png b/images/2.0x/background-onboarding-squares.png new file mode 100644 index 00000000..a160aea6 Binary files /dev/null and b/images/2.0x/background-onboarding-squares.png differ diff --git a/images/2.0x/button-plus-orange.png b/images/2.0x/button-plus-orange.png new file mode 100755 index 00000000..ab8717be Binary files /dev/null and b/images/2.0x/button-plus-orange.png differ diff --git a/images/2.0x/campus-tools-blue.png b/images/2.0x/campus-tools-blue.png new file mode 100644 index 00000000..904ae3ba Binary files /dev/null and b/images/2.0x/campus-tools-blue.png differ diff --git a/images/2.0x/campus-tools.png b/images/2.0x/campus-tools.png new file mode 100644 index 00000000..c5461d5d Binary files /dev/null and b/images/2.0x/campus-tools.png differ diff --git a/images/2.0x/certified-copy.png b/images/2.0x/certified-copy.png new file mode 100644 index 00000000..0a7bd989 Binary files /dev/null and b/images/2.0x/certified-copy.png differ diff --git a/images/2.0x/certified.png b/images/2.0x/certified.png new file mode 100644 index 00000000..0fde9b92 Binary files /dev/null and b/images/2.0x/certified.png differ diff --git a/images/2.0x/checkbox-selected.png b/images/2.0x/checkbox-selected.png new file mode 100644 index 00000000..4aaf4cf7 Binary files /dev/null and b/images/2.0x/checkbox-selected.png differ diff --git a/images/2.0x/checkbox-small.png b/images/2.0x/checkbox-small.png new file mode 100644 index 00000000..29b57cbd Binary files /dev/null and b/images/2.0x/checkbox-small.png differ diff --git a/images/2.0x/checkbox-unselected.png b/images/2.0x/checkbox-unselected.png new file mode 100644 index 00000000..84e4a257 Binary files /dev/null and b/images/2.0x/checkbox-unselected.png differ diff --git a/images/2.0x/chevron-blue-right.png b/images/2.0x/chevron-blue-right.png new file mode 100755 index 00000000..3030e730 Binary files /dev/null and b/images/2.0x/chevron-blue-right.png differ diff --git a/images/2.0x/chevron-down.png b/images/2.0x/chevron-down.png new file mode 100644 index 00000000..8013508f Binary files /dev/null and b/images/2.0x/chevron-down.png differ diff --git a/images/2.0x/chevron-left-blue.png b/images/2.0x/chevron-left-blue.png new file mode 100755 index 00000000..45120d51 Binary files /dev/null and b/images/2.0x/chevron-left-blue.png differ diff --git a/images/2.0x/chevron-left-white.png b/images/2.0x/chevron-left-white.png new file mode 100644 index 00000000..7e44fde2 Binary files /dev/null and b/images/2.0x/chevron-left-white.png differ diff --git a/images/2.0x/chevron-left.png b/images/2.0x/chevron-left.png new file mode 100644 index 00000000..b2943f8c Binary files /dev/null and b/images/2.0x/chevron-left.png differ diff --git a/images/2.0x/chevron-right.png b/images/2.0x/chevron-right.png new file mode 100755 index 00000000..78dbb6a1 Binary files /dev/null and b/images/2.0x/chevron-right.png differ diff --git a/images/2.0x/chevron-up.png b/images/2.0x/chevron-up.png new file mode 100644 index 00000000..b1b1d33c Binary files /dev/null and b/images/2.0x/chevron-up.png differ diff --git a/images/2.0x/chevron2-down.png b/images/2.0x/chevron2-down.png new file mode 100755 index 00000000..c8f49edd Binary files /dev/null and b/images/2.0x/chevron2-down.png differ diff --git a/images/2.0x/classic-meal-blue.png b/images/2.0x/classic-meal-blue.png new file mode 100644 index 00000000..354ecc2d Binary files /dev/null and b/images/2.0x/classic-meal-blue.png differ diff --git a/images/2.0x/classic-meal-orange.png b/images/2.0x/classic-meal-orange.png new file mode 100755 index 00000000..79be1370 Binary files /dev/null and b/images/2.0x/classic-meal-orange.png differ diff --git a/images/2.0x/close-blue.png b/images/2.0x/close-blue.png new file mode 100644 index 00000000..ee65956f Binary files /dev/null and b/images/2.0x/close-blue.png differ diff --git a/images/2.0x/close-gray.png b/images/2.0x/close-gray.png new file mode 100644 index 00000000..a91b8ebb Binary files /dev/null and b/images/2.0x/close-gray.png differ diff --git a/images/2.0x/close-menu.png b/images/2.0x/close-menu.png new file mode 100644 index 00000000..27593249 Binary files /dev/null and b/images/2.0x/close-menu.png differ diff --git a/images/2.0x/close-orange-large.png b/images/2.0x/close-orange-large.png new file mode 100644 index 00000000..d168859f Binary files /dev/null and b/images/2.0x/close-orange-large.png differ diff --git a/images/2.0x/close-orange.png b/images/2.0x/close-orange.png new file mode 100755 index 00000000..95dcccba Binary files /dev/null and b/images/2.0x/close-orange.png differ diff --git a/images/2.0x/close-white-large.png b/images/2.0x/close-white-large.png new file mode 100644 index 00000000..29ce3829 Binary files /dev/null and b/images/2.0x/close-white-large.png differ diff --git a/images/2.0x/close-white-shadow.png b/images/2.0x/close-white-shadow.png new file mode 100644 index 00000000..816df667 Binary files /dev/null and b/images/2.0x/close-white-shadow.png differ diff --git a/images/2.0x/close-white.png b/images/2.0x/close-white.png new file mode 100644 index 00000000..d829ad29 Binary files /dev/null and b/images/2.0x/close-white.png differ diff --git a/images/2.0x/covid.png b/images/2.0x/covid.png new file mode 100644 index 00000000..e3c04d2b Binary files /dev/null and b/images/2.0x/covid.png differ diff --git a/images/2.0x/covid19-header-blue.png b/images/2.0x/covid19-header-blue.png new file mode 100644 index 00000000..44ce5775 Binary files /dev/null and b/images/2.0x/covid19-header-blue.png differ diff --git a/images/2.0x/covid19-orange.png b/images/2.0x/covid19-orange.png new file mode 100644 index 00000000..a1a9d7b5 Binary files /dev/null and b/images/2.0x/covid19-orange.png differ diff --git a/images/2.0x/deselected-dark.png b/images/2.0x/deselected-dark.png new file mode 100755 index 00000000..b842b046 Binary files /dev/null and b/images/2.0x/deselected-dark.png differ diff --git a/images/2.0x/deselected.png b/images/2.0x/deselected.png new file mode 100755 index 00000000..63508353 Binary files /dev/null and b/images/2.0x/deselected.png differ diff --git a/images/2.0x/disabled.png b/images/2.0x/disabled.png new file mode 100755 index 00000000..6fd05d2a Binary files /dev/null and b/images/2.0x/disabled.png differ diff --git a/images/2.0x/emotional.png b/images/2.0x/emotional.png new file mode 100644 index 00000000..ebafdead Binary files /dev/null and b/images/2.0x/emotional.png differ diff --git a/images/2.0x/enable-bluetooth-header.png b/images/2.0x/enable-bluetooth-header.png new file mode 100644 index 00000000..c00597b6 Binary files /dev/null and b/images/2.0x/enable-bluetooth-header.png differ diff --git a/images/2.0x/environmental.png b/images/2.0x/environmental.png new file mode 100644 index 00000000..3f13f494 Binary files /dev/null and b/images/2.0x/environmental.png differ diff --git a/images/2.0x/example.png b/images/2.0x/example.png new file mode 100644 index 00000000..a54a8e2f Binary files /dev/null and b/images/2.0x/example.png differ diff --git a/images/2.0x/explore.png b/images/2.0x/explore.png new file mode 100644 index 00000000..f69df057 Binary files /dev/null and b/images/2.0x/explore.png differ diff --git a/images/2.0x/external-link.png b/images/2.0x/external-link.png new file mode 100644 index 00000000..22f79032 Binary files /dev/null and b/images/2.0x/external-link.png differ diff --git a/images/2.0x/fb-10x20.png b/images/2.0x/fb-10x20.png new file mode 100644 index 00000000..ea8a48d4 Binary files /dev/null and b/images/2.0x/fb-10x20.png differ diff --git a/images/2.0x/fb-12x24.png b/images/2.0x/fb-12x24.png new file mode 100644 index 00000000..cf3d97c3 Binary files /dev/null and b/images/2.0x/fb-12x24.png differ diff --git a/images/2.0x/fb-16x32.png b/images/2.0x/fb-16x32.png new file mode 100755 index 00000000..c8a059fc Binary files /dev/null and b/images/2.0x/fb-16x32.png differ diff --git a/images/2.0x/fill-1.png b/images/2.0x/fill-1.png new file mode 100644 index 00000000..26bb03ea Binary files /dev/null and b/images/2.0x/fill-1.png differ diff --git a/images/2.0x/financial-2.png b/images/2.0x/financial-2.png new file mode 100644 index 00000000..67d031d9 Binary files /dev/null and b/images/2.0x/financial-2.png differ diff --git a/images/2.0x/game_day_blue.png b/images/2.0x/game_day_blue.png new file mode 100644 index 00000000..405369b3 Binary files /dev/null and b/images/2.0x/game_day_blue.png differ diff --git a/images/2.0x/globe.png b/images/2.0x/globe.png new file mode 100644 index 00000000..f738fe4b Binary files /dev/null and b/images/2.0x/globe.png differ diff --git a/images/2.0x/group-10.png b/images/2.0x/group-10.png new file mode 100644 index 00000000..c89d48d1 Binary files /dev/null and b/images/2.0x/group-10.png differ diff --git a/images/2.0x/group-15.png b/images/2.0x/group-15.png new file mode 100644 index 00000000..f33933bd Binary files /dev/null and b/images/2.0x/group-15.png differ diff --git a/images/2.0x/group-16.png b/images/2.0x/group-16.png new file mode 100644 index 00000000..f75c4121 Binary files /dev/null and b/images/2.0x/group-16.png differ diff --git a/images/2.0x/group-18.png b/images/2.0x/group-18.png new file mode 100644 index 00000000..14b2fbd8 Binary files /dev/null and b/images/2.0x/group-18.png differ diff --git a/images/2.0x/group-2.png b/images/2.0x/group-2.png new file mode 100644 index 00000000..14fdafb1 Binary files /dev/null and b/images/2.0x/group-2.png differ diff --git a/images/2.0x/group-20.png b/images/2.0x/group-20.png new file mode 100644 index 00000000..13fd53aa Binary files /dev/null and b/images/2.0x/group-20.png differ diff --git a/images/2.0x/group-25.png b/images/2.0x/group-25.png new file mode 100644 index 00000000..1cc8dede Binary files /dev/null and b/images/2.0x/group-25.png differ diff --git a/images/2.0x/group-28.png b/images/2.0x/group-28.png new file mode 100644 index 00000000..95c018b6 Binary files /dev/null and b/images/2.0x/group-28.png differ diff --git a/images/2.0x/group-3.png b/images/2.0x/group-3.png new file mode 100644 index 00000000..f64cb356 Binary files /dev/null and b/images/2.0x/group-3.png differ diff --git a/images/2.0x/group-4.png b/images/2.0x/group-4.png new file mode 100644 index 00000000..cdcc66e6 Binary files /dev/null and b/images/2.0x/group-4.png differ diff --git a/images/2.0x/group-44.png b/images/2.0x/group-44.png new file mode 100644 index 00000000..84a5b5c8 Binary files /dev/null and b/images/2.0x/group-44.png differ diff --git a/images/2.0x/group-444.png b/images/2.0x/group-444.png new file mode 100644 index 00000000..1abd6bc3 Binary files /dev/null and b/images/2.0x/group-444.png differ diff --git a/images/2.0x/group-5-blue.png b/images/2.0x/group-5-blue.png new file mode 100644 index 00000000..3330e9d2 Binary files /dev/null and b/images/2.0x/group-5-blue.png differ diff --git a/images/2.0x/group-5-white.png b/images/2.0x/group-5-white.png new file mode 100644 index 00000000..a07e7626 Binary files /dev/null and b/images/2.0x/group-5-white.png differ diff --git a/images/2.0x/group-7.png b/images/2.0x/group-7.png new file mode 100644 index 00000000..18237f0f Binary files /dev/null and b/images/2.0x/group-7.png differ diff --git a/images/2.0x/group-8.png b/images/2.0x/group-8.png new file mode 100644 index 00000000..115f557d Binary files /dev/null and b/images/2.0x/group-8.png differ diff --git a/images/2.0x/group-9.png b/images/2.0x/group-9.png new file mode 100644 index 00000000..8b7f1111 Binary files /dev/null and b/images/2.0x/group-9.png differ diff --git a/images/2.0x/group-event-settings.png b/images/2.0x/group-event-settings.png new file mode 100644 index 00000000..e4c726ee Binary files /dev/null and b/images/2.0x/group-event-settings.png differ diff --git a/images/2.0x/group-settings-icon.png b/images/2.0x/group-settings-icon.png new file mode 100644 index 00000000..e4c726ee Binary files /dev/null and b/images/2.0x/group-settings-icon.png differ diff --git a/images/2.0x/group.png b/images/2.0x/group.png new file mode 100644 index 00000000..47201148 Binary files /dev/null and b/images/2.0x/group.png differ diff --git a/images/2.0x/happening.png b/images/2.0x/happening.png new file mode 100644 index 00000000..d07e462f Binary files /dev/null and b/images/2.0x/happening.png differ diff --git a/images/2.0x/icon-add-14x14.png b/images/2.0x/icon-add-14x14.png new file mode 100644 index 00000000..d784d25f Binary files /dev/null and b/images/2.0x/icon-add-14x14.png differ diff --git a/images/2.0x/icon-add-20x18.png b/images/2.0x/icon-add-20x18.png new file mode 100644 index 00000000..72e66e08 Binary files /dev/null and b/images/2.0x/icon-add-20x18.png differ diff --git a/images/2.0x/icon-all-set-header.png b/images/2.0x/icon-all-set-header.png new file mode 100644 index 00000000..efe6a0a9 Binary files /dev/null and b/images/2.0x/icon-all-set-header.png differ diff --git a/images/2.0x/icon-athletics-blue.png b/images/2.0x/icon-athletics-blue.png new file mode 100644 index 00000000..d47dcd56 Binary files /dev/null and b/images/2.0x/icon-athletics-blue.png differ diff --git a/images/2.0x/icon-athletics.png b/images/2.0x/icon-athletics.png new file mode 100644 index 00000000..cafd8b04 Binary files /dev/null and b/images/2.0x/icon-athletics.png differ diff --git a/images/2.0x/icon-avatar-placeholder.png b/images/2.0x/icon-avatar-placeholder.png new file mode 100644 index 00000000..adca818d Binary files /dev/null and b/images/2.0x/icon-avatar-placeholder.png differ diff --git a/images/2.0x/icon-badge.png b/images/2.0x/icon-badge.png new file mode 100644 index 00000000..5381608b Binary files /dev/null and b/images/2.0x/icon-badge.png differ diff --git a/images/2.0x/icon-big-onboarding-health.png b/images/2.0x/icon-big-onboarding-health.png new file mode 100644 index 00000000..4b37f2bd Binary files /dev/null and b/images/2.0x/icon-big-onboarding-health.png differ diff --git a/images/2.0x/icon-big-onboarding-privacy.png b/images/2.0x/icon-big-onboarding-privacy.png new file mode 100644 index 00000000..25a61559 Binary files /dev/null and b/images/2.0x/icon-big-onboarding-privacy.png differ diff --git a/images/2.0x/icon-bluetooth.png b/images/2.0x/icon-bluetooth.png new file mode 100644 index 00000000..6995ee54 Binary files /dev/null and b/images/2.0x/icon-bluetooth.png differ diff --git a/images/2.0x/icon-browse-athletics.png b/images/2.0x/icon-browse-athletics.png new file mode 100644 index 00000000..34749d3d Binary files /dev/null and b/images/2.0x/icon-browse-athletics.png differ diff --git a/images/2.0x/icon-browse-covid19.png b/images/2.0x/icon-browse-covid19.png new file mode 100644 index 00000000..96b62c27 Binary files /dev/null and b/images/2.0x/icon-browse-covid19.png differ diff --git a/images/2.0x/icon-browse-dinings.png b/images/2.0x/icon-browse-dinings.png new file mode 100644 index 00000000..fc6d6bb6 Binary files /dev/null and b/images/2.0x/icon-browse-dinings.png differ diff --git a/images/2.0x/icon-browse-events.png b/images/2.0x/icon-browse-events.png new file mode 100644 index 00000000..6c3bfa06 Binary files /dev/null and b/images/2.0x/icon-browse-events.png differ diff --git a/images/2.0x/icon-browse-quick-polls.png b/images/2.0x/icon-browse-quick-polls.png new file mode 100644 index 00000000..0f2a351d Binary files /dev/null and b/images/2.0x/icon-browse-quick-polls.png differ diff --git a/images/2.0x/icon-browse-saved.png b/images/2.0x/icon-browse-saved.png new file mode 100644 index 00000000..cfd55050 Binary files /dev/null and b/images/2.0x/icon-browse-saved.png differ diff --git a/images/2.0x/icon-browse-wellness.png b/images/2.0x/icon-browse-wellness.png new file mode 100644 index 00000000..980febd1 Binary files /dev/null and b/images/2.0x/icon-browse-wellness.png differ diff --git a/images/2.0x/icon-browse.png b/images/2.0x/icon-browse.png new file mode 100755 index 00000000..7310657f Binary files /dev/null and b/images/2.0x/icon-browse.png differ diff --git a/images/2.0x/icon-calendar.png b/images/2.0x/icon-calendar.png new file mode 100755 index 00000000..7807542b Binary files /dev/null and b/images/2.0x/icon-calendar.png differ diff --git a/images/2.0x/icon-campus-tools-athletics.png b/images/2.0x/icon-campus-tools-athletics.png new file mode 100755 index 00000000..f3ff0ea7 Binary files /dev/null and b/images/2.0x/icon-campus-tools-athletics.png differ diff --git a/images/2.0x/icon-campus-tools-dining.png b/images/2.0x/icon-campus-tools-dining.png new file mode 100755 index 00000000..0545de41 Binary files /dev/null and b/images/2.0x/icon-campus-tools-dining.png differ diff --git a/images/2.0x/icon-campus-tools-events.png b/images/2.0x/icon-campus-tools-events.png new file mode 100755 index 00000000..3f5602ea Binary files /dev/null and b/images/2.0x/icon-campus-tools-events.png differ diff --git a/images/2.0x/icon-campus-tools-illini-cash.png b/images/2.0x/icon-campus-tools-illini-cash.png new file mode 100755 index 00000000..b3ded404 Binary files /dev/null and b/images/2.0x/icon-campus-tools-illini-cash.png differ diff --git a/images/2.0x/icon-campus-tools-laundry.png b/images/2.0x/icon-campus-tools-laundry.png new file mode 100755 index 00000000..1d2b7877 Binary files /dev/null and b/images/2.0x/icon-campus-tools-laundry.png differ diff --git a/images/2.0x/icon-campus-tools.png b/images/2.0x/icon-campus-tools.png new file mode 100755 index 00000000..be783fa6 Binary files /dev/null and b/images/2.0x/icon-campus-tools.png differ diff --git a/images/2.0x/icon-campus-updates.png b/images/2.0x/icon-campus-updates.png new file mode 100644 index 00000000..56213ac6 Binary files /dev/null and b/images/2.0x/icon-campus-updates.png differ diff --git a/images/2.0x/icon-cellphone.png b/images/2.0x/icon-cellphone.png new file mode 100644 index 00000000..fdbdc723 Binary files /dev/null and b/images/2.0x/icon-cellphone.png differ diff --git a/images/2.0x/icon-certified.png b/images/2.0x/icon-certified.png new file mode 100755 index 00000000..0fde9b92 Binary files /dev/null and b/images/2.0x/icon-certified.png differ diff --git a/images/2.0x/icon-check-example.png b/images/2.0x/icon-check-example.png new file mode 100755 index 00000000..fe388fd1 Binary files /dev/null and b/images/2.0x/icon-check-example.png differ diff --git a/images/2.0x/icon-check-simple.png b/images/2.0x/icon-check-simple.png new file mode 100755 index 00000000..a2947581 Binary files /dev/null and b/images/2.0x/icon-check-simple.png differ diff --git a/images/2.0x/icon-check.png b/images/2.0x/icon-check.png new file mode 100644 index 00000000..1e40d475 Binary files /dev/null and b/images/2.0x/icon-check.png differ diff --git a/images/2.0x/icon-circle-close.png b/images/2.0x/icon-circle-close.png new file mode 100755 index 00000000..d829ad29 Binary files /dev/null and b/images/2.0x/icon-circle-close.png differ diff --git a/images/2.0x/icon-close-big.png b/images/2.0x/icon-close-big.png new file mode 100755 index 00000000..67c61b61 Binary files /dev/null and b/images/2.0x/icon-close-big.png differ diff --git a/images/2.0x/icon-comment-dots.png b/images/2.0x/icon-comment-dots.png new file mode 100644 index 00000000..1563b738 Binary files /dev/null and b/images/2.0x/icon-comment-dots.png differ diff --git a/images/2.0x/icon-copy.png b/images/2.0x/icon-copy.png new file mode 100644 index 00000000..8a746452 Binary files /dev/null and b/images/2.0x/icon-copy.png differ diff --git a/images/2.0x/icon-cost.png b/images/2.0x/icon-cost.png new file mode 100644 index 00000000..8dc7d168 Binary files /dev/null and b/images/2.0x/icon-cost.png differ diff --git a/images/2.0x/icon-country-guidelines.png b/images/2.0x/icon-country-guidelines.png new file mode 100644 index 00000000..0e01a527 Binary files /dev/null and b/images/2.0x/icon-country-guidelines.png differ diff --git a/images/2.0x/icon-create-event.png b/images/2.0x/icon-create-event.png new file mode 100755 index 00000000..635640e1 Binary files /dev/null and b/images/2.0x/icon-create-event.png differ diff --git a/images/2.0x/icon-credit.png b/images/2.0x/icon-credit.png new file mode 100755 index 00000000..87781bca Binary files /dev/null and b/images/2.0x/icon-credit.png differ diff --git a/images/2.0x/icon-deselected-checkbox.png b/images/2.0x/icon-deselected-checkbox.png new file mode 100644 index 00000000..38824250 Binary files /dev/null and b/images/2.0x/icon-deselected-checkbox.png differ diff --git a/images/2.0x/icon-dining-orange.png b/images/2.0x/icon-dining-orange.png new file mode 100644 index 00000000..bb440cf9 Binary files /dev/null and b/images/2.0x/icon-dining-orange.png differ diff --git a/images/2.0x/icon-dining-yellow.png b/images/2.0x/icon-dining-yellow.png new file mode 100755 index 00000000..1d9f1756 Binary files /dev/null and b/images/2.0x/icon-dining-yellow.png differ diff --git a/images/2.0x/icon-dining.png b/images/2.0x/icon-dining.png new file mode 100644 index 00000000..0308ffc0 Binary files /dev/null and b/images/2.0x/icon-dining.png differ diff --git a/images/2.0x/icon-down-orange.png b/images/2.0x/icon-down-orange.png new file mode 100755 index 00000000..1f8ab81c Binary files /dev/null and b/images/2.0x/icon-down-orange.png differ diff --git a/images/2.0x/icon-down.png b/images/2.0x/icon-down.png new file mode 100755 index 00000000..b6a8cfe7 Binary files /dev/null and b/images/2.0x/icon-down.png differ diff --git a/images/2.0x/icon-dryer-big.png b/images/2.0x/icon-dryer-big.png new file mode 100755 index 00000000..63053a08 Binary files /dev/null and b/images/2.0x/icon-dryer-big.png differ diff --git a/images/2.0x/icon-dryer-small.png b/images/2.0x/icon-dryer-small.png new file mode 100755 index 00000000..5b25b2b4 Binary files /dev/null and b/images/2.0x/icon-dryer-small.png differ diff --git a/images/2.0x/icon-edit.png b/images/2.0x/icon-edit.png new file mode 100644 index 00000000..866de36d Binary files /dev/null and b/images/2.0x/icon-edit.png differ diff --git a/images/2.0x/icon-event.png b/images/2.0x/icon-event.png new file mode 100644 index 00000000..02c0b45c Binary files /dev/null and b/images/2.0x/icon-event.png differ diff --git a/images/2.0x/icon-explore-campus-athletics.png b/images/2.0x/icon-explore-campus-athletics.png new file mode 100644 index 00000000..f3ff0ea7 Binary files /dev/null and b/images/2.0x/icon-explore-campus-athletics.png differ diff --git a/images/2.0x/icon-explore-campus-dining.png b/images/2.0x/icon-explore-campus-dining.png new file mode 100644 index 00000000..0545de41 Binary files /dev/null and b/images/2.0x/icon-explore-campus-dining.png differ diff --git a/images/2.0x/icon-explore-campus-events.png b/images/2.0x/icon-explore-campus-events.png new file mode 100644 index 00000000..3f5602ea Binary files /dev/null and b/images/2.0x/icon-explore-campus-events.png differ diff --git a/images/2.0x/icon-explore.png b/images/2.0x/icon-explore.png new file mode 100644 index 00000000..f69df057 Binary files /dev/null and b/images/2.0x/icon-explore.png differ diff --git a/images/2.0x/icon-face-mask.png b/images/2.0x/icon-face-mask.png new file mode 100644 index 00000000..b287ed71 Binary files /dev/null and b/images/2.0x/icon-face-mask.png differ diff --git a/images/2.0x/icon-feedback.png b/images/2.0x/icon-feedback.png new file mode 100755 index 00000000..6a76f0a5 Binary files /dev/null and b/images/2.0x/icon-feedback.png differ diff --git a/images/2.0x/icon-gear.png b/images/2.0x/icon-gear.png new file mode 100755 index 00000000..9b6c265e Binary files /dev/null and b/images/2.0x/icon-gear.png differ diff --git a/images/2.0x/icon-health.png b/images/2.0x/icon-health.png new file mode 100644 index 00000000..bc117fc2 Binary files /dev/null and b/images/2.0x/icon-health.png differ diff --git a/images/2.0x/icon-hospital.png b/images/2.0x/icon-hospital.png new file mode 100644 index 00000000..525ad708 Binary files /dev/null and b/images/2.0x/icon-hospital.png differ diff --git a/images/2.0x/icon-identity.png b/images/2.0x/icon-identity.png new file mode 100644 index 00000000..04f452fd Binary files /dev/null and b/images/2.0x/icon-identity.png differ diff --git a/images/2.0x/icon-illini-cash.png b/images/2.0x/icon-illini-cash.png new file mode 100755 index 00000000..684930f2 Binary files /dev/null and b/images/2.0x/icon-illini-cash.png differ diff --git a/images/2.0x/icon-info-orange.png b/images/2.0x/icon-info-orange.png new file mode 100644 index 00000000..b3e5f9dc Binary files /dev/null and b/images/2.0x/icon-info-orange.png differ diff --git a/images/2.0x/icon-key.png b/images/2.0x/icon-key.png new file mode 100644 index 00000000..75090dee Binary files /dev/null and b/images/2.0x/icon-key.png differ diff --git a/images/2.0x/icon-list-view.png b/images/2.0x/icon-list-view.png new file mode 100644 index 00000000..818d1eac Binary files /dev/null and b/images/2.0x/icon-list-view.png differ diff --git a/images/2.0x/icon-listen.png b/images/2.0x/icon-listen.png new file mode 100755 index 00000000..b4ad9a86 Binary files /dev/null and b/images/2.0x/icon-listen.png differ diff --git a/images/2.0x/icon-live-stats.png b/images/2.0x/icon-live-stats.png new file mode 100755 index 00000000..66ed2072 Binary files /dev/null and b/images/2.0x/icon-live-stats.png differ diff --git a/images/2.0x/icon-location-1.png b/images/2.0x/icon-location-1.png new file mode 100644 index 00000000..9dda0e17 Binary files /dev/null and b/images/2.0x/icon-location-1.png differ diff --git a/images/2.0x/icon-location.png b/images/2.0x/icon-location.png new file mode 100644 index 00000000..014b092d Binary files /dev/null and b/images/2.0x/icon-location.png differ diff --git a/images/2.0x/icon-map-view.png b/images/2.0x/icon-map-view.png new file mode 100644 index 00000000..eb402cc7 Binary files /dev/null and b/images/2.0x/icon-map-view.png differ diff --git a/images/2.0x/icon-member.png b/images/2.0x/icon-member.png new file mode 100644 index 00000000..a4c4258b Binary files /dev/null and b/images/2.0x/icon-member.png differ diff --git a/images/2.0x/icon-more-info.png b/images/2.0x/icon-more-info.png new file mode 100755 index 00000000..b4398d3e Binary files /dev/null and b/images/2.0x/icon-more-info.png differ diff --git a/images/2.0x/icon-my-illini.png b/images/2.0x/icon-my-illini.png new file mode 100755 index 00000000..ef5a5c6c Binary files /dev/null and b/images/2.0x/icon-my-illini.png differ diff --git a/images/2.0x/icon-near-you.png b/images/2.0x/icon-near-you.png new file mode 100755 index 00000000..c9c932ac Binary files /dev/null and b/images/2.0x/icon-near-you.png differ diff --git a/images/2.0x/icon-news.png b/images/2.0x/icon-news.png new file mode 100755 index 00000000..1c3896bf Binary files /dev/null and b/images/2.0x/icon-news.png differ diff --git a/images/2.0x/icon-notifications-blue.png b/images/2.0x/icon-notifications-blue.png new file mode 100644 index 00000000..67a8a9ac Binary files /dev/null and b/images/2.0x/icon-notifications-blue.png differ diff --git a/images/2.0x/icon-orange-i.png b/images/2.0x/icon-orange-i.png new file mode 100644 index 00000000..eefb99e7 Binary files /dev/null and b/images/2.0x/icon-orange-i.png differ diff --git a/images/2.0x/icon-parking.png b/images/2.0x/icon-parking.png new file mode 100755 index 00000000..05b14b13 Binary files /dev/null and b/images/2.0x/icon-parking.png differ diff --git a/images/2.0x/icon-passport.png b/images/2.0x/icon-passport.png new file mode 100644 index 00000000..512e184e Binary files /dev/null and b/images/2.0x/icon-passport.png differ diff --git a/images/2.0x/icon-payment-type-apple-pay.png b/images/2.0x/icon-payment-type-apple-pay.png new file mode 100644 index 00000000..fc9ddbc5 Binary files /dev/null and b/images/2.0x/icon-payment-type-apple-pay.png differ diff --git a/images/2.0x/icon-payment-type-cache.png b/images/2.0x/icon-payment-type-cache.png new file mode 100644 index 00000000..06e1198c Binary files /dev/null and b/images/2.0x/icon-payment-type-cache.png differ diff --git a/images/2.0x/icon-payment-type-cafe-credit.png b/images/2.0x/icon-payment-type-cafe-credit.png new file mode 100644 index 00000000..106e2c1e Binary files /dev/null and b/images/2.0x/icon-payment-type-cafe-credit.png differ diff --git a/images/2.0x/icon-payment-type-classic-meal.png b/images/2.0x/icon-payment-type-classic-meal.png new file mode 100644 index 00000000..2d4bd63e Binary files /dev/null and b/images/2.0x/icon-payment-type-classic-meal.png differ diff --git a/images/2.0x/icon-payment-type-credit-card.png b/images/2.0x/icon-payment-type-credit-card.png new file mode 100644 index 00000000..c2616b7d Binary files /dev/null and b/images/2.0x/icon-payment-type-credit-card.png differ diff --git a/images/2.0x/icon-payment-type-google-pay.png b/images/2.0x/icon-payment-type-google-pay.png new file mode 100644 index 00000000..c761f096 Binary files /dev/null and b/images/2.0x/icon-payment-type-google-pay.png differ diff --git a/images/2.0x/icon-payment-type-ilini-cash.png b/images/2.0x/icon-payment-type-ilini-cash.png new file mode 100644 index 00000000..39745b24 Binary files /dev/null and b/images/2.0x/icon-payment-type-ilini-cash.png differ diff --git a/images/2.0x/icon-persona-alumni-normal.png b/images/2.0x/icon-persona-alumni-normal.png new file mode 100644 index 00000000..0e128218 Binary files /dev/null and b/images/2.0x/icon-persona-alumni-normal.png differ diff --git a/images/2.0x/icon-persona-alumni-selected.png b/images/2.0x/icon-persona-alumni-selected.png new file mode 100644 index 00000000..65493fa3 Binary files /dev/null and b/images/2.0x/icon-persona-alumni-selected.png differ diff --git a/images/2.0x/icon-persona-athletics-normal.png b/images/2.0x/icon-persona-athletics-normal.png new file mode 100644 index 00000000..931bf12b Binary files /dev/null and b/images/2.0x/icon-persona-athletics-normal.png differ diff --git a/images/2.0x/icon-persona-athletics-selected.png b/images/2.0x/icon-persona-athletics-selected.png new file mode 100644 index 00000000..bf8efd29 Binary files /dev/null and b/images/2.0x/icon-persona-athletics-selected.png differ diff --git a/images/2.0x/icon-persona-employee-normal.png b/images/2.0x/icon-persona-employee-normal.png new file mode 100644 index 00000000..178a3d7b Binary files /dev/null and b/images/2.0x/icon-persona-employee-normal.png differ diff --git a/images/2.0x/icon-persona-employee-selected.png b/images/2.0x/icon-persona-employee-selected.png new file mode 100644 index 00000000..995a60dd Binary files /dev/null and b/images/2.0x/icon-persona-employee-selected.png differ diff --git a/images/2.0x/icon-persona-parent-normal.png b/images/2.0x/icon-persona-parent-normal.png new file mode 100644 index 00000000..d9277df1 Binary files /dev/null and b/images/2.0x/icon-persona-parent-normal.png differ diff --git a/images/2.0x/icon-persona-parent-selected.png b/images/2.0x/icon-persona-parent-selected.png new file mode 100644 index 00000000..715c63b7 Binary files /dev/null and b/images/2.0x/icon-persona-parent-selected.png differ diff --git a/images/2.0x/icon-persona-resident-normal.png b/images/2.0x/icon-persona-resident-normal.png new file mode 100644 index 00000000..aeb541c6 Binary files /dev/null and b/images/2.0x/icon-persona-resident-normal.png differ diff --git a/images/2.0x/icon-persona-resident-selected.png b/images/2.0x/icon-persona-resident-selected.png new file mode 100644 index 00000000..8ae614d7 Binary files /dev/null and b/images/2.0x/icon-persona-resident-selected.png differ diff --git a/images/2.0x/icon-persona-student-normal.png b/images/2.0x/icon-persona-student-normal.png new file mode 100644 index 00000000..45e02d8a Binary files /dev/null and b/images/2.0x/icon-persona-student-normal.png differ diff --git a/images/2.0x/icon-persona-student-selected.png b/images/2.0x/icon-persona-student-selected.png new file mode 100644 index 00000000..ceac1243 Binary files /dev/null and b/images/2.0x/icon-persona-student-selected.png differ diff --git a/images/2.0x/icon-persona-visitor-normal.png b/images/2.0x/icon-persona-visitor-normal.png new file mode 100644 index 00000000..53c0e73f Binary files /dev/null and b/images/2.0x/icon-persona-visitor-normal.png differ diff --git a/images/2.0x/icon-persona-visitor-selected.png b/images/2.0x/icon-persona-visitor-selected.png new file mode 100644 index 00000000..bac78779 Binary files /dev/null and b/images/2.0x/icon-persona-visitor-selected.png differ diff --git a/images/2.0x/icon-phone.png b/images/2.0x/icon-phone.png new file mode 100644 index 00000000..4f555f6c Binary files /dev/null and b/images/2.0x/icon-phone.png differ diff --git a/images/2.0x/icon-placeholder-blue.png b/images/2.0x/icon-placeholder-blue.png new file mode 100755 index 00000000..c245b44d Binary files /dev/null and b/images/2.0x/icon-placeholder-blue.png differ diff --git a/images/2.0x/icon-placeholder-empty.png b/images/2.0x/icon-placeholder-empty.png new file mode 100755 index 00000000..225dc5da Binary files /dev/null and b/images/2.0x/icon-placeholder-empty.png differ diff --git a/images/2.0x/icon-placeholder-navy.png b/images/2.0x/icon-placeholder-navy.png new file mode 100755 index 00000000..90abd486 Binary files /dev/null and b/images/2.0x/icon-placeholder-navy.png differ diff --git a/images/2.0x/icon-placeholder-orange.png b/images/2.0x/icon-placeholder-orange.png new file mode 100755 index 00000000..1e8eb435 Binary files /dev/null and b/images/2.0x/icon-placeholder-orange.png differ diff --git a/images/2.0x/icon-placeholder-teal.png b/images/2.0x/icon-placeholder-teal.png new file mode 100755 index 00000000..4056bd8d Binary files /dev/null and b/images/2.0x/icon-placeholder-teal.png differ diff --git a/images/2.0x/icon-placeholder-yellow.png b/images/2.0x/icon-placeholder-yellow.png new file mode 100755 index 00000000..fd27e331 Binary files /dev/null and b/images/2.0x/icon-placeholder-yellow.png differ diff --git a/images/2.0x/icon-plus.png b/images/2.0x/icon-plus.png new file mode 100755 index 00000000..cd86d9ac Binary files /dev/null and b/images/2.0x/icon-plus.png differ diff --git a/images/2.0x/icon-poi.png b/images/2.0x/icon-poi.png new file mode 100644 index 00000000..19adc0a9 Binary files /dev/null and b/images/2.0x/icon-poi.png differ diff --git a/images/2.0x/icon-privacy.png b/images/2.0x/icon-privacy.png new file mode 100755 index 00000000..5043a8d6 Binary files /dev/null and b/images/2.0x/icon-privacy.png differ diff --git a/images/2.0x/icon-quickpoll.png b/images/2.0x/icon-quickpoll.png new file mode 100755 index 00000000..66ed2072 Binary files /dev/null and b/images/2.0x/icon-quickpoll.png differ diff --git a/images/2.0x/icon-recurring-event.png b/images/2.0x/icon-recurring-event.png new file mode 100755 index 00000000..354e0168 Binary files /dev/null and b/images/2.0x/icon-recurring-event.png differ diff --git a/images/2.0x/icon-reminder.png b/images/2.0x/icon-reminder.png new file mode 100755 index 00000000..32fa2e85 Binary files /dev/null and b/images/2.0x/icon-reminder.png differ diff --git a/images/2.0x/icon-report-test.png b/images/2.0x/icon-report-test.png new file mode 100644 index 00000000..c4e84374 Binary files /dev/null and b/images/2.0x/icon-report-test.png differ diff --git a/images/2.0x/icon-saved-white.png b/images/2.0x/icon-saved-white.png new file mode 100755 index 00000000..d723d511 Binary files /dev/null and b/images/2.0x/icon-saved-white.png differ diff --git a/images/2.0x/icon-saved.png b/images/2.0x/icon-saved.png new file mode 100755 index 00000000..92482d52 Binary files /dev/null and b/images/2.0x/icon-saved.png differ diff --git a/images/2.0x/icon-schedule.png b/images/2.0x/icon-schedule.png new file mode 100755 index 00000000..304e05bf Binary files /dev/null and b/images/2.0x/icon-schedule.png differ diff --git a/images/2.0x/icon-search.png b/images/2.0x/icon-search.png new file mode 100755 index 00000000..994cb22d Binary files /dev/null and b/images/2.0x/icon-search.png differ diff --git a/images/2.0x/icon-selected-checkbox.png b/images/2.0x/icon-selected-checkbox.png new file mode 100644 index 00000000..659ea918 Binary files /dev/null and b/images/2.0x/icon-selected-checkbox.png differ diff --git a/images/2.0x/icon-selected.png b/images/2.0x/icon-selected.png new file mode 100755 index 00000000..9d2d6f17 Binary files /dev/null and b/images/2.0x/icon-selected.png differ diff --git a/images/2.0x/icon-separate-people.png b/images/2.0x/icon-separate-people.png new file mode 100644 index 00000000..8afd92dc Binary files /dev/null and b/images/2.0x/icon-separate-people.png differ diff --git a/images/2.0x/icon-settings.png b/images/2.0x/icon-settings.png new file mode 100755 index 00000000..23780341 Binary files /dev/null and b/images/2.0x/icon-settings.png differ diff --git a/images/2.0x/icon-social-distance.png b/images/2.0x/icon-social-distance.png new file mode 100644 index 00000000..a939a002 Binary files /dev/null and b/images/2.0x/icon-social-distance.png differ diff --git a/images/2.0x/icon-star-selected.png b/images/2.0x/icon-star-selected.png new file mode 100755 index 00000000..3739ff68 Binary files /dev/null and b/images/2.0x/icon-star-selected.png differ diff --git a/images/2.0x/icon-star-solid.png b/images/2.0x/icon-star-solid.png new file mode 100755 index 00000000..9155c106 Binary files /dev/null and b/images/2.0x/icon-star-solid.png differ diff --git a/images/2.0x/icon-star-white.png b/images/2.0x/icon-star-white.png new file mode 100644 index 00000000..273d0e06 Binary files /dev/null and b/images/2.0x/icon-star-white.png differ diff --git a/images/2.0x/icon-star.png b/images/2.0x/icon-star.png new file mode 100644 index 00000000..50dcbd03 Binary files /dev/null and b/images/2.0x/icon-star.png differ diff --git a/images/2.0x/icon-stay-at-home.png b/images/2.0x/icon-stay-at-home.png new file mode 100644 index 00000000..472d2d1e Binary files /dev/null and b/images/2.0x/icon-stay-at-home.png differ diff --git a/images/2.0x/icon-stehoscope.png b/images/2.0x/icon-stehoscope.png new file mode 100644 index 00000000..2b17299d Binary files /dev/null and b/images/2.0x/icon-stehoscope.png differ diff --git a/images/2.0x/icon-team.png b/images/2.0x/icon-team.png new file mode 100755 index 00000000..e3bbfd42 Binary files /dev/null and b/images/2.0x/icon-team.png differ diff --git a/images/2.0x/icon-test-history.png b/images/2.0x/icon-test-history.png new file mode 100644 index 00000000..e1fa82d1 Binary files /dev/null and b/images/2.0x/icon-test-history.png differ diff --git a/images/2.0x/icon-time.png b/images/2.0x/icon-time.png new file mode 100644 index 00000000..dc8f99d1 Binary files /dev/null and b/images/2.0x/icon-time.png differ diff --git a/images/2.0x/icon-unselected.png b/images/2.0x/icon-unselected.png new file mode 100755 index 00000000..8ebaddbb Binary files /dev/null and b/images/2.0x/icon-unselected.png differ diff --git a/images/2.0x/icon-up.png b/images/2.0x/icon-up.png new file mode 100755 index 00000000..5575e0dc Binary files /dev/null and b/images/2.0x/icon-up.png differ diff --git a/images/2.0x/icon-washer-big.png b/images/2.0x/icon-washer-big.png new file mode 100755 index 00000000..eadc15e5 Binary files /dev/null and b/images/2.0x/icon-washer-big.png differ diff --git a/images/2.0x/icon-washer-small.png b/images/2.0x/icon-washer-small.png new file mode 100755 index 00000000..ccbd26a3 Binary files /dev/null and b/images/2.0x/icon-washer-small.png differ diff --git a/images/2.0x/icon-washer.png b/images/2.0x/icon-washer.png new file mode 100755 index 00000000..bfa09413 Binary files /dev/null and b/images/2.0x/icon-washer.png differ diff --git a/images/2.0x/icon-watch.png b/images/2.0x/icon-watch.png new file mode 100755 index 00000000..e097d7ff Binary files /dev/null and b/images/2.0x/icon-watch.png differ diff --git a/images/2.0x/icon-x-orange-small.png b/images/2.0x/icon-x-orange-small.png new file mode 100755 index 00000000..68b3b7cd Binary files /dev/null and b/images/2.0x/icon-x-orange-small.png differ diff --git a/images/2.0x/icon-x-orange.png b/images/2.0x/icon-x-orange.png new file mode 100755 index 00000000..70fdf9ad Binary files /dev/null and b/images/2.0x/icon-x-orange.png differ diff --git a/images/2.0x/icon-your-care-team.png b/images/2.0x/icon-your-care-team.png new file mode 100644 index 00000000..78fa2acc Binary files /dev/null and b/images/2.0x/icon-your-care-team.png differ diff --git a/images/2.0x/ig-20x20.png b/images/2.0x/ig-20x20.png new file mode 100644 index 00000000..893bc134 Binary files /dev/null and b/images/2.0x/ig-20x20.png differ diff --git a/images/2.0x/ig-24x24.png b/images/2.0x/ig-24x24.png new file mode 100644 index 00000000..40391234 Binary files /dev/null and b/images/2.0x/ig-24x24.png differ diff --git a/images/2.0x/ig-32x32.png b/images/2.0x/ig-32x32.png new file mode 100755 index 00000000..c2a6ed80 Binary files /dev/null and b/images/2.0x/ig-32x32.png differ diff --git a/images/2.0x/ilini-cash.png b/images/2.0x/ilini-cash.png new file mode 100644 index 00000000..3a1f1dce Binary files /dev/null and b/images/2.0x/ilini-cash.png differ diff --git a/images/2.0x/kognito.png b/images/2.0x/kognito.png new file mode 100644 index 00000000..59184199 Binary files /dev/null and b/images/2.0x/kognito.png differ diff --git a/images/2.0x/link-out.png b/images/2.0x/link-out.png new file mode 100644 index 00000000..063d6470 Binary files /dev/null and b/images/2.0x/link-out.png differ diff --git a/images/2.0x/login-header.png b/images/2.0x/login-header.png new file mode 100644 index 00000000..3efb72f9 Binary files /dev/null and b/images/2.0x/login-header.png differ diff --git a/images/2.0x/mc-kinley-gray.png b/images/2.0x/mc-kinley-gray.png new file mode 100644 index 00000000..798ccc01 Binary files /dev/null and b/images/2.0x/mc-kinley-gray.png differ diff --git a/images/2.0x/member.png b/images/2.0x/member.png new file mode 100644 index 00000000..e5b74584 Binary files /dev/null and b/images/2.0x/member.png differ diff --git a/images/2.0x/mental.png b/images/2.0x/mental.png new file mode 100644 index 00000000..db2faebf Binary files /dev/null and b/images/2.0x/mental.png differ diff --git a/images/2.0x/mtd-logo.png b/images/2.0x/mtd-logo.png new file mode 100644 index 00000000..ebee22fd Binary files /dev/null and b/images/2.0x/mtd-logo.png differ diff --git a/images/2.0x/my-illini-orange.png b/images/2.0x/my-illini-orange.png new file mode 100644 index 00000000..a41dbc94 Binary files /dev/null and b/images/2.0x/my-illini-orange.png differ diff --git a/images/2.0x/navy.png b/images/2.0x/navy.png new file mode 100755 index 00000000..613fb5ee Binary files /dev/null and b/images/2.0x/navy.png differ diff --git a/images/2.0x/near-you.png b/images/2.0x/near-you.png new file mode 100644 index 00000000..42e56eec Binary files /dev/null and b/images/2.0x/near-you.png differ diff --git a/images/2.0x/onboarding-back-btn.png b/images/2.0x/onboarding-back-btn.png new file mode 100644 index 00000000..e44e3e7d Binary files /dev/null and b/images/2.0x/onboarding-back-btn.png differ diff --git a/images/2.0x/osf-logo-gray.png b/images/2.0x/osf-logo-gray.png new file mode 100644 index 00000000..e93a5018 Binary files /dev/null and b/images/2.0x/osf-logo-gray.png differ diff --git a/images/2.0x/path.png b/images/2.0x/path.png new file mode 100644 index 00000000..64312c28 Binary files /dev/null and b/images/2.0x/path.png differ diff --git a/images/2.0x/pending.png b/images/2.0x/pending.png new file mode 100644 index 00000000..485b79f0 Binary files /dev/null and b/images/2.0x/pending.png differ diff --git a/images/2.0x/physical.png b/images/2.0x/physical.png new file mode 100644 index 00000000..74736b24 Binary files /dev/null and b/images/2.0x/physical.png differ diff --git a/images/2.0x/pledge.png b/images/2.0x/pledge.png new file mode 100644 index 00000000..9e402744 Binary files /dev/null and b/images/2.0x/pledge.png differ diff --git a/images/2.0x/powered-by.png b/images/2.0x/powered-by.png new file mode 100644 index 00000000..4a04cf56 Binary files /dev/null and b/images/2.0x/powered-by.png differ diff --git a/images/2.0x/privacy-header.png b/images/2.0x/privacy-header.png new file mode 100644 index 00000000..5be65a28 Binary files /dev/null and b/images/2.0x/privacy-header.png differ diff --git a/images/2.0x/privacy.png b/images/2.0x/privacy.png new file mode 100644 index 00000000..0d0cecd5 Binary files /dev/null and b/images/2.0x/privacy.png differ diff --git a/images/2.0x/provider.png b/images/2.0x/provider.png new file mode 100644 index 00000000..14af573d Binary files /dev/null and b/images/2.0x/provider.png differ diff --git a/images/2.0x/reflection.png b/images/2.0x/reflection.png new file mode 100644 index 00000000..8e7e795a Binary files /dev/null and b/images/2.0x/reflection.png differ diff --git a/images/2.0x/reminder.png b/images/2.0x/reminder.png new file mode 100755 index 00000000..32fa2e85 Binary files /dev/null and b/images/2.0x/reminder.png differ diff --git a/images/2.0x/safer-illinois.png b/images/2.0x/safer-illinois.png new file mode 100644 index 00000000..0eb949a4 Binary files /dev/null and b/images/2.0x/safer-illinois.png differ diff --git a/images/2.0x/schedule-orange.png b/images/2.0x/schedule-orange.png new file mode 100755 index 00000000..31e7b2af Binary files /dev/null and b/images/2.0x/schedule-orange.png differ diff --git a/images/2.0x/selected-orange.png b/images/2.0x/selected-orange.png new file mode 100644 index 00000000..9d2d6f17 Binary files /dev/null and b/images/2.0x/selected-orange.png differ diff --git a/images/2.0x/selected.png b/images/2.0x/selected.png new file mode 100755 index 00000000..918b271a Binary files /dev/null and b/images/2.0x/selected.png differ diff --git a/images/2.0x/settings-white.png b/images/2.0x/settings-white.png new file mode 100644 index 00000000..cd5943d0 Binary files /dev/null and b/images/2.0x/settings-white.png differ diff --git a/images/2.0x/share-location-header.png b/images/2.0x/share-location-header.png new file mode 100644 index 00000000..2f999e10 Binary files /dev/null and b/images/2.0x/share-location-header.png differ diff --git a/images/2.0x/slant-down-right-blue-rotated.png b/images/2.0x/slant-down-right-blue-rotated.png new file mode 100644 index 00000000..e58d22a0 Binary files /dev/null and b/images/2.0x/slant-down-right-blue-rotated.png differ diff --git a/images/2.0x/slant-down-right-blue.png b/images/2.0x/slant-down-right-blue.png new file mode 100644 index 00000000..0a6c05e0 Binary files /dev/null and b/images/2.0x/slant-down-right-blue.png differ diff --git a/images/2.0x/small-add-orange.png b/images/2.0x/small-add-orange.png new file mode 100644 index 00000000..5ea9afbb Binary files /dev/null and b/images/2.0x/small-add-orange.png differ diff --git a/images/2.0x/small-add.png b/images/2.0x/small-add.png new file mode 100644 index 00000000..dbaa5bb0 Binary files /dev/null and b/images/2.0x/small-add.png differ diff --git a/images/2.0x/social.png b/images/2.0x/social.png new file mode 100644 index 00000000..0ede3c64 Binary files /dev/null and b/images/2.0x/social.png differ diff --git a/images/2.0x/spiritual.png b/images/2.0x/spiritual.png new file mode 100644 index 00000000..e338aeba Binary files /dev/null and b/images/2.0x/spiritual.png differ diff --git a/images/2.0x/switch-off.png b/images/2.0x/switch-off.png new file mode 100644 index 00000000..5cf96574 Binary files /dev/null and b/images/2.0x/switch-off.png differ diff --git a/images/2.0x/switch-on.png b/images/2.0x/switch-on.png new file mode 100644 index 00000000..151b7394 Binary files /dev/null and b/images/2.0x/switch-on.png differ diff --git a/images/2.0x/tab-browse-selected.png b/images/2.0x/tab-browse-selected.png new file mode 100644 index 00000000..b7964394 Binary files /dev/null and b/images/2.0x/tab-browse-selected.png differ diff --git a/images/2.0x/tab-browse.png b/images/2.0x/tab-browse.png new file mode 100644 index 00000000..e6e95d45 Binary files /dev/null and b/images/2.0x/tab-browse.png differ diff --git a/images/2.0x/tab-explore.png b/images/2.0x/tab-explore.png new file mode 100644 index 00000000..6cb7f355 Binary files /dev/null and b/images/2.0x/tab-explore.png differ diff --git a/images/2.0x/tab-home-selected.png b/images/2.0x/tab-home-selected.png new file mode 100755 index 00000000..244b2b08 Binary files /dev/null and b/images/2.0x/tab-home-selected.png differ diff --git a/images/2.0x/tab-home.png b/images/2.0x/tab-home.png new file mode 100755 index 00000000..ef752447 Binary files /dev/null and b/images/2.0x/tab-home.png differ diff --git a/images/2.0x/tab-more-selected.png b/images/2.0x/tab-more-selected.png new file mode 100755 index 00000000..b108f32a Binary files /dev/null and b/images/2.0x/tab-more-selected.png differ diff --git a/images/2.0x/tab-more.png b/images/2.0x/tab-more.png new file mode 100755 index 00000000..8b9a85d9 Binary files /dev/null and b/images/2.0x/tab-more.png differ diff --git a/images/2.0x/tab-saved-selected.png b/images/2.0x/tab-saved-selected.png new file mode 100755 index 00000000..5577b25a Binary files /dev/null and b/images/2.0x/tab-saved-selected.png differ diff --git a/images/2.0x/tab-saved.png b/images/2.0x/tab-saved.png new file mode 100755 index 00000000..92482d52 Binary files /dev/null and b/images/2.0x/tab-saved.png differ diff --git a/images/2.0x/tab-wallet.png b/images/2.0x/tab-wallet.png new file mode 100644 index 00000000..4fad57f8 Binary files /dev/null and b/images/2.0x/tab-wallet.png differ diff --git a/images/2.0x/teal.png b/images/2.0x/teal.png new file mode 100644 index 00000000..b656e567 Binary files /dev/null and b/images/2.0x/teal.png differ diff --git a/images/2.0x/tickets_yellow.png b/images/2.0x/tickets_yellow.png new file mode 100644 index 00000000..52306ffb Binary files /dev/null and b/images/2.0x/tickets_yellow.png differ diff --git a/images/2.0x/twitter-20x18.png b/images/2.0x/twitter-20x18.png new file mode 100644 index 00000000..618b4b73 Binary files /dev/null and b/images/2.0x/twitter-20x18.png differ diff --git a/images/2.0x/twitter-24x22.png b/images/2.0x/twitter-24x22.png new file mode 100644 index 00000000..abd78cb8 Binary files /dev/null and b/images/2.0x/twitter-24x22.png differ diff --git a/images/2.0x/twitter-32x28.png b/images/2.0x/twitter-32x28.png new file mode 100755 index 00000000..85ed8968 Binary files /dev/null and b/images/2.0x/twitter-32x28.png differ diff --git a/images/2.0x/u.png b/images/2.0x/u.png new file mode 100644 index 00000000..7f45bafa Binary files /dev/null and b/images/2.0x/u.png differ diff --git a/images/2.0x/upcoming_events_orange.png b/images/2.0x/upcoming_events_orange.png new file mode 100644 index 00000000..3f5602ea Binary files /dev/null and b/images/2.0x/upcoming_events_orange.png differ diff --git a/images/2.0x/user-check.png b/images/2.0x/user-check.png new file mode 100644 index 00000000..b0a8bc7c Binary files /dev/null and b/images/2.0x/user-check.png differ diff --git a/images/2.0x/vocational.png b/images/2.0x/vocational.png new file mode 100644 index 00000000..f02009ac Binary files /dev/null and b/images/2.0x/vocational.png differ diff --git a/images/2.0x/warning-orange.png b/images/2.0x/warning-orange.png new file mode 100644 index 00000000..e3aee5fa Binary files /dev/null and b/images/2.0x/warning-orange.png differ diff --git a/images/2.0x/you-tube-20x15.png b/images/2.0x/you-tube-20x15.png new file mode 100644 index 00000000..8c52aedb Binary files /dev/null and b/images/2.0x/you-tube-20x15.png differ diff --git a/images/2.0x/you-tube-32x24.png b/images/2.0x/you-tube-32x24.png new file mode 100755 index 00000000..f687a8d6 Binary files /dev/null and b/images/2.0x/you-tube-32x24.png differ diff --git a/images/3.0x/add-to-apple-wallet.png b/images/3.0x/add-to-apple-wallet.png new file mode 100644 index 00000000..080908e9 Binary files /dev/null and b/images/3.0x/add-to-apple-wallet.png differ diff --git a/images/3.0x/athletics-baseball-orange.png b/images/3.0x/athletics-baseball-orange.png new file mode 100644 index 00000000..fcb4f3bd Binary files /dev/null and b/images/3.0x/athletics-baseball-orange.png differ diff --git a/images/3.0x/athletics-baseball-white.png b/images/3.0x/athletics-baseball-white.png new file mode 100644 index 00000000..b4bff3cf Binary files /dev/null and b/images/3.0x/athletics-baseball-white.png differ diff --git a/images/3.0x/athletics-basketball-orange.png b/images/3.0x/athletics-basketball-orange.png new file mode 100644 index 00000000..a02bae95 Binary files /dev/null and b/images/3.0x/athletics-basketball-orange.png differ diff --git a/images/3.0x/athletics-basketball-white.png b/images/3.0x/athletics-basketball-white.png new file mode 100644 index 00000000..21b6238b Binary files /dev/null and b/images/3.0x/athletics-basketball-white.png differ diff --git a/images/3.0x/athletics-cross-orange.png b/images/3.0x/athletics-cross-orange.png new file mode 100644 index 00000000..4f4b4001 Binary files /dev/null and b/images/3.0x/athletics-cross-orange.png differ diff --git a/images/3.0x/athletics-cross-white.png b/images/3.0x/athletics-cross-white.png new file mode 100644 index 00000000..04e68054 Binary files /dev/null and b/images/3.0x/athletics-cross-white.png differ diff --git a/images/3.0x/athletics-football-orange.png b/images/3.0x/athletics-football-orange.png new file mode 100644 index 00000000..754805ff Binary files /dev/null and b/images/3.0x/athletics-football-orange.png differ diff --git a/images/3.0x/athletics-football-white.png b/images/3.0x/athletics-football-white.png new file mode 100644 index 00000000..448ee2ca Binary files /dev/null and b/images/3.0x/athletics-football-white.png differ diff --git a/images/3.0x/athletics-golf-orange.png b/images/3.0x/athletics-golf-orange.png new file mode 100644 index 00000000..77abc697 Binary files /dev/null and b/images/3.0x/athletics-golf-orange.png differ diff --git a/images/3.0x/athletics-golf-white.png b/images/3.0x/athletics-golf-white.png new file mode 100644 index 00000000..b6b9b6dc Binary files /dev/null and b/images/3.0x/athletics-golf-white.png differ diff --git a/images/3.0x/athletics-gymnastics-orange.png b/images/3.0x/athletics-gymnastics-orange.png new file mode 100644 index 00000000..444cb9e1 Binary files /dev/null and b/images/3.0x/athletics-gymnastics-orange.png differ diff --git a/images/3.0x/athletics-gymnastics-white.png b/images/3.0x/athletics-gymnastics-white.png new file mode 100644 index 00000000..18a86f44 Binary files /dev/null and b/images/3.0x/athletics-gymnastics-white.png differ diff --git a/images/3.0x/athletics-handball-orange.png b/images/3.0x/athletics-handball-orange.png new file mode 100644 index 00000000..ab07f7b8 Binary files /dev/null and b/images/3.0x/athletics-handball-orange.png differ diff --git a/images/3.0x/athletics-handball-white.png b/images/3.0x/athletics-handball-white.png new file mode 100644 index 00000000..0eeeb362 Binary files /dev/null and b/images/3.0x/athletics-handball-white.png differ diff --git a/images/3.0x/athletics-soccer-orange.png b/images/3.0x/athletics-soccer-orange.png new file mode 100644 index 00000000..a69aa5e3 Binary files /dev/null and b/images/3.0x/athletics-soccer-orange.png differ diff --git a/images/3.0x/athletics-soccer-white.png b/images/3.0x/athletics-soccer-white.png new file mode 100644 index 00000000..af24dfdb Binary files /dev/null and b/images/3.0x/athletics-soccer-white.png differ diff --git a/images/3.0x/athletics-softball-orange.png b/images/3.0x/athletics-softball-orange.png new file mode 100644 index 00000000..fcb4f3bd Binary files /dev/null and b/images/3.0x/athletics-softball-orange.png differ diff --git a/images/3.0x/athletics-swim-orange.png b/images/3.0x/athletics-swim-orange.png new file mode 100644 index 00000000..ba505077 Binary files /dev/null and b/images/3.0x/athletics-swim-orange.png differ diff --git a/images/3.0x/athletics-swim-white.png b/images/3.0x/athletics-swim-white.png new file mode 100644 index 00000000..47ef4093 Binary files /dev/null and b/images/3.0x/athletics-swim-white.png differ diff --git a/images/3.0x/athletics-tennis-orange.png b/images/3.0x/athletics-tennis-orange.png new file mode 100644 index 00000000..6ad665be Binary files /dev/null and b/images/3.0x/athletics-tennis-orange.png differ diff --git a/images/3.0x/athletics-tennis-white.png b/images/3.0x/athletics-tennis-white.png new file mode 100644 index 00000000..2ef4f821 Binary files /dev/null and b/images/3.0x/athletics-tennis-white.png differ diff --git a/images/3.0x/athletics-track-orange.png b/images/3.0x/athletics-track-orange.png new file mode 100644 index 00000000..6e3a7843 Binary files /dev/null and b/images/3.0x/athletics-track-orange.png differ diff --git a/images/3.0x/athletics-track-white.png b/images/3.0x/athletics-track-white.png new file mode 100644 index 00000000..8a331851 Binary files /dev/null and b/images/3.0x/athletics-track-white.png differ diff --git a/images/3.0x/athletics-volleyball-orange.png b/images/3.0x/athletics-volleyball-orange.png new file mode 100644 index 00000000..ab07f7b8 Binary files /dev/null and b/images/3.0x/athletics-volleyball-orange.png differ diff --git a/images/3.0x/athletics-wrestling-orange.png b/images/3.0x/athletics-wrestling-orange.png new file mode 100644 index 00000000..6f81a98f Binary files /dev/null and b/images/3.0x/athletics-wrestling-orange.png differ diff --git a/images/3.0x/athletics-wrestling-white.png b/images/3.0x/athletics-wrestling-white.png new file mode 100644 index 00000000..ddbcd357 Binary files /dev/null and b/images/3.0x/athletics-wrestling-white.png differ diff --git a/images/3.0x/background-image.png b/images/3.0x/background-image.png new file mode 100644 index 00000000..02ad9701 Binary files /dev/null and b/images/3.0x/background-image.png differ diff --git a/images/3.0x/background-onboarding-squares-dark.png b/images/3.0x/background-onboarding-squares-dark.png new file mode 100644 index 00000000..55026f0f Binary files /dev/null and b/images/3.0x/background-onboarding-squares-dark.png differ diff --git a/images/3.0x/background-onboarding-squares-light.png b/images/3.0x/background-onboarding-squares-light.png new file mode 100644 index 00000000..60a97dab Binary files /dev/null and b/images/3.0x/background-onboarding-squares-light.png differ diff --git a/images/3.0x/background-onboarding-squares.png b/images/3.0x/background-onboarding-squares.png new file mode 100644 index 00000000..7e795cba Binary files /dev/null and b/images/3.0x/background-onboarding-squares.png differ diff --git a/images/3.0x/button-plus-orange.png b/images/3.0x/button-plus-orange.png new file mode 100755 index 00000000..f352095a Binary files /dev/null and b/images/3.0x/button-plus-orange.png differ diff --git a/images/3.0x/campus-tools-blue.png b/images/3.0x/campus-tools-blue.png new file mode 100644 index 00000000..ce202e1c Binary files /dev/null and b/images/3.0x/campus-tools-blue.png differ diff --git a/images/3.0x/campus-tools-blue.textClipping b/images/3.0x/campus-tools-blue.textClipping new file mode 100644 index 00000000..67aa32dd Binary files /dev/null and b/images/3.0x/campus-tools-blue.textClipping differ diff --git a/images/3.0x/campus-tools.png b/images/3.0x/campus-tools.png new file mode 100644 index 00000000..6ff8619b Binary files /dev/null and b/images/3.0x/campus-tools.png differ diff --git a/images/3.0x/certified-copy.png b/images/3.0x/certified-copy.png new file mode 100644 index 00000000..5658a3a2 Binary files /dev/null and b/images/3.0x/certified-copy.png differ diff --git a/images/3.0x/certified.png b/images/3.0x/certified.png new file mode 100644 index 00000000..fc98fc10 Binary files /dev/null and b/images/3.0x/certified.png differ diff --git a/images/3.0x/checkbox-selected.png b/images/3.0x/checkbox-selected.png new file mode 100644 index 00000000..2814357d Binary files /dev/null and b/images/3.0x/checkbox-selected.png differ diff --git a/images/3.0x/checkbox-small.png b/images/3.0x/checkbox-small.png new file mode 100644 index 00000000..84e4a257 Binary files /dev/null and b/images/3.0x/checkbox-small.png differ diff --git a/images/3.0x/checkbox-unselected.png b/images/3.0x/checkbox-unselected.png new file mode 100644 index 00000000..63649587 Binary files /dev/null and b/images/3.0x/checkbox-unselected.png differ diff --git a/images/3.0x/chevron-blue-right.png b/images/3.0x/chevron-blue-right.png new file mode 100755 index 00000000..f292b193 Binary files /dev/null and b/images/3.0x/chevron-blue-right.png differ diff --git a/images/3.0x/chevron-down.png b/images/3.0x/chevron-down.png new file mode 100644 index 00000000..d555b26a Binary files /dev/null and b/images/3.0x/chevron-down.png differ diff --git a/images/3.0x/chevron-left-blue.png b/images/3.0x/chevron-left-blue.png new file mode 100755 index 00000000..d8b25b03 Binary files /dev/null and b/images/3.0x/chevron-left-blue.png differ diff --git a/images/3.0x/chevron-left-white.png b/images/3.0x/chevron-left-white.png new file mode 100644 index 00000000..c27bcf84 Binary files /dev/null and b/images/3.0x/chevron-left-white.png differ diff --git a/images/3.0x/chevron-left.png b/images/3.0x/chevron-left.png new file mode 100644 index 00000000..68d73f93 Binary files /dev/null and b/images/3.0x/chevron-left.png differ diff --git a/images/3.0x/chevron-right.png b/images/3.0x/chevron-right.png new file mode 100755 index 00000000..8225443b Binary files /dev/null and b/images/3.0x/chevron-right.png differ diff --git a/images/3.0x/chevron-up.png b/images/3.0x/chevron-up.png new file mode 100644 index 00000000..082dba13 Binary files /dev/null and b/images/3.0x/chevron-up.png differ diff --git a/images/3.0x/chevron2-down.png b/images/3.0x/chevron2-down.png new file mode 100755 index 00000000..1faa038d Binary files /dev/null and b/images/3.0x/chevron2-down.png differ diff --git a/images/3.0x/classic-meal-blue.png b/images/3.0x/classic-meal-blue.png new file mode 100644 index 00000000..eac20d19 Binary files /dev/null and b/images/3.0x/classic-meal-blue.png differ diff --git a/images/3.0x/classic-meal-orange.png b/images/3.0x/classic-meal-orange.png new file mode 100755 index 00000000..a8d3bfbf Binary files /dev/null and b/images/3.0x/classic-meal-orange.png differ diff --git a/images/3.0x/close-blue.png b/images/3.0x/close-blue.png new file mode 100644 index 00000000..26390605 Binary files /dev/null and b/images/3.0x/close-blue.png differ diff --git a/images/3.0x/close-gray.png b/images/3.0x/close-gray.png new file mode 100644 index 00000000..82ca0729 Binary files /dev/null and b/images/3.0x/close-gray.png differ diff --git a/images/3.0x/close-menu.png b/images/3.0x/close-menu.png new file mode 100644 index 00000000..77f328db Binary files /dev/null and b/images/3.0x/close-menu.png differ diff --git a/images/3.0x/close-orange-large.png b/images/3.0x/close-orange-large.png new file mode 100644 index 00000000..30ba4225 Binary files /dev/null and b/images/3.0x/close-orange-large.png differ diff --git a/images/3.0x/close-orange.png b/images/3.0x/close-orange.png new file mode 100755 index 00000000..53faf74d Binary files /dev/null and b/images/3.0x/close-orange.png differ diff --git a/images/3.0x/close-white-large.png b/images/3.0x/close-white-large.png new file mode 100644 index 00000000..ab1ac315 Binary files /dev/null and b/images/3.0x/close-white-large.png differ diff --git a/images/3.0x/close-white-shadow.png b/images/3.0x/close-white-shadow.png new file mode 100644 index 00000000..61213e6e Binary files /dev/null and b/images/3.0x/close-white-shadow.png differ diff --git a/images/3.0x/close-white.png b/images/3.0x/close-white.png new file mode 100644 index 00000000..65cd0ea1 Binary files /dev/null and b/images/3.0x/close-white.png differ diff --git a/images/3.0x/covid.png b/images/3.0x/covid.png new file mode 100644 index 00000000..8efad934 Binary files /dev/null and b/images/3.0x/covid.png differ diff --git a/images/3.0x/covid19-header-blue.png b/images/3.0x/covid19-header-blue.png new file mode 100644 index 00000000..e0d5e40a Binary files /dev/null and b/images/3.0x/covid19-header-blue.png differ diff --git a/images/3.0x/covid19-orange.png b/images/3.0x/covid19-orange.png new file mode 100644 index 00000000..47776ae8 Binary files /dev/null and b/images/3.0x/covid19-orange.png differ diff --git a/images/3.0x/deselected-dark.png b/images/3.0x/deselected-dark.png new file mode 100755 index 00000000..6ea1bbfe Binary files /dev/null and b/images/3.0x/deselected-dark.png differ diff --git a/images/3.0x/deselected.png b/images/3.0x/deselected.png new file mode 100755 index 00000000..d7c91e5c Binary files /dev/null and b/images/3.0x/deselected.png differ diff --git a/images/3.0x/disabled.png b/images/3.0x/disabled.png new file mode 100755 index 00000000..c48502ea Binary files /dev/null and b/images/3.0x/disabled.png differ diff --git a/images/3.0x/emotional.png b/images/3.0x/emotional.png new file mode 100644 index 00000000..7299e204 Binary files /dev/null and b/images/3.0x/emotional.png differ diff --git a/images/3.0x/enable-bluetooth-header.png b/images/3.0x/enable-bluetooth-header.png new file mode 100644 index 00000000..087c86c2 Binary files /dev/null and b/images/3.0x/enable-bluetooth-header.png differ diff --git a/images/3.0x/environmental.png b/images/3.0x/environmental.png new file mode 100644 index 00000000..513c46a5 Binary files /dev/null and b/images/3.0x/environmental.png differ diff --git a/images/3.0x/example.png b/images/3.0x/example.png new file mode 100644 index 00000000..d54bf375 Binary files /dev/null and b/images/3.0x/example.png differ diff --git a/images/3.0x/explore.png b/images/3.0x/explore.png new file mode 100644 index 00000000..1fe85608 Binary files /dev/null and b/images/3.0x/explore.png differ diff --git a/images/3.0x/external-link.png b/images/3.0x/external-link.png new file mode 100644 index 00000000..719d3e2e Binary files /dev/null and b/images/3.0x/external-link.png differ diff --git a/images/3.0x/fb-10x20.png b/images/3.0x/fb-10x20.png new file mode 100644 index 00000000..4e59ec72 Binary files /dev/null and b/images/3.0x/fb-10x20.png differ diff --git a/images/3.0x/fb-12x24.png b/images/3.0x/fb-12x24.png new file mode 100644 index 00000000..51b6cbff Binary files /dev/null and b/images/3.0x/fb-12x24.png differ diff --git a/images/3.0x/fb.png b/images/3.0x/fb.png new file mode 100755 index 00000000..4ad4ce1e Binary files /dev/null and b/images/3.0x/fb.png differ diff --git a/images/3.0x/fill-1.png b/images/3.0x/fill-1.png new file mode 100644 index 00000000..a44237d8 Binary files /dev/null and b/images/3.0x/fill-1.png differ diff --git a/images/3.0x/financial-2.png b/images/3.0x/financial-2.png new file mode 100644 index 00000000..e90eda64 Binary files /dev/null and b/images/3.0x/financial-2.png differ diff --git a/images/3.0x/game_day_blue.png b/images/3.0x/game_day_blue.png new file mode 100644 index 00000000..8417a897 Binary files /dev/null and b/images/3.0x/game_day_blue.png differ diff --git a/images/3.0x/globe.png b/images/3.0x/globe.png new file mode 100644 index 00000000..24a78d2e Binary files /dev/null and b/images/3.0x/globe.png differ diff --git a/images/3.0x/group-10.png b/images/3.0x/group-10.png new file mode 100644 index 00000000..8d586d68 Binary files /dev/null and b/images/3.0x/group-10.png differ diff --git a/images/3.0x/group-15.png b/images/3.0x/group-15.png new file mode 100644 index 00000000..c2e75ff0 Binary files /dev/null and b/images/3.0x/group-15.png differ diff --git a/images/3.0x/group-16.png b/images/3.0x/group-16.png new file mode 100644 index 00000000..9af84aef Binary files /dev/null and b/images/3.0x/group-16.png differ diff --git a/images/3.0x/group-18.png b/images/3.0x/group-18.png new file mode 100644 index 00000000..be1151a8 Binary files /dev/null and b/images/3.0x/group-18.png differ diff --git a/images/3.0x/group-2.png b/images/3.0x/group-2.png new file mode 100644 index 00000000..d1e061f8 Binary files /dev/null and b/images/3.0x/group-2.png differ diff --git a/images/3.0x/group-20.png b/images/3.0x/group-20.png new file mode 100644 index 00000000..dbd14132 Binary files /dev/null and b/images/3.0x/group-20.png differ diff --git a/images/3.0x/group-25.png b/images/3.0x/group-25.png new file mode 100644 index 00000000..ca0011ff Binary files /dev/null and b/images/3.0x/group-25.png differ diff --git a/images/3.0x/group-28.png b/images/3.0x/group-28.png new file mode 100644 index 00000000..7dea23ea Binary files /dev/null and b/images/3.0x/group-28.png differ diff --git a/images/3.0x/group-3.png b/images/3.0x/group-3.png new file mode 100644 index 00000000..1484f8cd Binary files /dev/null and b/images/3.0x/group-3.png differ diff --git a/images/3.0x/group-4.png b/images/3.0x/group-4.png new file mode 100644 index 00000000..6eeda93d Binary files /dev/null and b/images/3.0x/group-4.png differ diff --git a/images/3.0x/group-44.png b/images/3.0x/group-44.png new file mode 100644 index 00000000..eaaeb6c2 Binary files /dev/null and b/images/3.0x/group-44.png differ diff --git a/images/3.0x/group-444.png b/images/3.0x/group-444.png new file mode 100644 index 00000000..7c1d7584 Binary files /dev/null and b/images/3.0x/group-444.png differ diff --git a/images/3.0x/group-5-blue.png b/images/3.0x/group-5-blue.png new file mode 100644 index 00000000..8e7f2846 Binary files /dev/null and b/images/3.0x/group-5-blue.png differ diff --git a/images/3.0x/group-5-white.png b/images/3.0x/group-5-white.png new file mode 100644 index 00000000..7767f6e0 Binary files /dev/null and b/images/3.0x/group-5-white.png differ diff --git a/images/3.0x/group-7.png b/images/3.0x/group-7.png new file mode 100644 index 00000000..22b5ddc8 Binary files /dev/null and b/images/3.0x/group-7.png differ diff --git a/images/3.0x/group-8.png b/images/3.0x/group-8.png new file mode 100644 index 00000000..6923a7ca Binary files /dev/null and b/images/3.0x/group-8.png differ diff --git a/images/3.0x/group-9.png b/images/3.0x/group-9.png new file mode 100644 index 00000000..00dddfe4 Binary files /dev/null and b/images/3.0x/group-9.png differ diff --git a/images/3.0x/group-event-settings.png b/images/3.0x/group-event-settings.png new file mode 100644 index 00000000..0d61e501 Binary files /dev/null and b/images/3.0x/group-event-settings.png differ diff --git a/images/3.0x/group-settings-icon.png b/images/3.0x/group-settings-icon.png new file mode 100644 index 00000000..0d61e501 Binary files /dev/null and b/images/3.0x/group-settings-icon.png differ diff --git a/images/3.0x/group.png b/images/3.0x/group.png new file mode 100644 index 00000000..0900cd3e Binary files /dev/null and b/images/3.0x/group.png differ diff --git a/images/3.0x/happening.png b/images/3.0x/happening.png new file mode 100644 index 00000000..97eac378 Binary files /dev/null and b/images/3.0x/happening.png differ diff --git a/images/3.0x/icon-add-14x14.png b/images/3.0x/icon-add-14x14.png new file mode 100644 index 00000000..92618c51 Binary files /dev/null and b/images/3.0x/icon-add-14x14.png differ diff --git a/images/3.0x/icon-add-20x18.png b/images/3.0x/icon-add-20x18.png new file mode 100644 index 00000000..f6c4ed18 Binary files /dev/null and b/images/3.0x/icon-add-20x18.png differ diff --git a/images/3.0x/icon-all-set-header.png b/images/3.0x/icon-all-set-header.png new file mode 100644 index 00000000..6c3fb910 Binary files /dev/null and b/images/3.0x/icon-all-set-header.png differ diff --git a/images/3.0x/icon-athletics-blue.png b/images/3.0x/icon-athletics-blue.png new file mode 100644 index 00000000..d946bce9 Binary files /dev/null and b/images/3.0x/icon-athletics-blue.png differ diff --git a/images/3.0x/icon-athletics.png b/images/3.0x/icon-athletics.png new file mode 100644 index 00000000..412b49a1 Binary files /dev/null and b/images/3.0x/icon-athletics.png differ diff --git a/images/3.0x/icon-avatar-placeholder.png b/images/3.0x/icon-avatar-placeholder.png new file mode 100644 index 00000000..4c05364b Binary files /dev/null and b/images/3.0x/icon-avatar-placeholder.png differ diff --git a/images/3.0x/icon-badge.png b/images/3.0x/icon-badge.png new file mode 100644 index 00000000..46e224f0 Binary files /dev/null and b/images/3.0x/icon-badge.png differ diff --git a/images/3.0x/icon-big-onboarding-health.png b/images/3.0x/icon-big-onboarding-health.png new file mode 100644 index 00000000..210ce381 Binary files /dev/null and b/images/3.0x/icon-big-onboarding-health.png differ diff --git a/images/3.0x/icon-big-onboarding-privacy.png b/images/3.0x/icon-big-onboarding-privacy.png new file mode 100644 index 00000000..89ec03ff Binary files /dev/null and b/images/3.0x/icon-big-onboarding-privacy.png differ diff --git a/images/3.0x/icon-bluetooth.png b/images/3.0x/icon-bluetooth.png new file mode 100644 index 00000000..8151dd9e Binary files /dev/null and b/images/3.0x/icon-bluetooth.png differ diff --git a/images/3.0x/icon-browse-athletics.png b/images/3.0x/icon-browse-athletics.png new file mode 100644 index 00000000..600d8a98 Binary files /dev/null and b/images/3.0x/icon-browse-athletics.png differ diff --git a/images/3.0x/icon-browse-covid19.png b/images/3.0x/icon-browse-covid19.png new file mode 100644 index 00000000..693a21c8 Binary files /dev/null and b/images/3.0x/icon-browse-covid19.png differ diff --git a/images/3.0x/icon-browse-dinings.png b/images/3.0x/icon-browse-dinings.png new file mode 100644 index 00000000..cc0b17c4 Binary files /dev/null and b/images/3.0x/icon-browse-dinings.png differ diff --git a/images/3.0x/icon-browse-events.png b/images/3.0x/icon-browse-events.png new file mode 100644 index 00000000..fdecdaff Binary files /dev/null and b/images/3.0x/icon-browse-events.png differ diff --git a/images/3.0x/icon-browse-quick-polls.png b/images/3.0x/icon-browse-quick-polls.png new file mode 100644 index 00000000..69fa054c Binary files /dev/null and b/images/3.0x/icon-browse-quick-polls.png differ diff --git a/images/3.0x/icon-browse-saved.png b/images/3.0x/icon-browse-saved.png new file mode 100644 index 00000000..22b2e70f Binary files /dev/null and b/images/3.0x/icon-browse-saved.png differ diff --git a/images/3.0x/icon-browse-wellness.png b/images/3.0x/icon-browse-wellness.png new file mode 100644 index 00000000..97cb4807 Binary files /dev/null and b/images/3.0x/icon-browse-wellness.png differ diff --git a/images/3.0x/icon-browse.png b/images/3.0x/icon-browse.png new file mode 100755 index 00000000..00730a7a Binary files /dev/null and b/images/3.0x/icon-browse.png differ diff --git a/images/3.0x/icon-calendar.png b/images/3.0x/icon-calendar.png new file mode 100755 index 00000000..5573b5d2 Binary files /dev/null and b/images/3.0x/icon-calendar.png differ diff --git a/images/3.0x/icon-campus-tools-athletics.png b/images/3.0x/icon-campus-tools-athletics.png new file mode 100755 index 00000000..d0b0805a Binary files /dev/null and b/images/3.0x/icon-campus-tools-athletics.png differ diff --git a/images/3.0x/icon-campus-tools-dining.png b/images/3.0x/icon-campus-tools-dining.png new file mode 100755 index 00000000..ae99eb54 Binary files /dev/null and b/images/3.0x/icon-campus-tools-dining.png differ diff --git a/images/3.0x/icon-campus-tools-events.png b/images/3.0x/icon-campus-tools-events.png new file mode 100755 index 00000000..4b85bfb5 Binary files /dev/null and b/images/3.0x/icon-campus-tools-events.png differ diff --git a/images/3.0x/icon-campus-tools-illini-cash.png b/images/3.0x/icon-campus-tools-illini-cash.png new file mode 100755 index 00000000..6b866b34 Binary files /dev/null and b/images/3.0x/icon-campus-tools-illini-cash.png differ diff --git a/images/3.0x/icon-campus-tools-laundry.png b/images/3.0x/icon-campus-tools-laundry.png new file mode 100755 index 00000000..23f93008 Binary files /dev/null and b/images/3.0x/icon-campus-tools-laundry.png differ diff --git a/images/3.0x/icon-campus-tools.png b/images/3.0x/icon-campus-tools.png new file mode 100755 index 00000000..32bc19c9 Binary files /dev/null and b/images/3.0x/icon-campus-tools.png differ diff --git a/images/3.0x/icon-campus-updates.png b/images/3.0x/icon-campus-updates.png new file mode 100644 index 00000000..6e53cc43 Binary files /dev/null and b/images/3.0x/icon-campus-updates.png differ diff --git a/images/3.0x/icon-cellphone.png b/images/3.0x/icon-cellphone.png new file mode 100644 index 00000000..04fe45e4 Binary files /dev/null and b/images/3.0x/icon-cellphone.png differ diff --git a/images/3.0x/icon-certified.png b/images/3.0x/icon-certified.png new file mode 100755 index 00000000..fc98fc10 Binary files /dev/null and b/images/3.0x/icon-certified.png differ diff --git a/images/3.0x/icon-check-example.png b/images/3.0x/icon-check-example.png new file mode 100755 index 00000000..57883d46 Binary files /dev/null and b/images/3.0x/icon-check-example.png differ diff --git a/images/3.0x/icon-check-simple.png b/images/3.0x/icon-check-simple.png new file mode 100755 index 00000000..6440d319 Binary files /dev/null and b/images/3.0x/icon-check-simple.png differ diff --git a/images/3.0x/icon-check.png b/images/3.0x/icon-check.png new file mode 100644 index 00000000..892c66af Binary files /dev/null and b/images/3.0x/icon-check.png differ diff --git a/images/3.0x/icon-circle-close.png b/images/3.0x/icon-circle-close.png new file mode 100755 index 00000000..65cd0ea1 Binary files /dev/null and b/images/3.0x/icon-circle-close.png differ diff --git a/images/3.0x/icon-close-big.png b/images/3.0x/icon-close-big.png new file mode 100755 index 00000000..e9f52665 Binary files /dev/null and b/images/3.0x/icon-close-big.png differ diff --git a/images/3.0x/icon-comment-dots.png b/images/3.0x/icon-comment-dots.png new file mode 100644 index 00000000..dc020d86 Binary files /dev/null and b/images/3.0x/icon-comment-dots.png differ diff --git a/images/3.0x/icon-copy.png b/images/3.0x/icon-copy.png new file mode 100644 index 00000000..fcfcbd21 Binary files /dev/null and b/images/3.0x/icon-copy.png differ diff --git a/images/3.0x/icon-cost.png b/images/3.0x/icon-cost.png new file mode 100644 index 00000000..5903f816 Binary files /dev/null and b/images/3.0x/icon-cost.png differ diff --git a/images/3.0x/icon-country-guidelines.png b/images/3.0x/icon-country-guidelines.png new file mode 100644 index 00000000..96ed4916 Binary files /dev/null and b/images/3.0x/icon-country-guidelines.png differ diff --git a/images/3.0x/icon-create-event.png b/images/3.0x/icon-create-event.png new file mode 100755 index 00000000..96dafa64 Binary files /dev/null and b/images/3.0x/icon-create-event.png differ diff --git a/images/3.0x/icon-credit.png b/images/3.0x/icon-credit.png new file mode 100755 index 00000000..1c7f5db9 Binary files /dev/null and b/images/3.0x/icon-credit.png differ diff --git a/images/3.0x/icon-deselected-checkbox.png b/images/3.0x/icon-deselected-checkbox.png new file mode 100644 index 00000000..a99ee4f9 Binary files /dev/null and b/images/3.0x/icon-deselected-checkbox.png differ diff --git a/images/3.0x/icon-dining-orange.png b/images/3.0x/icon-dining-orange.png new file mode 100644 index 00000000..a9eddd67 Binary files /dev/null and b/images/3.0x/icon-dining-orange.png differ diff --git a/images/3.0x/icon-dining-yellow.png b/images/3.0x/icon-dining-yellow.png new file mode 100755 index 00000000..e7103f22 Binary files /dev/null and b/images/3.0x/icon-dining-yellow.png differ diff --git a/images/3.0x/icon-dining.png b/images/3.0x/icon-dining.png new file mode 100644 index 00000000..f8bd6332 Binary files /dev/null and b/images/3.0x/icon-dining.png differ diff --git a/images/3.0x/icon-down-orange.png b/images/3.0x/icon-down-orange.png new file mode 100755 index 00000000..3953ed30 Binary files /dev/null and b/images/3.0x/icon-down-orange.png differ diff --git a/images/3.0x/icon-down.png b/images/3.0x/icon-down.png new file mode 100755 index 00000000..a34742c3 Binary files /dev/null and b/images/3.0x/icon-down.png differ diff --git a/images/3.0x/icon-dryer-big.png b/images/3.0x/icon-dryer-big.png new file mode 100755 index 00000000..2d703f9e Binary files /dev/null and b/images/3.0x/icon-dryer-big.png differ diff --git a/images/3.0x/icon-dryer-small.png b/images/3.0x/icon-dryer-small.png new file mode 100755 index 00000000..65261fc0 Binary files /dev/null and b/images/3.0x/icon-dryer-small.png differ diff --git a/images/3.0x/icon-edit.png b/images/3.0x/icon-edit.png new file mode 100644 index 00000000..c8e7c663 Binary files /dev/null and b/images/3.0x/icon-edit.png differ diff --git a/images/3.0x/icon-event.png b/images/3.0x/icon-event.png new file mode 100644 index 00000000..262fbd22 Binary files /dev/null and b/images/3.0x/icon-event.png differ diff --git a/images/3.0x/icon-explore-campus-athletics.png b/images/3.0x/icon-explore-campus-athletics.png new file mode 100644 index 00000000..d0b0805a Binary files /dev/null and b/images/3.0x/icon-explore-campus-athletics.png differ diff --git a/images/3.0x/icon-explore-campus-dining.png b/images/3.0x/icon-explore-campus-dining.png new file mode 100644 index 00000000..ae99eb54 Binary files /dev/null and b/images/3.0x/icon-explore-campus-dining.png differ diff --git a/images/3.0x/icon-explore-campus-events.png b/images/3.0x/icon-explore-campus-events.png new file mode 100644 index 00000000..4b85bfb5 Binary files /dev/null and b/images/3.0x/icon-explore-campus-events.png differ diff --git a/images/3.0x/icon-explore.png b/images/3.0x/icon-explore.png new file mode 100644 index 00000000..1fe85608 Binary files /dev/null and b/images/3.0x/icon-explore.png differ diff --git a/images/3.0x/icon-face-mask.png b/images/3.0x/icon-face-mask.png new file mode 100644 index 00000000..877569f0 Binary files /dev/null and b/images/3.0x/icon-face-mask.png differ diff --git a/images/3.0x/icon-feedback.png b/images/3.0x/icon-feedback.png new file mode 100755 index 00000000..05631c4e Binary files /dev/null and b/images/3.0x/icon-feedback.png differ diff --git a/images/3.0x/icon-gear.png b/images/3.0x/icon-gear.png new file mode 100755 index 00000000..9e20bc3c Binary files /dev/null and b/images/3.0x/icon-gear.png differ diff --git a/images/3.0x/icon-health.png b/images/3.0x/icon-health.png new file mode 100644 index 00000000..090bc5e8 Binary files /dev/null and b/images/3.0x/icon-health.png differ diff --git a/images/3.0x/icon-hospital.png b/images/3.0x/icon-hospital.png new file mode 100644 index 00000000..3649d7e5 Binary files /dev/null and b/images/3.0x/icon-hospital.png differ diff --git a/images/3.0x/icon-identity.png b/images/3.0x/icon-identity.png new file mode 100644 index 00000000..7099723a Binary files /dev/null and b/images/3.0x/icon-identity.png differ diff --git a/images/3.0x/icon-illini-cash.png b/images/3.0x/icon-illini-cash.png new file mode 100755 index 00000000..3c7307d2 Binary files /dev/null and b/images/3.0x/icon-illini-cash.png differ diff --git a/images/3.0x/icon-info-orange.png b/images/3.0x/icon-info-orange.png new file mode 100644 index 00000000..2c86cfed Binary files /dev/null and b/images/3.0x/icon-info-orange.png differ diff --git a/images/3.0x/icon-key.png b/images/3.0x/icon-key.png new file mode 100644 index 00000000..33152095 Binary files /dev/null and b/images/3.0x/icon-key.png differ diff --git a/images/3.0x/icon-list-view.png b/images/3.0x/icon-list-view.png new file mode 100644 index 00000000..e4899b1a Binary files /dev/null and b/images/3.0x/icon-list-view.png differ diff --git a/images/3.0x/icon-listen.png b/images/3.0x/icon-listen.png new file mode 100755 index 00000000..caa1f08d Binary files /dev/null and b/images/3.0x/icon-listen.png differ diff --git a/images/3.0x/icon-live-stats.png b/images/3.0x/icon-live-stats.png new file mode 100755 index 00000000..879e7414 Binary files /dev/null and b/images/3.0x/icon-live-stats.png differ diff --git a/images/3.0x/icon-location-1.png b/images/3.0x/icon-location-1.png new file mode 100644 index 00000000..e5d1d315 Binary files /dev/null and b/images/3.0x/icon-location-1.png differ diff --git a/images/3.0x/icon-location.png b/images/3.0x/icon-location.png new file mode 100644 index 00000000..148c051a Binary files /dev/null and b/images/3.0x/icon-location.png differ diff --git a/images/3.0x/icon-map-view.png b/images/3.0x/icon-map-view.png new file mode 100644 index 00000000..a7283c0e Binary files /dev/null and b/images/3.0x/icon-map-view.png differ diff --git a/images/3.0x/icon-member.png b/images/3.0x/icon-member.png new file mode 100644 index 00000000..29118a08 Binary files /dev/null and b/images/3.0x/icon-member.png differ diff --git a/images/3.0x/icon-more-info.png b/images/3.0x/icon-more-info.png new file mode 100755 index 00000000..0ea16384 Binary files /dev/null and b/images/3.0x/icon-more-info.png differ diff --git a/images/3.0x/icon-my-illini.png b/images/3.0x/icon-my-illini.png new file mode 100755 index 00000000..4893ec5b Binary files /dev/null and b/images/3.0x/icon-my-illini.png differ diff --git a/images/3.0x/icon-near-you.png b/images/3.0x/icon-near-you.png new file mode 100755 index 00000000..1e368f4e Binary files /dev/null and b/images/3.0x/icon-near-you.png differ diff --git a/images/3.0x/icon-news.png b/images/3.0x/icon-news.png new file mode 100755 index 00000000..c9add3d0 Binary files /dev/null and b/images/3.0x/icon-news.png differ diff --git a/images/3.0x/icon-notifications-blue.png b/images/3.0x/icon-notifications-blue.png new file mode 100644 index 00000000..442099a0 Binary files /dev/null and b/images/3.0x/icon-notifications-blue.png differ diff --git a/images/3.0x/icon-orange-i.png b/images/3.0x/icon-orange-i.png new file mode 100644 index 00000000..22306b2e Binary files /dev/null and b/images/3.0x/icon-orange-i.png differ diff --git a/images/3.0x/icon-parking.png b/images/3.0x/icon-parking.png new file mode 100755 index 00000000..7957ef67 Binary files /dev/null and b/images/3.0x/icon-parking.png differ diff --git a/images/3.0x/icon-passport.png b/images/3.0x/icon-passport.png new file mode 100644 index 00000000..5d99e468 Binary files /dev/null and b/images/3.0x/icon-passport.png differ diff --git a/images/3.0x/icon-payment-type-apple-pay.png b/images/3.0x/icon-payment-type-apple-pay.png new file mode 100644 index 00000000..2e15e5a8 Binary files /dev/null and b/images/3.0x/icon-payment-type-apple-pay.png differ diff --git a/images/3.0x/icon-payment-type-cache.png b/images/3.0x/icon-payment-type-cache.png new file mode 100644 index 00000000..e2cb3853 Binary files /dev/null and b/images/3.0x/icon-payment-type-cache.png differ diff --git a/images/3.0x/icon-payment-type-cafe-credit.png b/images/3.0x/icon-payment-type-cafe-credit.png new file mode 100644 index 00000000..e21803b1 Binary files /dev/null and b/images/3.0x/icon-payment-type-cafe-credit.png differ diff --git a/images/3.0x/icon-payment-type-classic-meal.png b/images/3.0x/icon-payment-type-classic-meal.png new file mode 100644 index 00000000..ae0d9f9f Binary files /dev/null and b/images/3.0x/icon-payment-type-classic-meal.png differ diff --git a/images/3.0x/icon-payment-type-credit-card.png b/images/3.0x/icon-payment-type-credit-card.png new file mode 100644 index 00000000..314602ad Binary files /dev/null and b/images/3.0x/icon-payment-type-credit-card.png differ diff --git a/images/3.0x/icon-payment-type-google-pay.png b/images/3.0x/icon-payment-type-google-pay.png new file mode 100644 index 00000000..68d46dfa Binary files /dev/null and b/images/3.0x/icon-payment-type-google-pay.png differ diff --git a/images/3.0x/icon-payment-type-ilini-cash.png b/images/3.0x/icon-payment-type-ilini-cash.png new file mode 100644 index 00000000..bf144e6a Binary files /dev/null and b/images/3.0x/icon-payment-type-ilini-cash.png differ diff --git a/images/3.0x/icon-persona-alumni-normal.png b/images/3.0x/icon-persona-alumni-normal.png new file mode 100644 index 00000000..a2d4b746 Binary files /dev/null and b/images/3.0x/icon-persona-alumni-normal.png differ diff --git a/images/3.0x/icon-persona-alumni-selected.png b/images/3.0x/icon-persona-alumni-selected.png new file mode 100644 index 00000000..035c709f Binary files /dev/null and b/images/3.0x/icon-persona-alumni-selected.png differ diff --git a/images/3.0x/icon-persona-athletics-normal.png b/images/3.0x/icon-persona-athletics-normal.png new file mode 100644 index 00000000..c74dd0b7 Binary files /dev/null and b/images/3.0x/icon-persona-athletics-normal.png differ diff --git a/images/3.0x/icon-persona-athletics-selected.png b/images/3.0x/icon-persona-athletics-selected.png new file mode 100644 index 00000000..00a12382 Binary files /dev/null and b/images/3.0x/icon-persona-athletics-selected.png differ diff --git a/images/3.0x/icon-persona-employee-normal.png b/images/3.0x/icon-persona-employee-normal.png new file mode 100644 index 00000000..93ded27f Binary files /dev/null and b/images/3.0x/icon-persona-employee-normal.png differ diff --git a/images/3.0x/icon-persona-employee-selected.png b/images/3.0x/icon-persona-employee-selected.png new file mode 100644 index 00000000..46cc6a0a Binary files /dev/null and b/images/3.0x/icon-persona-employee-selected.png differ diff --git a/images/3.0x/icon-persona-parent-normal.png b/images/3.0x/icon-persona-parent-normal.png new file mode 100644 index 00000000..0f04b97a Binary files /dev/null and b/images/3.0x/icon-persona-parent-normal.png differ diff --git a/images/3.0x/icon-persona-parent-selected.png b/images/3.0x/icon-persona-parent-selected.png new file mode 100644 index 00000000..de23120c Binary files /dev/null and b/images/3.0x/icon-persona-parent-selected.png differ diff --git a/images/3.0x/icon-persona-resident-normal.png b/images/3.0x/icon-persona-resident-normal.png new file mode 100644 index 00000000..8405cce9 Binary files /dev/null and b/images/3.0x/icon-persona-resident-normal.png differ diff --git a/images/3.0x/icon-persona-resident-selected.png b/images/3.0x/icon-persona-resident-selected.png new file mode 100644 index 00000000..ad2ea362 Binary files /dev/null and b/images/3.0x/icon-persona-resident-selected.png differ diff --git a/images/3.0x/icon-persona-student-normal.png b/images/3.0x/icon-persona-student-normal.png new file mode 100644 index 00000000..62157fb1 Binary files /dev/null and b/images/3.0x/icon-persona-student-normal.png differ diff --git a/images/3.0x/icon-persona-student-selected.png b/images/3.0x/icon-persona-student-selected.png new file mode 100644 index 00000000..2767e921 Binary files /dev/null and b/images/3.0x/icon-persona-student-selected.png differ diff --git a/images/3.0x/icon-persona-visitor-normal.png b/images/3.0x/icon-persona-visitor-normal.png new file mode 100644 index 00000000..04c65581 Binary files /dev/null and b/images/3.0x/icon-persona-visitor-normal.png differ diff --git a/images/3.0x/icon-persona-visitor-selected.png b/images/3.0x/icon-persona-visitor-selected.png new file mode 100644 index 00000000..79936fe6 Binary files /dev/null and b/images/3.0x/icon-persona-visitor-selected.png differ diff --git a/images/3.0x/icon-phone.png b/images/3.0x/icon-phone.png new file mode 100644 index 00000000..5bd9e974 Binary files /dev/null and b/images/3.0x/icon-phone.png differ diff --git a/images/3.0x/icon-placeholder-blue.png b/images/3.0x/icon-placeholder-blue.png new file mode 100755 index 00000000..e6040d46 Binary files /dev/null and b/images/3.0x/icon-placeholder-blue.png differ diff --git a/images/3.0x/icon-placeholder-empty.png b/images/3.0x/icon-placeholder-empty.png new file mode 100755 index 00000000..da281501 Binary files /dev/null and b/images/3.0x/icon-placeholder-empty.png differ diff --git a/images/3.0x/icon-placeholder-navy.png b/images/3.0x/icon-placeholder-navy.png new file mode 100755 index 00000000..15fcc5e5 Binary files /dev/null and b/images/3.0x/icon-placeholder-navy.png differ diff --git a/images/3.0x/icon-placeholder-orange.png b/images/3.0x/icon-placeholder-orange.png new file mode 100755 index 00000000..79df8dce Binary files /dev/null and b/images/3.0x/icon-placeholder-orange.png differ diff --git a/images/3.0x/icon-placeholder-teal.png b/images/3.0x/icon-placeholder-teal.png new file mode 100755 index 00000000..33dce01b Binary files /dev/null and b/images/3.0x/icon-placeholder-teal.png differ diff --git a/images/3.0x/icon-placeholder-yellow.png b/images/3.0x/icon-placeholder-yellow.png new file mode 100755 index 00000000..833ac84c Binary files /dev/null and b/images/3.0x/icon-placeholder-yellow.png differ diff --git a/images/3.0x/icon-plus.png b/images/3.0x/icon-plus.png new file mode 100755 index 00000000..f4be4e84 Binary files /dev/null and b/images/3.0x/icon-plus.png differ diff --git a/images/3.0x/icon-poi.png b/images/3.0x/icon-poi.png new file mode 100644 index 00000000..f9ab8cb6 Binary files /dev/null and b/images/3.0x/icon-poi.png differ diff --git a/images/3.0x/icon-privacy.png b/images/3.0x/icon-privacy.png new file mode 100755 index 00000000..440633f9 Binary files /dev/null and b/images/3.0x/icon-privacy.png differ diff --git a/images/3.0x/icon-quickpoll.png b/images/3.0x/icon-quickpoll.png new file mode 100755 index 00000000..879e7414 Binary files /dev/null and b/images/3.0x/icon-quickpoll.png differ diff --git a/images/3.0x/icon-recurring-event.png b/images/3.0x/icon-recurring-event.png new file mode 100755 index 00000000..02f609d9 Binary files /dev/null and b/images/3.0x/icon-recurring-event.png differ diff --git a/images/3.0x/icon-reminder.png b/images/3.0x/icon-reminder.png new file mode 100755 index 00000000..902a3240 Binary files /dev/null and b/images/3.0x/icon-reminder.png differ diff --git a/images/3.0x/icon-report-test.png b/images/3.0x/icon-report-test.png new file mode 100644 index 00000000..00dd791c Binary files /dev/null and b/images/3.0x/icon-report-test.png differ diff --git a/images/3.0x/icon-saved-white.png b/images/3.0x/icon-saved-white.png new file mode 100755 index 00000000..805c56ac Binary files /dev/null and b/images/3.0x/icon-saved-white.png differ diff --git a/images/3.0x/icon-saved.png b/images/3.0x/icon-saved.png new file mode 100755 index 00000000..f2ee84e7 Binary files /dev/null and b/images/3.0x/icon-saved.png differ diff --git a/images/3.0x/icon-schedule.png b/images/3.0x/icon-schedule.png new file mode 100755 index 00000000..c4a5d49c Binary files /dev/null and b/images/3.0x/icon-schedule.png differ diff --git a/images/3.0x/icon-search.png b/images/3.0x/icon-search.png new file mode 100755 index 00000000..5df2afb5 Binary files /dev/null and b/images/3.0x/icon-search.png differ diff --git a/images/3.0x/icon-selected-checkbox.png b/images/3.0x/icon-selected-checkbox.png new file mode 100644 index 00000000..4ea96793 Binary files /dev/null and b/images/3.0x/icon-selected-checkbox.png differ diff --git a/images/3.0x/icon-selected.png b/images/3.0x/icon-selected.png new file mode 100755 index 00000000..c6f67cbd Binary files /dev/null and b/images/3.0x/icon-selected.png differ diff --git a/images/3.0x/icon-separate-people.png b/images/3.0x/icon-separate-people.png new file mode 100644 index 00000000..97226231 Binary files /dev/null and b/images/3.0x/icon-separate-people.png differ diff --git a/images/3.0x/icon-settings.png b/images/3.0x/icon-settings.png new file mode 100755 index 00000000..33c0a645 Binary files /dev/null and b/images/3.0x/icon-settings.png differ diff --git a/images/3.0x/icon-social-distance.png b/images/3.0x/icon-social-distance.png new file mode 100644 index 00000000..edc28477 Binary files /dev/null and b/images/3.0x/icon-social-distance.png differ diff --git a/images/3.0x/icon-star-selected.png b/images/3.0x/icon-star-selected.png new file mode 100755 index 00000000..38a89ea8 Binary files /dev/null and b/images/3.0x/icon-star-selected.png differ diff --git a/images/3.0x/icon-star-solid.png b/images/3.0x/icon-star-solid.png new file mode 100755 index 00000000..2df150de Binary files /dev/null and b/images/3.0x/icon-star-solid.png differ diff --git a/images/3.0x/icon-star-white.png b/images/3.0x/icon-star-white.png new file mode 100644 index 00000000..67c17972 Binary files /dev/null and b/images/3.0x/icon-star-white.png differ diff --git a/images/3.0x/icon-star.png b/images/3.0x/icon-star.png new file mode 100644 index 00000000..0c304e22 Binary files /dev/null and b/images/3.0x/icon-star.png differ diff --git a/images/3.0x/icon-stay-at-home.png b/images/3.0x/icon-stay-at-home.png new file mode 100644 index 00000000..bbfe75c2 Binary files /dev/null and b/images/3.0x/icon-stay-at-home.png differ diff --git a/images/3.0x/icon-stehoscope.png b/images/3.0x/icon-stehoscope.png new file mode 100644 index 00000000..f88b51b3 Binary files /dev/null and b/images/3.0x/icon-stehoscope.png differ diff --git a/images/3.0x/icon-team.png b/images/3.0x/icon-team.png new file mode 100755 index 00000000..fd30c1d8 Binary files /dev/null and b/images/3.0x/icon-team.png differ diff --git a/images/3.0x/icon-test-history.png b/images/3.0x/icon-test-history.png new file mode 100644 index 00000000..8ffdeaf9 Binary files /dev/null and b/images/3.0x/icon-test-history.png differ diff --git a/images/3.0x/icon-time.png b/images/3.0x/icon-time.png new file mode 100644 index 00000000..a8e76599 Binary files /dev/null and b/images/3.0x/icon-time.png differ diff --git a/images/3.0x/icon-unselected.png b/images/3.0x/icon-unselected.png new file mode 100755 index 00000000..3a5f35a6 Binary files /dev/null and b/images/3.0x/icon-unselected.png differ diff --git a/images/3.0x/icon-up.png b/images/3.0x/icon-up.png new file mode 100755 index 00000000..3e035501 Binary files /dev/null and b/images/3.0x/icon-up.png differ diff --git a/images/3.0x/icon-washer-big.png b/images/3.0x/icon-washer-big.png new file mode 100755 index 00000000..663898dd Binary files /dev/null and b/images/3.0x/icon-washer-big.png differ diff --git a/images/3.0x/icon-washer-small.png b/images/3.0x/icon-washer-small.png new file mode 100755 index 00000000..14b5bc99 Binary files /dev/null and b/images/3.0x/icon-washer-small.png differ diff --git a/images/3.0x/icon-washer.png b/images/3.0x/icon-washer.png new file mode 100755 index 00000000..222e9314 Binary files /dev/null and b/images/3.0x/icon-washer.png differ diff --git a/images/3.0x/icon-watch.png b/images/3.0x/icon-watch.png new file mode 100755 index 00000000..1cd4eae9 Binary files /dev/null and b/images/3.0x/icon-watch.png differ diff --git a/images/3.0x/icon-x-orange-small.png b/images/3.0x/icon-x-orange-small.png new file mode 100755 index 00000000..956db8b9 Binary files /dev/null and b/images/3.0x/icon-x-orange-small.png differ diff --git a/images/3.0x/icon-x-orange.png b/images/3.0x/icon-x-orange.png new file mode 100755 index 00000000..b8c766ab Binary files /dev/null and b/images/3.0x/icon-x-orange.png differ diff --git a/images/3.0x/icon-your-care-team.png b/images/3.0x/icon-your-care-team.png new file mode 100644 index 00000000..aed6e605 Binary files /dev/null and b/images/3.0x/icon-your-care-team.png differ diff --git a/images/3.0x/ig-20x20.png b/images/3.0x/ig-20x20.png new file mode 100644 index 00000000..d59bca0f Binary files /dev/null and b/images/3.0x/ig-20x20.png differ diff --git a/images/3.0x/ig-24x24.png b/images/3.0x/ig-24x24.png new file mode 100644 index 00000000..a0d04675 Binary files /dev/null and b/images/3.0x/ig-24x24.png differ diff --git a/images/3.0x/ig.png b/images/3.0x/ig.png new file mode 100755 index 00000000..6689ebe7 Binary files /dev/null and b/images/3.0x/ig.png differ diff --git a/images/3.0x/ilini-cash.png b/images/3.0x/ilini-cash.png new file mode 100644 index 00000000..6c3d9f4c Binary files /dev/null and b/images/3.0x/ilini-cash.png differ diff --git a/images/3.0x/kognito.png b/images/3.0x/kognito.png new file mode 100644 index 00000000..1c1935d3 Binary files /dev/null and b/images/3.0x/kognito.png differ diff --git a/images/3.0x/link-out.png b/images/3.0x/link-out.png new file mode 100644 index 00000000..b5d1c897 Binary files /dev/null and b/images/3.0x/link-out.png differ diff --git a/images/3.0x/login-header.png b/images/3.0x/login-header.png new file mode 100644 index 00000000..caa6866e Binary files /dev/null and b/images/3.0x/login-header.png differ diff --git a/images/3.0x/mc-kinley-gray.png b/images/3.0x/mc-kinley-gray.png new file mode 100644 index 00000000..e8a24564 Binary files /dev/null and b/images/3.0x/mc-kinley-gray.png differ diff --git a/images/3.0x/member.png b/images/3.0x/member.png new file mode 100644 index 00000000..90177842 Binary files /dev/null and b/images/3.0x/member.png differ diff --git a/images/3.0x/mental.png b/images/3.0x/mental.png new file mode 100644 index 00000000..96e62310 Binary files /dev/null and b/images/3.0x/mental.png differ diff --git a/images/3.0x/mtd-logo.png b/images/3.0x/mtd-logo.png new file mode 100644 index 00000000..2ada4825 Binary files /dev/null and b/images/3.0x/mtd-logo.png differ diff --git a/images/3.0x/my-illini-orange.png b/images/3.0x/my-illini-orange.png new file mode 100644 index 00000000..7b03be42 Binary files /dev/null and b/images/3.0x/my-illini-orange.png differ diff --git a/images/3.0x/navy.png b/images/3.0x/navy.png new file mode 100755 index 00000000..967cb994 Binary files /dev/null and b/images/3.0x/navy.png differ diff --git a/images/3.0x/near-you.png b/images/3.0x/near-you.png new file mode 100644 index 00000000..3c1bbd34 Binary files /dev/null and b/images/3.0x/near-you.png differ diff --git a/images/3.0x/onboarding-back-btn.png b/images/3.0x/onboarding-back-btn.png new file mode 100644 index 00000000..d631d518 Binary files /dev/null and b/images/3.0x/onboarding-back-btn.png differ diff --git a/images/3.0x/osf-logo-gray.png b/images/3.0x/osf-logo-gray.png new file mode 100644 index 00000000..5da5745b Binary files /dev/null and b/images/3.0x/osf-logo-gray.png differ diff --git a/images/3.0x/path.png b/images/3.0x/path.png new file mode 100644 index 00000000..fcb4b98c Binary files /dev/null and b/images/3.0x/path.png differ diff --git a/images/3.0x/pending.png b/images/3.0x/pending.png new file mode 100644 index 00000000..e8c85275 Binary files /dev/null and b/images/3.0x/pending.png differ diff --git a/images/3.0x/physical.png b/images/3.0x/physical.png new file mode 100644 index 00000000..06946728 Binary files /dev/null and b/images/3.0x/physical.png differ diff --git a/images/3.0x/pledge.png b/images/3.0x/pledge.png new file mode 100644 index 00000000..965e6844 Binary files /dev/null and b/images/3.0x/pledge.png differ diff --git a/images/3.0x/powered-by.png b/images/3.0x/powered-by.png new file mode 100644 index 00000000..e14c349f Binary files /dev/null and b/images/3.0x/powered-by.png differ diff --git a/images/3.0x/privacy-header.png b/images/3.0x/privacy-header.png new file mode 100644 index 00000000..5be65a28 Binary files /dev/null and b/images/3.0x/privacy-header.png differ diff --git a/images/3.0x/privacy.png b/images/3.0x/privacy.png new file mode 100644 index 00000000..2bd81e31 Binary files /dev/null and b/images/3.0x/privacy.png differ diff --git a/images/3.0x/provider.png b/images/3.0x/provider.png new file mode 100644 index 00000000..fbca851d Binary files /dev/null and b/images/3.0x/provider.png differ diff --git a/images/3.0x/reflection.png b/images/3.0x/reflection.png new file mode 100644 index 00000000..f8afd166 Binary files /dev/null and b/images/3.0x/reflection.png differ diff --git a/images/3.0x/reminder.png b/images/3.0x/reminder.png new file mode 100755 index 00000000..902a3240 Binary files /dev/null and b/images/3.0x/reminder.png differ diff --git a/images/3.0x/safer-illinois.png b/images/3.0x/safer-illinois.png new file mode 100644 index 00000000..2809a45a Binary files /dev/null and b/images/3.0x/safer-illinois.png differ diff --git a/images/3.0x/schedule-orange.png b/images/3.0x/schedule-orange.png new file mode 100755 index 00000000..c93545a4 Binary files /dev/null and b/images/3.0x/schedule-orange.png differ diff --git a/images/3.0x/selected-orange.png b/images/3.0x/selected-orange.png new file mode 100644 index 00000000..c6f67cbd Binary files /dev/null and b/images/3.0x/selected-orange.png differ diff --git a/images/3.0x/selected.png b/images/3.0x/selected.png new file mode 100755 index 00000000..dcdad6ad Binary files /dev/null and b/images/3.0x/selected.png differ diff --git a/images/3.0x/settings-white.png b/images/3.0x/settings-white.png new file mode 100644 index 00000000..cbb15a37 Binary files /dev/null and b/images/3.0x/settings-white.png differ diff --git a/images/3.0x/slant-down-right-blue-rotated.png b/images/3.0x/slant-down-right-blue-rotated.png new file mode 100644 index 00000000..acfd15f4 Binary files /dev/null and b/images/3.0x/slant-down-right-blue-rotated.png differ diff --git a/images/3.0x/slant-down-right-blue.png b/images/3.0x/slant-down-right-blue.png new file mode 100644 index 00000000..24862c14 Binary files /dev/null and b/images/3.0x/slant-down-right-blue.png differ diff --git a/images/3.0x/small-add-orange.png b/images/3.0x/small-add-orange.png new file mode 100644 index 00000000..621d5692 Binary files /dev/null and b/images/3.0x/small-add-orange.png differ diff --git a/images/3.0x/small-add.png b/images/3.0x/small-add.png new file mode 100644 index 00000000..e6e58a49 Binary files /dev/null and b/images/3.0x/small-add.png differ diff --git a/images/3.0x/social.png b/images/3.0x/social.png new file mode 100644 index 00000000..655cbaf1 Binary files /dev/null and b/images/3.0x/social.png differ diff --git a/images/3.0x/spiritual.png b/images/3.0x/spiritual.png new file mode 100644 index 00000000..f07f4eec Binary files /dev/null and b/images/3.0x/spiritual.png differ diff --git a/images/3.0x/switch-off.png b/images/3.0x/switch-off.png new file mode 100644 index 00000000..3b22f8be Binary files /dev/null and b/images/3.0x/switch-off.png differ diff --git a/images/3.0x/switch-on.png b/images/3.0x/switch-on.png new file mode 100644 index 00000000..b76f8626 Binary files /dev/null and b/images/3.0x/switch-on.png differ diff --git a/images/3.0x/tab-browse-selected.png b/images/3.0x/tab-browse-selected.png new file mode 100644 index 00000000..5183cebd Binary files /dev/null and b/images/3.0x/tab-browse-selected.png differ diff --git a/images/3.0x/tab-browse.png b/images/3.0x/tab-browse.png new file mode 100644 index 00000000..1b029b29 Binary files /dev/null and b/images/3.0x/tab-browse.png differ diff --git a/images/3.0x/tab-explore.png b/images/3.0x/tab-explore.png new file mode 100755 index 00000000..b396b5f5 Binary files /dev/null and b/images/3.0x/tab-explore.png differ diff --git a/images/3.0x/tab-home-selected.png b/images/3.0x/tab-home-selected.png new file mode 100755 index 00000000..ef32f501 Binary files /dev/null and b/images/3.0x/tab-home-selected.png differ diff --git a/images/3.0x/tab-home.png b/images/3.0x/tab-home.png new file mode 100755 index 00000000..2aed3229 Binary files /dev/null and b/images/3.0x/tab-home.png differ diff --git a/images/3.0x/tab-more-selected.png b/images/3.0x/tab-more-selected.png new file mode 100755 index 00000000..00489a69 Binary files /dev/null and b/images/3.0x/tab-more-selected.png differ diff --git a/images/3.0x/tab-more.png b/images/3.0x/tab-more.png new file mode 100755 index 00000000..1ded14ea Binary files /dev/null and b/images/3.0x/tab-more.png differ diff --git a/images/3.0x/tab-saved-selected.png b/images/3.0x/tab-saved-selected.png new file mode 100755 index 00000000..50d789b6 Binary files /dev/null and b/images/3.0x/tab-saved-selected.png differ diff --git a/images/3.0x/tab-saved.png b/images/3.0x/tab-saved.png new file mode 100755 index 00000000..f2ee84e7 Binary files /dev/null and b/images/3.0x/tab-saved.png differ diff --git a/images/3.0x/tab-wallet.png b/images/3.0x/tab-wallet.png new file mode 100644 index 00000000..bafafd72 Binary files /dev/null and b/images/3.0x/tab-wallet.png differ diff --git a/images/3.0x/teal.png b/images/3.0x/teal.png new file mode 100644 index 00000000..aeb60873 Binary files /dev/null and b/images/3.0x/teal.png differ diff --git a/images/3.0x/tickets_yellow.png b/images/3.0x/tickets_yellow.png new file mode 100644 index 00000000..001a1ea9 Binary files /dev/null and b/images/3.0x/tickets_yellow.png differ diff --git a/images/3.0x/twitter-20x18.png b/images/3.0x/twitter-20x18.png new file mode 100644 index 00000000..5e8a9761 Binary files /dev/null and b/images/3.0x/twitter-20x18.png differ diff --git a/images/3.0x/twitter-24x22.png b/images/3.0x/twitter-24x22.png new file mode 100644 index 00000000..3c6a6cd5 Binary files /dev/null and b/images/3.0x/twitter-24x22.png differ diff --git a/images/3.0x/twitter.png b/images/3.0x/twitter.png new file mode 100755 index 00000000..ca0f7028 Binary files /dev/null and b/images/3.0x/twitter.png differ diff --git a/images/3.0x/u.png b/images/3.0x/u.png new file mode 100644 index 00000000..0a5f5ce8 Binary files /dev/null and b/images/3.0x/u.png differ diff --git a/images/3.0x/upcoming_events_orange.png b/images/3.0x/upcoming_events_orange.png new file mode 100644 index 00000000..4b85bfb5 Binary files /dev/null and b/images/3.0x/upcoming_events_orange.png differ diff --git a/images/3.0x/user-check.png b/images/3.0x/user-check.png new file mode 100644 index 00000000..942da3fd Binary files /dev/null and b/images/3.0x/user-check.png differ diff --git a/images/3.0x/vocational.png b/images/3.0x/vocational.png new file mode 100644 index 00000000..5feaef4e Binary files /dev/null and b/images/3.0x/vocational.png differ diff --git a/images/3.0x/warning-orange.png b/images/3.0x/warning-orange.png new file mode 100644 index 00000000..abfe90f5 Binary files /dev/null and b/images/3.0x/warning-orange.png differ diff --git a/images/3.0x/you-tube-20x15.png b/images/3.0x/you-tube-20x15.png new file mode 100644 index 00000000..5280211e Binary files /dev/null and b/images/3.0x/you-tube-20x15.png differ diff --git a/images/3.0x/you-tube.png b/images/3.0x/you-tube.png new file mode 100755 index 00000000..958a543a Binary files /dev/null and b/images/3.0x/you-tube.png differ diff --git a/images/activate-menu.png b/images/activate-menu.png new file mode 100755 index 00000000..2f6a4440 Binary files /dev/null and b/images/activate-menu.png differ diff --git a/images/add-to-apple-wallet.png b/images/add-to-apple-wallet.png new file mode 100644 index 00000000..9bc60171 Binary files /dev/null and b/images/add-to-apple-wallet.png differ diff --git a/images/allow-notifications-header.png b/images/allow-notifications-header.png new file mode 100644 index 00000000..bec2016f Binary files /dev/null and b/images/allow-notifications-header.png differ diff --git a/images/athletics-baseball-orange.png b/images/athletics-baseball-orange.png new file mode 100644 index 00000000..bd3d9d25 Binary files /dev/null and b/images/athletics-baseball-orange.png differ diff --git a/images/athletics-baseball-white.png b/images/athletics-baseball-white.png new file mode 100644 index 00000000..72c433e3 Binary files /dev/null and b/images/athletics-baseball-white.png differ diff --git a/images/athletics-basketball-orange.png b/images/athletics-basketball-orange.png new file mode 100644 index 00000000..f4a61487 Binary files /dev/null and b/images/athletics-basketball-orange.png differ diff --git a/images/athletics-basketball-white.png b/images/athletics-basketball-white.png new file mode 100644 index 00000000..4195e02b Binary files /dev/null and b/images/athletics-basketball-white.png differ diff --git a/images/athletics-cross-orange.png b/images/athletics-cross-orange.png new file mode 100644 index 00000000..fca63443 Binary files /dev/null and b/images/athletics-cross-orange.png differ diff --git a/images/athletics-cross-white.png b/images/athletics-cross-white.png new file mode 100644 index 00000000..4cd1a497 Binary files /dev/null and b/images/athletics-cross-white.png differ diff --git a/images/athletics-football-orange.png b/images/athletics-football-orange.png new file mode 100644 index 00000000..88bb2035 Binary files /dev/null and b/images/athletics-football-orange.png differ diff --git a/images/athletics-football-white.png b/images/athletics-football-white.png new file mode 100644 index 00000000..63ef3f76 Binary files /dev/null and b/images/athletics-football-white.png differ diff --git a/images/athletics-golf-orange.png b/images/athletics-golf-orange.png new file mode 100644 index 00000000..e5d8682c Binary files /dev/null and b/images/athletics-golf-orange.png differ diff --git a/images/athletics-golf-white.png b/images/athletics-golf-white.png new file mode 100644 index 00000000..b5014004 Binary files /dev/null and b/images/athletics-golf-white.png differ diff --git a/images/athletics-gymnastics-orange.png b/images/athletics-gymnastics-orange.png new file mode 100644 index 00000000..575ba6db Binary files /dev/null and b/images/athletics-gymnastics-orange.png differ diff --git a/images/athletics-gymnastics-white.png b/images/athletics-gymnastics-white.png new file mode 100644 index 00000000..3cd25275 Binary files /dev/null and b/images/athletics-gymnastics-white.png differ diff --git a/images/athletics-handball-orange.png b/images/athletics-handball-orange.png new file mode 100644 index 00000000..058bd92c Binary files /dev/null and b/images/athletics-handball-orange.png differ diff --git a/images/athletics-handball-white.png b/images/athletics-handball-white.png new file mode 100644 index 00000000..fea8f04e Binary files /dev/null and b/images/athletics-handball-white.png differ diff --git a/images/athletics-soccer-orange.png b/images/athletics-soccer-orange.png new file mode 100644 index 00000000..75355ea0 Binary files /dev/null and b/images/athletics-soccer-orange.png differ diff --git a/images/athletics-soccer-white.png b/images/athletics-soccer-white.png new file mode 100644 index 00000000..972e0896 Binary files /dev/null and b/images/athletics-soccer-white.png differ diff --git a/images/athletics-softball-orange.png b/images/athletics-softball-orange.png new file mode 100644 index 00000000..bd3d9d25 Binary files /dev/null and b/images/athletics-softball-orange.png differ diff --git a/images/athletics-swim-orange.png b/images/athletics-swim-orange.png new file mode 100644 index 00000000..3c073c97 Binary files /dev/null and b/images/athletics-swim-orange.png differ diff --git a/images/athletics-swim-white.png b/images/athletics-swim-white.png new file mode 100644 index 00000000..d880dd6d Binary files /dev/null and b/images/athletics-swim-white.png differ diff --git a/images/athletics-tennis-orange.png b/images/athletics-tennis-orange.png new file mode 100644 index 00000000..8daba745 Binary files /dev/null and b/images/athletics-tennis-orange.png differ diff --git a/images/athletics-tennis-white.png b/images/athletics-tennis-white.png new file mode 100644 index 00000000..c545d9b4 Binary files /dev/null and b/images/athletics-tennis-white.png differ diff --git a/images/athletics-track-orange.png b/images/athletics-track-orange.png new file mode 100644 index 00000000..f95e5cee Binary files /dev/null and b/images/athletics-track-orange.png differ diff --git a/images/athletics-track-white.png b/images/athletics-track-white.png new file mode 100644 index 00000000..50048c3b Binary files /dev/null and b/images/athletics-track-white.png differ diff --git a/images/athletics-volleyball-orange.png b/images/athletics-volleyball-orange.png new file mode 100644 index 00000000..058bd92c Binary files /dev/null and b/images/athletics-volleyball-orange.png differ diff --git a/images/athletics-wrestling-orange.png b/images/athletics-wrestling-orange.png new file mode 100644 index 00000000..1be6ef4c Binary files /dev/null and b/images/athletics-wrestling-orange.png differ diff --git a/images/athletics-wrestling-white.png b/images/athletics-wrestling-white.png new file mode 100644 index 00000000..6007de5f Binary files /dev/null and b/images/athletics-wrestling-white.png differ diff --git a/images/background-image.png b/images/background-image.png new file mode 100644 index 00000000..2733ff90 Binary files /dev/null and b/images/background-image.png differ diff --git a/images/background-onboarding-squares-dark.png b/images/background-onboarding-squares-dark.png new file mode 100644 index 00000000..2c34de06 Binary files /dev/null and b/images/background-onboarding-squares-dark.png differ diff --git a/images/background-onboarding-squares-light.png b/images/background-onboarding-squares-light.png new file mode 100644 index 00000000..2c415841 Binary files /dev/null and b/images/background-onboarding-squares-light.png differ diff --git a/images/background-onboarding-squares.png b/images/background-onboarding-squares.png new file mode 100644 index 00000000..b9fa6192 Binary files /dev/null and b/images/background-onboarding-squares.png differ diff --git a/images/block-i-orange.png b/images/block-i-orange.png new file mode 100755 index 00000000..d5b9e6a1 Binary files /dev/null and b/images/block-i-orange.png differ diff --git a/images/button-plus-orange.png b/images/button-plus-orange.png new file mode 100755 index 00000000..c2e91fa7 Binary files /dev/null and b/images/button-plus-orange.png differ diff --git a/images/campus-tools-blue.png b/images/campus-tools-blue.png new file mode 100644 index 00000000..0b405341 Binary files /dev/null and b/images/campus-tools-blue.png differ diff --git a/images/campus-tools.png b/images/campus-tools.png new file mode 100644 index 00000000..9fa4e128 Binary files /dev/null and b/images/campus-tools.png differ diff --git a/images/card-image-placeholder.jpeg b/images/card-image-placeholder.jpeg new file mode 100644 index 00000000..e7e915ea Binary files /dev/null and b/images/card-image-placeholder.jpeg differ diff --git a/images/certified-copy.png b/images/certified-copy.png new file mode 100644 index 00000000..ba537321 Binary files /dev/null and b/images/certified-copy.png differ diff --git a/images/certified.png b/images/certified.png new file mode 100644 index 00000000..2991d5cd Binary files /dev/null and b/images/certified.png differ diff --git a/images/checkbox-selected.png b/images/checkbox-selected.png new file mode 100644 index 00000000..7df5ad48 Binary files /dev/null and b/images/checkbox-selected.png differ diff --git a/images/checkbox-small.png b/images/checkbox-small.png new file mode 100644 index 00000000..ff9a1beb Binary files /dev/null and b/images/checkbox-small.png differ diff --git a/images/checkbox-unselected.png b/images/checkbox-unselected.png new file mode 100644 index 00000000..2fea7953 Binary files /dev/null and b/images/checkbox-unselected.png differ diff --git a/images/chevron-blue-right.png b/images/chevron-blue-right.png new file mode 100755 index 00000000..e47f6b1d Binary files /dev/null and b/images/chevron-blue-right.png differ diff --git a/images/chevron-down.png b/images/chevron-down.png new file mode 100644 index 00000000..d17a3abc Binary files /dev/null and b/images/chevron-down.png differ diff --git a/images/chevron-left-blue.png b/images/chevron-left-blue.png new file mode 100755 index 00000000..08f5a8fc Binary files /dev/null and b/images/chevron-left-blue.png differ diff --git a/images/chevron-left-gray.png b/images/chevron-left-gray.png new file mode 100644 index 00000000..f12124d6 Binary files /dev/null and b/images/chevron-left-gray.png differ diff --git a/images/chevron-left-white.png b/images/chevron-left-white.png new file mode 100644 index 00000000..0bf34aba Binary files /dev/null and b/images/chevron-left-white.png differ diff --git a/images/chevron-left.png b/images/chevron-left.png new file mode 100644 index 00000000..b116c219 Binary files /dev/null and b/images/chevron-left.png differ diff --git a/images/chevron-right.png b/images/chevron-right.png new file mode 100755 index 00000000..831a1bcf Binary files /dev/null and b/images/chevron-right.png differ diff --git a/images/chevron-up.png b/images/chevron-up.png new file mode 100644 index 00000000..575f2a91 Binary files /dev/null and b/images/chevron-up.png differ diff --git a/images/chevron2-down.png b/images/chevron2-down.png new file mode 100755 index 00000000..d2b7fe7e Binary files /dev/null and b/images/chevron2-down.png differ diff --git a/images/classic-meal-blue.png b/images/classic-meal-blue.png new file mode 100644 index 00000000..74df2598 Binary files /dev/null and b/images/classic-meal-blue.png differ diff --git a/images/classic-meal-orange.png b/images/classic-meal-orange.png new file mode 100755 index 00000000..39c55632 Binary files /dev/null and b/images/classic-meal-orange.png differ diff --git a/images/close-blue.png b/images/close-blue.png new file mode 100644 index 00000000..b2ef1590 Binary files /dev/null and b/images/close-blue.png differ diff --git a/images/close-gray.png b/images/close-gray.png new file mode 100644 index 00000000..6970c0c6 Binary files /dev/null and b/images/close-gray.png differ diff --git a/images/close-menu.png b/images/close-menu.png new file mode 100644 index 00000000..437037a0 Binary files /dev/null and b/images/close-menu.png differ diff --git a/images/close-orange-large.png b/images/close-orange-large.png new file mode 100644 index 00000000..172e1eb5 Binary files /dev/null and b/images/close-orange-large.png differ diff --git a/images/close-orange.png b/images/close-orange.png new file mode 100755 index 00000000..fdf884a4 Binary files /dev/null and b/images/close-orange.png differ diff --git a/images/close-white-large.png b/images/close-white-large.png new file mode 100644 index 00000000..e5ac1f93 Binary files /dev/null and b/images/close-white-large.png differ diff --git a/images/close-white-shadow.png b/images/close-white-shadow.png new file mode 100644 index 00000000..256a9300 Binary files /dev/null and b/images/close-white-shadow.png differ diff --git a/images/close-white.png b/images/close-white.png new file mode 100644 index 00000000..771316e4 Binary files /dev/null and b/images/close-white.png differ diff --git a/images/covid.png b/images/covid.png new file mode 100644 index 00000000..1a7a6cf1 Binary files /dev/null and b/images/covid.png differ diff --git a/images/covid19-header-blue.png b/images/covid19-header-blue.png new file mode 100644 index 00000000..d2757760 Binary files /dev/null and b/images/covid19-header-blue.png differ diff --git a/images/covid19-orange.png b/images/covid19-orange.png new file mode 100644 index 00000000..81bdb97e Binary files /dev/null and b/images/covid19-orange.png differ diff --git a/images/deselected-dark.png b/images/deselected-dark.png new file mode 100755 index 00000000..9b949216 Binary files /dev/null and b/images/deselected-dark.png differ diff --git a/images/deselected.png b/images/deselected.png new file mode 100755 index 00000000..a818d1ca Binary files /dev/null and b/images/deselected.png differ diff --git a/images/disabled.png b/images/disabled.png new file mode 100755 index 00000000..2d372bb4 Binary files /dev/null and b/images/disabled.png differ diff --git a/images/emotional.png b/images/emotional.png new file mode 100644 index 00000000..47959743 Binary files /dev/null and b/images/emotional.png differ diff --git a/images/enable-bluetooth-header.png b/images/enable-bluetooth-header.png new file mode 100644 index 00000000..855b1e8a Binary files /dev/null and b/images/enable-bluetooth-header.png differ diff --git a/images/environmental.png b/images/environmental.png new file mode 100644 index 00000000..d1e7e374 Binary files /dev/null and b/images/environmental.png differ diff --git a/images/example.png b/images/example.png new file mode 100644 index 00000000..535a1503 Binary files /dev/null and b/images/example.png differ diff --git a/images/explore.png b/images/explore.png new file mode 100644 index 00000000..29b16874 Binary files /dev/null and b/images/explore.png differ diff --git a/images/external-link.png b/images/external-link.png new file mode 100644 index 00000000..05fb7ae2 Binary files /dev/null and b/images/external-link.png differ diff --git a/images/fb-10x20.png b/images/fb-10x20.png new file mode 100644 index 00000000..691fc278 Binary files /dev/null and b/images/fb-10x20.png differ diff --git a/images/fb-12x24.png b/images/fb-12x24.png new file mode 100644 index 00000000..d22ed2a2 Binary files /dev/null and b/images/fb-12x24.png differ diff --git a/images/fb-16x32.png b/images/fb-16x32.png new file mode 100755 index 00000000..f6f4477b Binary files /dev/null and b/images/fb-16x32.png differ diff --git a/images/fill-1.png b/images/fill-1.png new file mode 100644 index 00000000..d6a0a00c Binary files /dev/null and b/images/fill-1.png differ diff --git a/images/financial-2.png b/images/financial-2.png new file mode 100644 index 00000000..2e2a1341 Binary files /dev/null and b/images/financial-2.png differ diff --git a/images/financial.png b/images/financial.png new file mode 100644 index 00000000..eade2d3d Binary files /dev/null and b/images/financial.png differ diff --git a/images/game_day_blue.png b/images/game_day_blue.png new file mode 100644 index 00000000..6c594496 Binary files /dev/null and b/images/game_day_blue.png differ diff --git a/images/globe.png b/images/globe.png new file mode 100644 index 00000000..886ff1f9 Binary files /dev/null and b/images/globe.png differ diff --git a/images/group-10.png b/images/group-10.png new file mode 100644 index 00000000..e1d42a99 Binary files /dev/null and b/images/group-10.png differ diff --git a/images/group-15.png b/images/group-15.png new file mode 100644 index 00000000..eb4ccd92 Binary files /dev/null and b/images/group-15.png differ diff --git a/images/group-16.png b/images/group-16.png new file mode 100644 index 00000000..645ea569 Binary files /dev/null and b/images/group-16.png differ diff --git a/images/group-18.png b/images/group-18.png new file mode 100644 index 00000000..42a74b47 Binary files /dev/null and b/images/group-18.png differ diff --git a/images/group-2.png b/images/group-2.png new file mode 100644 index 00000000..5330b2c3 Binary files /dev/null and b/images/group-2.png differ diff --git a/images/group-20.png b/images/group-20.png new file mode 100644 index 00000000..fd3aa359 Binary files /dev/null and b/images/group-20.png differ diff --git a/images/group-25.png b/images/group-25.png new file mode 100644 index 00000000..43fc4d1d Binary files /dev/null and b/images/group-25.png differ diff --git a/images/group-28.png b/images/group-28.png new file mode 100644 index 00000000..2f603ebd Binary files /dev/null and b/images/group-28.png differ diff --git a/images/group-3.png b/images/group-3.png new file mode 100644 index 00000000..de451f21 Binary files /dev/null and b/images/group-3.png differ diff --git a/images/group-4.png b/images/group-4.png new file mode 100644 index 00000000..e99bead2 Binary files /dev/null and b/images/group-4.png differ diff --git a/images/group-44.png b/images/group-44.png new file mode 100644 index 00000000..e6255092 Binary files /dev/null and b/images/group-44.png differ diff --git a/images/group-444.png b/images/group-444.png new file mode 100644 index 00000000..02357db5 Binary files /dev/null and b/images/group-444.png differ diff --git a/images/group-5-blue.png b/images/group-5-blue.png new file mode 100644 index 00000000..a39e8c4c Binary files /dev/null and b/images/group-5-blue.png differ diff --git a/images/group-5-white.png b/images/group-5-white.png new file mode 100644 index 00000000..901ba403 Binary files /dev/null and b/images/group-5-white.png differ diff --git a/images/group-7.png b/images/group-7.png new file mode 100644 index 00000000..927009a4 Binary files /dev/null and b/images/group-7.png differ diff --git a/images/group-8.png b/images/group-8.png new file mode 100644 index 00000000..fe2039bf Binary files /dev/null and b/images/group-8.png differ diff --git a/images/group-9.png b/images/group-9.png new file mode 100644 index 00000000..6ab70e3b Binary files /dev/null and b/images/group-9.png differ diff --git a/images/group-event-settings.png b/images/group-event-settings.png new file mode 100644 index 00000000..5651e117 Binary files /dev/null and b/images/group-event-settings.png differ diff --git a/images/group-settings-icon.png b/images/group-settings-icon.png new file mode 100644 index 00000000..5651e117 Binary files /dev/null and b/images/group-settings-icon.png differ diff --git a/images/group.png b/images/group.png new file mode 100644 index 00000000..6259cce4 Binary files /dev/null and b/images/group.png differ diff --git a/images/happening.png b/images/happening.png new file mode 100644 index 00000000..b33189b2 Binary files /dev/null and b/images/happening.png differ diff --git a/images/header-get-started.jpg b/images/header-get-started.jpg new file mode 100644 index 00000000..5a302f35 Binary files /dev/null and b/images/header-get-started.jpg differ diff --git a/images/icon-add-14x14.png b/images/icon-add-14x14.png new file mode 100644 index 00000000..aaa7c897 Binary files /dev/null and b/images/icon-add-14x14.png differ diff --git a/images/icon-add-20x18.png b/images/icon-add-20x18.png new file mode 100644 index 00000000..f4796171 Binary files /dev/null and b/images/icon-add-20x18.png differ diff --git a/images/icon-add-more.png b/images/icon-add-more.png new file mode 100644 index 00000000..4a0e6008 Binary files /dev/null and b/images/icon-add-more.png differ diff --git a/images/icon-all-set-header.png b/images/icon-all-set-header.png new file mode 100644 index 00000000..b5d7940f Binary files /dev/null and b/images/icon-all-set-header.png differ diff --git a/images/icon-apple-pay.png b/images/icon-apple-pay.png new file mode 100644 index 00000000..f5272f50 Binary files /dev/null and b/images/icon-apple-pay.png differ diff --git a/images/icon-athletics-blue.png b/images/icon-athletics-blue.png new file mode 100644 index 00000000..b514426e Binary files /dev/null and b/images/icon-athletics-blue.png differ diff --git a/images/icon-athletics-orange.png b/images/icon-athletics-orange.png new file mode 100644 index 00000000..dbf04069 Binary files /dev/null and b/images/icon-athletics-orange.png differ diff --git a/images/icon-athletics.png b/images/icon-athletics.png new file mode 100644 index 00000000..ebc43ddb Binary files /dev/null and b/images/icon-athletics.png differ diff --git a/images/icon-avatar-placeholder.png b/images/icon-avatar-placeholder.png new file mode 100644 index 00000000..d066b17e Binary files /dev/null and b/images/icon-avatar-placeholder.png differ diff --git a/images/icon-badge.png b/images/icon-badge.png new file mode 100644 index 00000000..d1d7c797 Binary files /dev/null and b/images/icon-badge.png differ diff --git a/images/icon-big-onboarding-health.png b/images/icon-big-onboarding-health.png new file mode 100644 index 00000000..9a86bf91 Binary files /dev/null and b/images/icon-big-onboarding-health.png differ diff --git a/images/icon-big-onboarding-privacy.png b/images/icon-big-onboarding-privacy.png new file mode 100644 index 00000000..abe5e6dc Binary files /dev/null and b/images/icon-big-onboarding-privacy.png differ diff --git a/images/icon-bluetooth.png b/images/icon-bluetooth.png new file mode 100644 index 00000000..8dbf13c7 Binary files /dev/null and b/images/icon-bluetooth.png differ diff --git a/images/icon-browse-athletics.png b/images/icon-browse-athletics.png new file mode 100644 index 00000000..e42758bc Binary files /dev/null and b/images/icon-browse-athletics.png differ diff --git a/images/icon-browse-covid19.png b/images/icon-browse-covid19.png new file mode 100644 index 00000000..ec3daccb Binary files /dev/null and b/images/icon-browse-covid19.png differ diff --git a/images/icon-browse-dinings.png b/images/icon-browse-dinings.png new file mode 100644 index 00000000..91a7c9ec Binary files /dev/null and b/images/icon-browse-dinings.png differ diff --git a/images/icon-browse-events.png b/images/icon-browse-events.png new file mode 100644 index 00000000..28a2fd50 Binary files /dev/null and b/images/icon-browse-events.png differ diff --git a/images/icon-browse-quick-polls.png b/images/icon-browse-quick-polls.png new file mode 100644 index 00000000..ba1e89dc Binary files /dev/null and b/images/icon-browse-quick-polls.png differ diff --git a/images/icon-browse-saved.png b/images/icon-browse-saved.png new file mode 100644 index 00000000..f4e2b93d Binary files /dev/null and b/images/icon-browse-saved.png differ diff --git a/images/icon-browse-wellness.png b/images/icon-browse-wellness.png new file mode 100644 index 00000000..63bb28ec Binary files /dev/null and b/images/icon-browse-wellness.png differ diff --git a/images/icon-browse.png b/images/icon-browse.png new file mode 100755 index 00000000..470f648c Binary files /dev/null and b/images/icon-browse.png differ diff --git a/images/icon-calendar.png b/images/icon-calendar.png new file mode 100755 index 00000000..106ca18a Binary files /dev/null and b/images/icon-calendar.png differ diff --git a/images/icon-campus-tools-athletics.png b/images/icon-campus-tools-athletics.png new file mode 100755 index 00000000..787e5f0b Binary files /dev/null and b/images/icon-campus-tools-athletics.png differ diff --git a/images/icon-campus-tools-dining.png b/images/icon-campus-tools-dining.png new file mode 100755 index 00000000..d82ae232 Binary files /dev/null and b/images/icon-campus-tools-dining.png differ diff --git a/images/icon-campus-tools-events.png b/images/icon-campus-tools-events.png new file mode 100755 index 00000000..fc598d75 Binary files /dev/null and b/images/icon-campus-tools-events.png differ diff --git a/images/icon-campus-tools-illini-cash.png b/images/icon-campus-tools-illini-cash.png new file mode 100755 index 00000000..f43a964d Binary files /dev/null and b/images/icon-campus-tools-illini-cash.png differ diff --git a/images/icon-campus-tools-laundry.png b/images/icon-campus-tools-laundry.png new file mode 100755 index 00000000..876f0a8f Binary files /dev/null and b/images/icon-campus-tools-laundry.png differ diff --git a/images/icon-campus-tools.png b/images/icon-campus-tools.png new file mode 100755 index 00000000..c2837a49 Binary files /dev/null and b/images/icon-campus-tools.png differ diff --git a/images/icon-campus-updates.png b/images/icon-campus-updates.png new file mode 100644 index 00000000..267cdafa Binary files /dev/null and b/images/icon-campus-updates.png differ diff --git a/images/icon-cellphone.png b/images/icon-cellphone.png new file mode 100644 index 00000000..c7ae728e Binary files /dev/null and b/images/icon-cellphone.png differ diff --git a/images/icon-certified.png b/images/icon-certified.png new file mode 100755 index 00000000..2991d5cd Binary files /dev/null and b/images/icon-certified.png differ diff --git a/images/icon-check-example.png b/images/icon-check-example.png new file mode 100755 index 00000000..156fe9a3 Binary files /dev/null and b/images/icon-check-example.png differ diff --git a/images/icon-check-simple.png b/images/icon-check-simple.png new file mode 100755 index 00000000..e013aeff Binary files /dev/null and b/images/icon-check-simple.png differ diff --git a/images/icon-check.png b/images/icon-check.png new file mode 100644 index 00000000..44e38441 Binary files /dev/null and b/images/icon-check.png differ diff --git a/images/icon-circle-close.png b/images/icon-circle-close.png new file mode 100755 index 00000000..771316e4 Binary files /dev/null and b/images/icon-circle-close.png differ diff --git a/images/icon-close-big.png b/images/icon-close-big.png new file mode 100755 index 00000000..942e118f Binary files /dev/null and b/images/icon-close-big.png differ diff --git a/images/icon-comment-dots.png b/images/icon-comment-dots.png new file mode 100644 index 00000000..1327789e Binary files /dev/null and b/images/icon-comment-dots.png differ diff --git a/images/icon-copy.png b/images/icon-copy.png new file mode 100644 index 00000000..16956475 Binary files /dev/null and b/images/icon-copy.png differ diff --git a/images/icon-cost.png b/images/icon-cost.png new file mode 100644 index 00000000..885e72b7 Binary files /dev/null and b/images/icon-cost.png differ diff --git a/images/icon-country-guidelines.png b/images/icon-country-guidelines.png new file mode 100644 index 00000000..7b930970 Binary files /dev/null and b/images/icon-country-guidelines.png differ diff --git a/images/icon-create-event.png b/images/icon-create-event.png new file mode 100755 index 00000000..4a0e6008 Binary files /dev/null and b/images/icon-create-event.png differ diff --git a/images/icon-credit.png b/images/icon-credit.png new file mode 100755 index 00000000..f4e8f08a Binary files /dev/null and b/images/icon-credit.png differ diff --git a/images/icon-deselected-checkbox.png b/images/icon-deselected-checkbox.png new file mode 100644 index 00000000..f33b2a2c Binary files /dev/null and b/images/icon-deselected-checkbox.png differ diff --git a/images/icon-dining-orange.png b/images/icon-dining-orange.png new file mode 100644 index 00000000..e2b2236d Binary files /dev/null and b/images/icon-dining-orange.png differ diff --git a/images/icon-dining-yellow.png b/images/icon-dining-yellow.png new file mode 100755 index 00000000..6cbed5e9 Binary files /dev/null and b/images/icon-dining-yellow.png differ diff --git a/images/icon-dining.png b/images/icon-dining.png new file mode 100644 index 00000000..34dc50bc Binary files /dev/null and b/images/icon-dining.png differ diff --git a/images/icon-down-orange.png b/images/icon-down-orange.png new file mode 100755 index 00000000..ccbdb6a6 Binary files /dev/null and b/images/icon-down-orange.png differ diff --git a/images/icon-down.png b/images/icon-down.png new file mode 100755 index 00000000..2182d1ee Binary files /dev/null and b/images/icon-down.png differ diff --git a/images/icon-dryer-big.png b/images/icon-dryer-big.png new file mode 100755 index 00000000..65a8159a Binary files /dev/null and b/images/icon-dryer-big.png differ diff --git a/images/icon-dryer-small.png b/images/icon-dryer-small.png new file mode 100755 index 00000000..0402daff Binary files /dev/null and b/images/icon-dryer-small.png differ diff --git a/images/icon-edit.png b/images/icon-edit.png new file mode 100644 index 00000000..65574c8e Binary files /dev/null and b/images/icon-edit.png differ diff --git a/images/icon-event.png b/images/icon-event.png new file mode 100644 index 00000000..699af337 Binary files /dev/null and b/images/icon-event.png differ diff --git a/images/icon-explore-campus-athletics.png b/images/icon-explore-campus-athletics.png new file mode 100644 index 00000000..787e5f0b Binary files /dev/null and b/images/icon-explore-campus-athletics.png differ diff --git a/images/icon-explore-campus-dining.png b/images/icon-explore-campus-dining.png new file mode 100644 index 00000000..d82ae232 Binary files /dev/null and b/images/icon-explore-campus-dining.png differ diff --git a/images/icon-explore-campus-events.png b/images/icon-explore-campus-events.png new file mode 100644 index 00000000..fc598d75 Binary files /dev/null and b/images/icon-explore-campus-events.png differ diff --git a/images/icon-explore.png b/images/icon-explore.png new file mode 100644 index 00000000..29b16874 Binary files /dev/null and b/images/icon-explore.png differ diff --git a/images/icon-face-mask.png b/images/icon-face-mask.png new file mode 100644 index 00000000..21d37afa Binary files /dev/null and b/images/icon-face-mask.png differ diff --git a/images/icon-feedback.png b/images/icon-feedback.png new file mode 100755 index 00000000..12f6b47d Binary files /dev/null and b/images/icon-feedback.png differ diff --git a/images/icon-gear.png b/images/icon-gear.png new file mode 100755 index 00000000..2aa41ffb Binary files /dev/null and b/images/icon-gear.png differ diff --git a/images/icon-health.png b/images/icon-health.png new file mode 100644 index 00000000..f5a70de7 Binary files /dev/null and b/images/icon-health.png differ diff --git a/images/icon-hospital.png b/images/icon-hospital.png new file mode 100644 index 00000000..0849cba1 Binary files /dev/null and b/images/icon-hospital.png differ diff --git a/images/icon-identity.png b/images/icon-identity.png new file mode 100644 index 00000000..f412b8de Binary files /dev/null and b/images/icon-identity.png differ diff --git a/images/icon-illini-cash.png b/images/icon-illini-cash.png new file mode 100755 index 00000000..4681e316 Binary files /dev/null and b/images/icon-illini-cash.png differ diff --git a/images/icon-info-orange.png b/images/icon-info-orange.png new file mode 100644 index 00000000..664af228 Binary files /dev/null and b/images/icon-info-orange.png differ diff --git a/images/icon-key.png b/images/icon-key.png new file mode 100644 index 00000000..775852e0 Binary files /dev/null and b/images/icon-key.png differ diff --git a/images/icon-list-view.png b/images/icon-list-view.png new file mode 100644 index 00000000..d735b487 Binary files /dev/null and b/images/icon-list-view.png differ diff --git a/images/icon-listen.png b/images/icon-listen.png new file mode 100755 index 00000000..ee485a87 Binary files /dev/null and b/images/icon-listen.png differ diff --git a/images/icon-live-stats.png b/images/icon-live-stats.png new file mode 100755 index 00000000..12af9e40 Binary files /dev/null and b/images/icon-live-stats.png differ diff --git a/images/icon-location-1.png b/images/icon-location-1.png new file mode 100644 index 00000000..38493c0e Binary files /dev/null and b/images/icon-location-1.png differ diff --git a/images/icon-location.png b/images/icon-location.png new file mode 100644 index 00000000..6458b6ac Binary files /dev/null and b/images/icon-location.png differ diff --git a/images/icon-map-view.png b/images/icon-map-view.png new file mode 100644 index 00000000..478e3544 Binary files /dev/null and b/images/icon-map-view.png differ diff --git a/images/icon-member.png b/images/icon-member.png new file mode 100644 index 00000000..3d6cbe14 Binary files /dev/null and b/images/icon-member.png differ diff --git a/images/icon-more-info.png b/images/icon-more-info.png new file mode 100755 index 00000000..415e89a4 Binary files /dev/null and b/images/icon-more-info.png differ diff --git a/images/icon-my-illini.png b/images/icon-my-illini.png new file mode 100755 index 00000000..1f0b7499 Binary files /dev/null and b/images/icon-my-illini.png differ diff --git a/images/icon-near-you.png b/images/icon-near-you.png new file mode 100755 index 00000000..77041d81 Binary files /dev/null and b/images/icon-near-you.png differ diff --git a/images/icon-news.png b/images/icon-news.png new file mode 100755 index 00000000..222cdd80 Binary files /dev/null and b/images/icon-news.png differ diff --git a/images/icon-notifications-blue.png b/images/icon-notifications-blue.png new file mode 100644 index 00000000..65a1b8a3 Binary files /dev/null and b/images/icon-notifications-blue.png differ diff --git a/images/icon-orange-credit-16x13.png b/images/icon-orange-credit-16x13.png new file mode 100644 index 00000000..65c17f52 Binary files /dev/null and b/images/icon-orange-credit-16x13.png differ diff --git a/images/icon-orange-credit.png b/images/icon-orange-credit.png new file mode 100644 index 00000000..d1926b66 Binary files /dev/null and b/images/icon-orange-credit.png differ diff --git a/images/icon-orange-i.png b/images/icon-orange-i.png new file mode 100644 index 00000000..10b69fae Binary files /dev/null and b/images/icon-orange-i.png differ diff --git a/images/icon-parking.png b/images/icon-parking.png new file mode 100755 index 00000000..d19331f5 Binary files /dev/null and b/images/icon-parking.png differ diff --git a/images/icon-passport.png b/images/icon-passport.png new file mode 100644 index 00000000..b3de1113 Binary files /dev/null and b/images/icon-passport.png differ diff --git a/images/icon-payment-type-apple-pay.png b/images/icon-payment-type-apple-pay.png new file mode 100644 index 00000000..5fdf6d91 Binary files /dev/null and b/images/icon-payment-type-apple-pay.png differ diff --git a/images/icon-payment-type-cache.png b/images/icon-payment-type-cache.png new file mode 100644 index 00000000..993fcbe0 Binary files /dev/null and b/images/icon-payment-type-cache.png differ diff --git a/images/icon-payment-type-cafe-credits.png b/images/icon-payment-type-cafe-credits.png new file mode 100644 index 00000000..ab316cbe Binary files /dev/null and b/images/icon-payment-type-cafe-credits.png differ diff --git a/images/icon-payment-type-classic-meal.png b/images/icon-payment-type-classic-meal.png new file mode 100644 index 00000000..111aa54a Binary files /dev/null and b/images/icon-payment-type-classic-meal.png differ diff --git a/images/icon-payment-type-credit-card.png b/images/icon-payment-type-credit-card.png new file mode 100644 index 00000000..cb6c25f9 Binary files /dev/null and b/images/icon-payment-type-credit-card.png differ diff --git a/images/icon-payment-type-google-pay.png b/images/icon-payment-type-google-pay.png new file mode 100644 index 00000000..6f83940c Binary files /dev/null and b/images/icon-payment-type-google-pay.png differ diff --git a/images/icon-payment-type-ilini-cash.png b/images/icon-payment-type-ilini-cash.png new file mode 100644 index 00000000..cc68cabb Binary files /dev/null and b/images/icon-payment-type-ilini-cash.png differ diff --git a/images/icon-persona-alumni-normal.png b/images/icon-persona-alumni-normal.png new file mode 100644 index 00000000..2e8d011e Binary files /dev/null and b/images/icon-persona-alumni-normal.png differ diff --git a/images/icon-persona-alumni-selected.png b/images/icon-persona-alumni-selected.png new file mode 100644 index 00000000..16549582 Binary files /dev/null and b/images/icon-persona-alumni-selected.png differ diff --git a/images/icon-persona-athletics-normal.png b/images/icon-persona-athletics-normal.png new file mode 100644 index 00000000..66586b7a Binary files /dev/null and b/images/icon-persona-athletics-normal.png differ diff --git a/images/icon-persona-athletics-selected.png b/images/icon-persona-athletics-selected.png new file mode 100644 index 00000000..b1b2599f Binary files /dev/null and b/images/icon-persona-athletics-selected.png differ diff --git a/images/icon-persona-employee-normal.png b/images/icon-persona-employee-normal.png new file mode 100644 index 00000000..10bbf312 Binary files /dev/null and b/images/icon-persona-employee-normal.png differ diff --git a/images/icon-persona-employee-selected.png b/images/icon-persona-employee-selected.png new file mode 100644 index 00000000..7bbaea92 Binary files /dev/null and b/images/icon-persona-employee-selected.png differ diff --git a/images/icon-persona-parent-normal.png b/images/icon-persona-parent-normal.png new file mode 100644 index 00000000..1f99658f Binary files /dev/null and b/images/icon-persona-parent-normal.png differ diff --git a/images/icon-persona-parent-selected.png b/images/icon-persona-parent-selected.png new file mode 100644 index 00000000..b994fcb6 Binary files /dev/null and b/images/icon-persona-parent-selected.png differ diff --git a/images/icon-persona-resident-normal.png b/images/icon-persona-resident-normal.png new file mode 100644 index 00000000..26f66524 Binary files /dev/null and b/images/icon-persona-resident-normal.png differ diff --git a/images/icon-persona-resident-selected.png b/images/icon-persona-resident-selected.png new file mode 100644 index 00000000..e7985ad3 Binary files /dev/null and b/images/icon-persona-resident-selected.png differ diff --git a/images/icon-persona-student-normal.png b/images/icon-persona-student-normal.png new file mode 100644 index 00000000..7d79d22e Binary files /dev/null and b/images/icon-persona-student-normal.png differ diff --git a/images/icon-persona-student-selected.png b/images/icon-persona-student-selected.png new file mode 100644 index 00000000..a612d5d4 Binary files /dev/null and b/images/icon-persona-student-selected.png differ diff --git a/images/icon-persona-visitor-normal.png b/images/icon-persona-visitor-normal.png new file mode 100644 index 00000000..e66aacc5 Binary files /dev/null and b/images/icon-persona-visitor-normal.png differ diff --git a/images/icon-persona-visitor-selected.png b/images/icon-persona-visitor-selected.png new file mode 100644 index 00000000..d292ae82 Binary files /dev/null and b/images/icon-persona-visitor-selected.png differ diff --git a/images/icon-phone.png b/images/icon-phone.png new file mode 100644 index 00000000..3c52b4aa Binary files /dev/null and b/images/icon-phone.png differ diff --git a/images/icon-placeholder-blue.png b/images/icon-placeholder-blue.png new file mode 100755 index 00000000..3df87e54 Binary files /dev/null and b/images/icon-placeholder-blue.png differ diff --git a/images/icon-placeholder-empty.png b/images/icon-placeholder-empty.png new file mode 100755 index 00000000..16312000 Binary files /dev/null and b/images/icon-placeholder-empty.png differ diff --git a/images/icon-placeholder-navy.png b/images/icon-placeholder-navy.png new file mode 100755 index 00000000..8223140a Binary files /dev/null and b/images/icon-placeholder-navy.png differ diff --git a/images/icon-placeholder-orange.png b/images/icon-placeholder-orange.png new file mode 100755 index 00000000..6f8d4245 Binary files /dev/null and b/images/icon-placeholder-orange.png differ diff --git a/images/icon-placeholder-teal.png b/images/icon-placeholder-teal.png new file mode 100755 index 00000000..b600287f Binary files /dev/null and b/images/icon-placeholder-teal.png differ diff --git a/images/icon-placeholder-yellow.png b/images/icon-placeholder-yellow.png new file mode 100755 index 00000000..5321aa95 Binary files /dev/null and b/images/icon-placeholder-yellow.png differ diff --git a/images/icon-plus.png b/images/icon-plus.png new file mode 100755 index 00000000..76d6604a Binary files /dev/null and b/images/icon-plus.png differ diff --git a/images/icon-poi.png b/images/icon-poi.png new file mode 100644 index 00000000..4b75bbaa Binary files /dev/null and b/images/icon-poi.png differ diff --git a/images/icon-privacy.png b/images/icon-privacy.png new file mode 100755 index 00000000..0c59212a Binary files /dev/null and b/images/icon-privacy.png differ diff --git a/images/icon-quickpoll.png b/images/icon-quickpoll.png new file mode 100755 index 00000000..12af9e40 Binary files /dev/null and b/images/icon-quickpoll.png differ diff --git a/images/icon-recurring-event.png b/images/icon-recurring-event.png new file mode 100755 index 00000000..598d50d5 Binary files /dev/null and b/images/icon-recurring-event.png differ diff --git a/images/icon-reminder.png b/images/icon-reminder.png new file mode 100755 index 00000000..b2ff259c Binary files /dev/null and b/images/icon-reminder.png differ diff --git a/images/icon-report-test.png b/images/icon-report-test.png new file mode 100644 index 00000000..3b13beef Binary files /dev/null and b/images/icon-report-test.png differ diff --git a/images/icon-saved-white.png b/images/icon-saved-white.png new file mode 100755 index 00000000..10ac8525 Binary files /dev/null and b/images/icon-saved-white.png differ diff --git a/images/icon-saved.png b/images/icon-saved.png new file mode 100755 index 00000000..547a411c Binary files /dev/null and b/images/icon-saved.png differ diff --git a/images/icon-schedule.png b/images/icon-schedule.png new file mode 100755 index 00000000..8872eff0 Binary files /dev/null and b/images/icon-schedule.png differ diff --git a/images/icon-search.png b/images/icon-search.png new file mode 100755 index 00000000..e2fa90a1 Binary files /dev/null and b/images/icon-search.png differ diff --git a/images/icon-selected-checkbox.png b/images/icon-selected-checkbox.png new file mode 100644 index 00000000..6c6a2bf8 Binary files /dev/null and b/images/icon-selected-checkbox.png differ diff --git a/images/icon-selected.png b/images/icon-selected.png new file mode 100755 index 00000000..8be14cdc Binary files /dev/null and b/images/icon-selected.png differ diff --git a/images/icon-separate-people.png b/images/icon-separate-people.png new file mode 100644 index 00000000..04e31f17 Binary files /dev/null and b/images/icon-separate-people.png differ diff --git a/images/icon-settings.png b/images/icon-settings.png new file mode 100755 index 00000000..e63caa5a Binary files /dev/null and b/images/icon-settings.png differ diff --git a/images/icon-social-distance.png b/images/icon-social-distance.png new file mode 100644 index 00000000..49b452e5 Binary files /dev/null and b/images/icon-social-distance.png differ diff --git a/images/icon-star-selected.png b/images/icon-star-selected.png new file mode 100755 index 00000000..f0b83b4f Binary files /dev/null and b/images/icon-star-selected.png differ diff --git a/images/icon-star-solid.png b/images/icon-star-solid.png new file mode 100755 index 00000000..6bc61cba Binary files /dev/null and b/images/icon-star-solid.png differ diff --git a/images/icon-star-white.png b/images/icon-star-white.png new file mode 100644 index 00000000..03f90d76 Binary files /dev/null and b/images/icon-star-white.png differ diff --git a/images/icon-star.png b/images/icon-star.png new file mode 100755 index 00000000..e30202db Binary files /dev/null and b/images/icon-star.png differ diff --git a/images/icon-stay-at-home.png b/images/icon-stay-at-home.png new file mode 100644 index 00000000..930f9a25 Binary files /dev/null and b/images/icon-stay-at-home.png differ diff --git a/images/icon-stehoscope.png b/images/icon-stehoscope.png new file mode 100644 index 00000000..2a53a8fd Binary files /dev/null and b/images/icon-stehoscope.png differ diff --git a/images/icon-team.png b/images/icon-team.png new file mode 100755 index 00000000..248ab988 Binary files /dev/null and b/images/icon-team.png differ diff --git a/images/icon-test-history.png b/images/icon-test-history.png new file mode 100644 index 00000000..eab6d88b Binary files /dev/null and b/images/icon-test-history.png differ diff --git a/images/icon-time.png b/images/icon-time.png new file mode 100644 index 00000000..2e5a77d7 Binary files /dev/null and b/images/icon-time.png differ diff --git a/images/icon-unselected.png b/images/icon-unselected.png new file mode 100755 index 00000000..bbdd90e6 Binary files /dev/null and b/images/icon-unselected.png differ diff --git a/images/icon-up.png b/images/icon-up.png new file mode 100755 index 00000000..c38e6607 Binary files /dev/null and b/images/icon-up.png differ diff --git a/images/icon-washer-big.png b/images/icon-washer-big.png new file mode 100755 index 00000000..3e84ec28 Binary files /dev/null and b/images/icon-washer-big.png differ diff --git a/images/icon-washer-small.png b/images/icon-washer-small.png new file mode 100755 index 00000000..42aef68f Binary files /dev/null and b/images/icon-washer-small.png differ diff --git a/images/icon-washer.png b/images/icon-washer.png new file mode 100755 index 00000000..2c548435 Binary files /dev/null and b/images/icon-washer.png differ diff --git a/images/icon-watch.png b/images/icon-watch.png new file mode 100755 index 00000000..edb88be9 Binary files /dev/null and b/images/icon-watch.png differ diff --git a/images/icon-white-arrow-right.png b/images/icon-white-arrow-right.png new file mode 100644 index 00000000..9ff9d78e Binary files /dev/null and b/images/icon-white-arrow-right.png differ diff --git a/images/icon-x-orange-small.png b/images/icon-x-orange-small.png new file mode 100755 index 00000000..7e08bd73 Binary files /dev/null and b/images/icon-x-orange-small.png differ diff --git a/images/icon-x-orange.png b/images/icon-x-orange.png new file mode 100755 index 00000000..599f4235 Binary files /dev/null and b/images/icon-x-orange.png differ diff --git a/images/icon-your-care-team.png b/images/icon-your-care-team.png new file mode 100644 index 00000000..187a710a Binary files /dev/null and b/images/icon-your-care-team.png differ diff --git a/images/ig-20x20.png b/images/ig-20x20.png new file mode 100644 index 00000000..ced6c915 Binary files /dev/null and b/images/ig-20x20.png differ diff --git a/images/ig-24x24.png b/images/ig-24x24.png new file mode 100644 index 00000000..6a832485 Binary files /dev/null and b/images/ig-24x24.png differ diff --git a/images/ig-32x32.png b/images/ig-32x32.png new file mode 100755 index 00000000..d4c2efb1 Binary files /dev/null and b/images/ig-32x32.png differ diff --git a/images/ilini-cash.png b/images/ilini-cash.png new file mode 100644 index 00000000..e4d35117 Binary files /dev/null and b/images/ilini-cash.png differ diff --git a/images/illini-cash-logo.jpg b/images/illini-cash-logo.jpg new file mode 100644 index 00000000..e04a583f Binary files /dev/null and b/images/illini-cash-logo.jpg differ diff --git a/images/kognito.png b/images/kognito.png new file mode 100644 index 00000000..620ed214 Binary files /dev/null and b/images/kognito.png differ diff --git a/images/link-out.png b/images/link-out.png new file mode 100644 index 00000000..e887c513 Binary files /dev/null and b/images/link-out.png differ diff --git a/images/login-header.png b/images/login-header.png new file mode 100644 index 00000000..a695212b Binary files /dev/null and b/images/login-header.png differ diff --git a/images/mc-kinley-gray.png b/images/mc-kinley-gray.png new file mode 100644 index 00000000..8f776ae7 Binary files /dev/null and b/images/mc-kinley-gray.png differ diff --git a/images/member.png b/images/member.png new file mode 100644 index 00000000..c666ebd2 Binary files /dev/null and b/images/member.png differ diff --git a/images/mental.png b/images/mental.png new file mode 100644 index 00000000..f0cbbd9f Binary files /dev/null and b/images/mental.png differ diff --git a/images/mtd-logo.png b/images/mtd-logo.png new file mode 100644 index 00000000..7fc29a8f Binary files /dev/null and b/images/mtd-logo.png differ diff --git a/images/my-illini-orange.png b/images/my-illini-orange.png new file mode 100644 index 00000000..700ab7f7 Binary files /dev/null and b/images/my-illini-orange.png differ diff --git a/images/navy.png b/images/navy.png new file mode 100755 index 00000000..1c3f787d Binary files /dev/null and b/images/navy.png differ diff --git a/images/near-you.png b/images/near-you.png new file mode 100644 index 00000000..96b92727 Binary files /dev/null and b/images/near-you.png differ diff --git a/images/onboarding-back-btn.png b/images/onboarding-back-btn.png new file mode 100644 index 00000000..da2d19bb Binary files /dev/null and b/images/onboarding-back-btn.png differ diff --git a/images/osf-logo-gray.png b/images/osf-logo-gray.png new file mode 100644 index 00000000..4fc42c07 Binary files /dev/null and b/images/osf-logo-gray.png differ diff --git a/images/path.png b/images/path.png new file mode 100644 index 00000000..cb8fe5fc Binary files /dev/null and b/images/path.png differ diff --git a/images/pending.png b/images/pending.png new file mode 100644 index 00000000..481c1d76 Binary files /dev/null and b/images/pending.png differ diff --git a/images/physical.png b/images/physical.png new file mode 100644 index 00000000..9c0c7d98 Binary files /dev/null and b/images/physical.png differ diff --git a/images/pledge.png b/images/pledge.png new file mode 100644 index 00000000..a6f60d57 Binary files /dev/null and b/images/pledge.png differ diff --git a/images/posession.png b/images/posession.png new file mode 100644 index 00000000..78a1cc41 Binary files /dev/null and b/images/posession.png differ diff --git a/images/powered-by.png b/images/powered-by.png new file mode 100644 index 00000000..0ad4d2f5 Binary files /dev/null and b/images/powered-by.png differ diff --git a/images/privacy-header.png b/images/privacy-header.png new file mode 100644 index 00000000..5be65a28 Binary files /dev/null and b/images/privacy-header.png differ diff --git a/images/privacy.png b/images/privacy.png new file mode 100644 index 00000000..3689f413 Binary files /dev/null and b/images/privacy.png differ diff --git a/images/provider.png b/images/provider.png new file mode 100644 index 00000000..2a53a8fd Binary files /dev/null and b/images/provider.png differ diff --git a/images/reflection.png b/images/reflection.png new file mode 100644 index 00000000..aa9ed5ab Binary files /dev/null and b/images/reflection.png differ diff --git a/images/reminder.png b/images/reminder.png new file mode 100755 index 00000000..b2ff259c Binary files /dev/null and b/images/reminder.png differ diff --git a/images/roster_photo.png b/images/roster_photo.png new file mode 100644 index 00000000..203652c7 Binary files /dev/null and b/images/roster_photo.png differ diff --git a/images/safer-illinois.png b/images/safer-illinois.png new file mode 100644 index 00000000..614486c9 Binary files /dev/null and b/images/safer-illinois.png differ diff --git a/images/sample-event-dining.png b/images/sample-event-dining.png new file mode 100644 index 00000000..28675dc5 Binary files /dev/null and b/images/sample-event-dining.png differ diff --git a/images/sample-event-event.png b/images/sample-event-event.png new file mode 100644 index 00000000..9aaa457a Binary files /dev/null and b/images/sample-event-event.png differ diff --git a/images/schedule-orange.png b/images/schedule-orange.png new file mode 100755 index 00000000..6a44030e Binary files /dev/null and b/images/schedule-orange.png differ diff --git a/images/selected-black.png b/images/selected-black.png new file mode 100644 index 00000000..f89ee2df Binary files /dev/null and b/images/selected-black.png differ diff --git a/images/selected-gray.png b/images/selected-gray.png new file mode 100644 index 00000000..3e7e7571 Binary files /dev/null and b/images/selected-gray.png differ diff --git a/images/selected-orange.png b/images/selected-orange.png new file mode 100644 index 00000000..8be14cdc Binary files /dev/null and b/images/selected-orange.png differ diff --git a/images/selected.png b/images/selected.png new file mode 100755 index 00000000..64c67b17 Binary files /dev/null and b/images/selected.png differ diff --git a/images/settings-white.png b/images/settings-white.png new file mode 100644 index 00000000..ea4401af Binary files /dev/null and b/images/settings-white.png differ diff --git a/images/share-location-header.png b/images/share-location-header.png new file mode 100644 index 00000000..c568b48b Binary files /dev/null and b/images/share-location-header.png differ diff --git a/images/slant-down-right-blue-rotated.png b/images/slant-down-right-blue-rotated.png new file mode 100644 index 00000000..e58d22a0 Binary files /dev/null and b/images/slant-down-right-blue-rotated.png differ diff --git a/images/slant-down-right-blue.png b/images/slant-down-right-blue.png new file mode 100644 index 00000000..0a6c05e0 Binary files /dev/null and b/images/slant-down-right-blue.png differ diff --git a/images/slant-down-right-grey.png b/images/slant-down-right-grey.png new file mode 100644 index 00000000..e7fe48d2 Binary files /dev/null and b/images/slant-down-right-grey.png differ diff --git a/images/slant-down-right-rotated.png b/images/slant-down-right-rotated.png new file mode 100644 index 00000000..23ea0856 Binary files /dev/null and b/images/slant-down-right-rotated.png differ diff --git a/images/slant-down-right.png b/images/slant-down-right.png new file mode 100755 index 00000000..3ec1ac73 Binary files /dev/null and b/images/slant-down-right.png differ diff --git a/images/small-add-orange.png b/images/small-add-orange.png new file mode 100644 index 00000000..59a8f53c Binary files /dev/null and b/images/small-add-orange.png differ diff --git a/images/small-add.png b/images/small-add.png new file mode 100644 index 00000000..9a970aa9 Binary files /dev/null and b/images/small-add.png differ diff --git a/images/social.png b/images/social.png new file mode 100644 index 00000000..61e5fd9a Binary files /dev/null and b/images/social.png differ diff --git a/images/spiritual.png b/images/spiritual.png new file mode 100644 index 00000000..816ddfa2 Binary files /dev/null and b/images/spiritual.png differ diff --git a/images/switch-off.png b/images/switch-off.png new file mode 100644 index 00000000..cc802107 Binary files /dev/null and b/images/switch-off.png differ diff --git a/images/switch-on.png b/images/switch-on.png new file mode 100644 index 00000000..506be70a Binary files /dev/null and b/images/switch-on.png differ diff --git a/images/tab-browse-selected.png b/images/tab-browse-selected.png new file mode 100644 index 00000000..c4490262 Binary files /dev/null and b/images/tab-browse-selected.png differ diff --git a/images/tab-browse.png b/images/tab-browse.png new file mode 100644 index 00000000..d443ade6 Binary files /dev/null and b/images/tab-browse.png differ diff --git a/images/tab-explore-selected.png b/images/tab-explore-selected.png new file mode 100644 index 00000000..549bf362 Binary files /dev/null and b/images/tab-explore-selected.png differ diff --git a/images/tab-explore.png b/images/tab-explore.png new file mode 100755 index 00000000..433fc078 Binary files /dev/null and b/images/tab-explore.png differ diff --git a/images/tab-home-selected.png b/images/tab-home-selected.png new file mode 100755 index 00000000..d137dc11 Binary files /dev/null and b/images/tab-home-selected.png differ diff --git a/images/tab-home.png b/images/tab-home.png new file mode 100755 index 00000000..2655cb81 Binary files /dev/null and b/images/tab-home.png differ diff --git a/images/tab-more-selected.png b/images/tab-more-selected.png new file mode 100755 index 00000000..0cacb796 Binary files /dev/null and b/images/tab-more-selected.png differ diff --git a/images/tab-more.png b/images/tab-more.png new file mode 100755 index 00000000..28c2ae5b Binary files /dev/null and b/images/tab-more.png differ diff --git a/images/tab-saved-selected.png b/images/tab-saved-selected.png new file mode 100755 index 00000000..9c86c9e6 Binary files /dev/null and b/images/tab-saved-selected.png differ diff --git a/images/tab-saved.png b/images/tab-saved.png new file mode 100755 index 00000000..547a411c Binary files /dev/null and b/images/tab-saved.png differ diff --git a/images/tab-wallet.png b/images/tab-wallet.png new file mode 100644 index 00000000..dbe24847 Binary files /dev/null and b/images/tab-wallet.png differ diff --git a/images/teal.png b/images/teal.png new file mode 100644 index 00000000..160ed90f Binary files /dev/null and b/images/teal.png differ diff --git a/images/tickets_yellow.png b/images/tickets_yellow.png new file mode 100644 index 00000000..1006c96e Binary files /dev/null and b/images/tickets_yellow.png differ diff --git a/images/transparent-buss-icon.png b/images/transparent-buss-icon.png new file mode 100644 index 00000000..08cfe896 Binary files /dev/null and b/images/transparent-buss-icon.png differ diff --git a/images/twitter-20x18.png b/images/twitter-20x18.png new file mode 100644 index 00000000..81671424 Binary files /dev/null and b/images/twitter-20x18.png differ diff --git a/images/twitter-24x22.png b/images/twitter-24x22.png new file mode 100644 index 00000000..6b9a1e30 Binary files /dev/null and b/images/twitter-24x22.png differ diff --git a/images/twitter-32x28.png b/images/twitter-32x28.png new file mode 100755 index 00000000..d740984f Binary files /dev/null and b/images/twitter-32x28.png differ diff --git a/images/u.png b/images/u.png new file mode 100644 index 00000000..6ec9b803 Binary files /dev/null and b/images/u.png differ diff --git a/images/upcoming_events_orange.png b/images/upcoming_events_orange.png new file mode 100644 index 00000000..fc598d75 Binary files /dev/null and b/images/upcoming_events_orange.png differ diff --git a/images/upcoming_events_orange.textClipping b/images/upcoming_events_orange.textClipping new file mode 100644 index 00000000..0b181a1d Binary files /dev/null and b/images/upcoming_events_orange.textClipping differ diff --git a/images/user-check.png b/images/user-check.png new file mode 100644 index 00000000..0f2e6308 Binary files /dev/null and b/images/user-check.png differ diff --git a/images/vocational.png b/images/vocational.png new file mode 100644 index 00000000..9d384ac1 Binary files /dev/null and b/images/vocational.png differ diff --git a/images/warning-orange.png b/images/warning-orange.png new file mode 100644 index 00000000..5204b1ee Binary files /dev/null and b/images/warning-orange.png differ diff --git a/images/welcome-to-illinois.png b/images/welcome-to-illinois.png new file mode 100755 index 00000000..995cd06d Binary files /dev/null and b/images/welcome-to-illinois.png differ diff --git a/images/you-tube-20x15.png b/images/you-tube-20x15.png new file mode 100644 index 00000000..5459b259 Binary files /dev/null and b/images/you-tube-20x15.png differ diff --git a/images/you-tube-32x24.png b/images/you-tube-32x24.png new file mode 100755 index 00000000..f4ce7e91 Binary files /dev/null and b/images/you-tube-32x24.png differ diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..9367d483 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..e8efba11 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Flutter.podspec b/ios/Flutter/Flutter.podspec new file mode 100644 index 00000000..5ca30416 --- /dev/null +++ b/ios/Flutter/Flutter.podspec @@ -0,0 +1,18 @@ +# +# NOTE: This podspec is NOT to be published. It is only used as a local source! +# + +Pod::Spec.new do |s| + s.name = 'Flutter' + s.version = '1.0.0' + s.summary = 'High-performance, high-fidelity mobile apps.' + s.description = <<-DESC +Flutter provides an easy and productive way to build and deploy high-performance mobile apps for Android and iOS. + DESC + s.homepage = 'https://flutter.io' + s.license = { :type => 'MIT' } + s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } + s.source = { :git => 'https://github.com/flutter/engine', :tag => s.version.to_s } + s.ios.deployment_target = '8.0' + s.vendored_frameworks = 'Flutter.framework' +end diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..399e9340 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Locations/Grainger Engineering Library.gpx b/ios/Locations/Grainger Engineering Library.gpx new file mode 100644 index 00000000..73db27b6 --- /dev/null +++ b/ios/Locations/Grainger Engineering Library.gpx @@ -0,0 +1 @@ + Novato 1301 W Springfield Ave Urbana IL 61801 United States \ No newline at end of file diff --git a/ios/Locations/MemorialStadium.gpx b/ios/Locations/MemorialStadium.gpx new file mode 100644 index 00000000..199ed2e9 --- /dev/null +++ b/ios/Locations/MemorialStadium.gpx @@ -0,0 +1 @@ + Memorial Stadium Fourth and Kirby Champaign IL 61820 United States \ No newline at end of file diff --git a/ios/Locations/Novato.gpx b/ios/Locations/Novato.gpx new file mode 100644 index 00000000..f9b79535 --- /dev/null +++ b/ios/Locations/Novato.gpx @@ -0,0 +1 @@ + Novato 633 Trumbull Ave Novato CA 94947 United States \ No newline at end of file diff --git a/ios/Locations/PaloAlto.gpx b/ios/Locations/PaloAlto.gpx new file mode 100644 index 00000000..4c12e739 --- /dev/null +++ b/ios/Locations/PaloAlto.gpx @@ -0,0 +1 @@ + Palo Alto 780 Seale Ave Palo Alto CA 94303 United States \ No newline at end of file diff --git a/ios/Locations/Ruse.gpx b/ios/Locations/Ruse.gpx new file mode 100644 index 00000000..67c69aa0 --- /dev/null +++ b/ios/Locations/Ruse.gpx @@ -0,0 +1 @@ + Novato ulitsa Borisova 94b Ruse 7012 Bulgaria \ No newline at end of file diff --git a/ios/Locations/StateFarm-around.gpx b/ios/Locations/StateFarm-around.gpx new file mode 100644 index 00000000..bfe45626 --- /dev/null +++ b/ios/Locations/StateFarm-around.gpx @@ -0,0 +1 @@ + State Farm Center (around) Fourth and Kirby Champaign IL 61820 United States \ No newline at end of file diff --git a/ios/Locations/StateFarm-around2.gpx b/ios/Locations/StateFarm-around2.gpx new file mode 100644 index 00000000..ecf7977f --- /dev/null +++ b/ios/Locations/StateFarm-around2.gpx @@ -0,0 +1 @@ + State Farm Center (around 2) Fourth and Kirby Champaign IL 61820 United States \ No newline at end of file diff --git a/ios/Locations/StateFarm-around3.gpx b/ios/Locations/StateFarm-around3.gpx new file mode 100644 index 00000000..7bb2a0ec --- /dev/null +++ b/ios/Locations/StateFarm-around3.gpx @@ -0,0 +1 @@ + State Farm Center (around 3) Fourth and Kirby Champaign IL 61820 United States \ No newline at end of file diff --git a/ios/Locations/StateFarm.gpx b/ios/Locations/StateFarm.gpx new file mode 100644 index 00000000..3ec31731 --- /dev/null +++ b/ios/Locations/StateFarm.gpx @@ -0,0 +1 @@ + State Farm Center Fourth and Kirby Champaign IL 61820 United States \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 00000000..1146ba13 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,98 @@ +# Uncomment this line to define a global platform for your project +# Set platform version to 11.0 because of MeridianSDK 5.10.0 +platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def parse_KV_file(file, separator='=') + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return []; + end + generated_key_values = {} + skip_line_start_symbols = ["#", "/"] + File.foreach(file_abs_path) do |line| + next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } + plugin = line.split(pattern=separator) + if plugin.length == 2 + podname = plugin[0].strip() + path = plugin[1].strip() + podpath = File.expand_path("#{path}", file_abs_path) + generated_key_values[podname] = podpath + else + puts "Invalid plugin specification: #{line}" + end + end + generated_key_values +end + +target 'Runner' do + use_frameworks! + use_modular_headers! + + pod 'GoogleMaps', '3.3.0' + pod 'MapsIndoors', '3.8.0' + pod 'ZXingObjC', '3.6.4' + pod 'PPBlinkID', '~> 5.3.0' + pod 'HKDFKit', '0.0.3' + pod 'Firebase/MLVisionBarcodeModel' + + # Flutter Pod + + copied_flutter_dir = File.join(__dir__, 'Flutter') + copied_framework_path = File.join(copied_flutter_dir, 'Flutter.framework') + copied_podspec_path = File.join(copied_flutter_dir, 'Flutter.podspec') + unless File.exist?(copied_framework_path) && File.exist?(copied_podspec_path) + # Copy Flutter.framework and Flutter.podspec to Flutter/ to have something to link against if the xcode backend script has not run yet. + # That script will copy the correct debug/profile/release version of the framework based on the currently selected Xcode configuration. + # CocoaPods will not embed the framework on pod install (before any build phases can generate) if the dylib does not exist. + + generated_xcode_build_settings_path = File.join(copied_flutter_dir, 'Generated.xcconfig') + unless File.exist?(generated_xcode_build_settings_path) + raise "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + generated_xcode_build_settings = parse_KV_file(generated_xcode_build_settings_path) + cached_framework_dir = generated_xcode_build_settings['FLUTTER_FRAMEWORK_DIR']; + + unless File.exist?(copied_framework_path) + FileUtils.cp_r(File.join(cached_framework_dir, 'Flutter.framework'), copied_flutter_dir) + end + unless File.exist?(copied_podspec_path) + FileUtils.cp(File.join(cached_framework_dir, 'Flutter.podspec'), copied_flutter_dir) + end + end + + # Keep pod path relative so it can be checked into Podfile.lock. + pod 'Flutter', :path => 'Flutter' + + # Plugin Pods + + # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock + # referring to absolute paths on developers' machines. + system('rm -rf .symlinks') + system('mkdir -p .symlinks/plugins') + plugin_pods = parse_KV_file('../.flutter-plugins') + plugin_pods.each do |name, path| + symlink = File.join('.symlinks', 'plugins', name) + File.symlink(path, symlink) + pod name, :path => File.join(symlink, 'ios') + end +end + +# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. +install! 'cocoapods', :disable_input_output_paths => true + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['ENABLE_BITCODE'] = 'NO' + end + end +end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..ab021839 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,926 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; settings = {COMPILER_FLAGS = "-w"; }; }; + 2605FF54236C13A6002F71BE /* GoogleService-Info-Debug.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2605FF52236C13A6002F71BE /* GoogleService-Info-Debug.plist */; }; + 2605FF55236C13A6002F71BE /* GoogleService-Info-Release.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2605FF53236C13A6002F71BE /* GoogleService-Info-Release.plist */; }; + 2626D94B22DC99C800F6BC2F /* NSString+InaJson.m in Sources */ = {isa = PBXBuildFile; fileRef = 2626D94922DC99C700F6BC2F /* NSString+InaJson.m */; }; + 2626D94E22DCB80000F6BC2F /* MapMarkerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 2626D94D22DCB80000F6BC2F /* MapMarkerView.m */; }; + 266FA50323E0388B00F800F5 /* MapController.m in Sources */ = {isa = PBXBuildFile; fileRef = 266FA50223E0388B00F800F5 /* MapController.m */; }; + 268F30F122E5DD7900547FE1 /* travel-mode-walk@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 268F30ED22E5DD7900547FE1 /* travel-mode-walk@2x.png */; }; + 268F30F222E5DD7900547FE1 /* travel-mode-transit@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 268F30EE22E5DD7900547FE1 /* travel-mode-transit@2x.png */; }; + 268F30F322E5DD7900547FE1 /* travel-mode-bicycle@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 268F30EF22E5DD7900547FE1 /* travel-mode-bicycle@2x.png */; }; + 268F30F422E5DD7900547FE1 /* travel-mode-drive@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 268F30F022E5DD7900547FE1 /* travel-mode-drive@2x.png */; }; + 268F30F622E5E47600547FE1 /* travel-mode-unknown@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 268F30F522E5E47600547FE1 /* travel-mode-unknown@2x.png */; }; + 268F310022E5E7BF00547FE1 /* NSUserDefaults+InaUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 268F30FF22E5E7BF00547FE1 /* NSUserDefaults+InaUtils.m */; }; + 268F647A22DF050400A85AFD /* CLLocationCoordinate2D+InaUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 268F647922DF050400A85AFD /* CLLocationCoordinate2D+InaUtils.m */; }; + 268F647D22DF09D900A85AFD /* NSArray+InaTypedValue.m in Sources */ = {isa = PBXBuildFile; fileRef = 268F647B22DF09D900A85AFD /* NSArray+InaTypedValue.m */; }; + 268F647F22DF15F700A85AFD /* button-icon-nav-refresh.png in Resources */ = {isa = PBXBuildFile; fileRef = 268F647E22DF15F700A85AFD /* button-icon-nav-refresh.png */; }; + 268F648122DF200300A85AFD /* button-icon-nav-location.png in Resources */ = {isa = PBXBuildFile; fileRef = 268F648022DF200300A85AFD /* button-icon-nav-location.png */; }; + 26962B542306E8000026240A /* maps-icon-marker-pin-20@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 26962B502306E8000026240A /* maps-icon-marker-pin-20@2x.png */; }; + 26962B552306E8000026240A /* maps-icon-marker-pin-30@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 26962B512306E8000026240A /* maps-icon-marker-pin-30@2x.png */; }; + 26962B562306E8000026240A /* maps-icon-marker-pin-10@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 26962B522306E8000026240A /* maps-icon-marker-pin-10@2x.png */; }; + 26962B572306E8000026240A /* maps-icon-marker-pin-40@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 26962B532306E8000026240A /* maps-icon-marker-pin-40@2x.png */; }; + 26962B592306EB190026240A /* maps-icon-marker-pin-50@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 26962B582306EB190026240A /* maps-icon-marker-pin-50@2x.png */; }; + 2696995D22C38B4000B3290E /* AppKeys.m in Sources */ = {isa = PBXBuildFile; fileRef = 2696995922C38B4000B3290E /* AppKeys.m */; }; + 2696995E22C38B4000B3290E /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 2696995A22C38B4000B3290E /* AppDelegate.m */; }; + 2696996022C38C1D00B3290E /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 2696995F22C38C1D00B3290E /* main.m */; }; + 2696998022C3A14A00B3290E /* MapView.m in Sources */ = {isa = PBXBuildFile; fileRef = 2696997F22C3A14A00B3290E /* MapView.m */; }; + 269F83E622D73E7400CC11A4 /* maps-icon-marker-circle-10@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 269F83E222D73E7400CC11A4 /* maps-icon-marker-circle-10@2x.png */; }; + 269F83E722D73E7400CC11A4 /* maps-icon-marker-circle-30@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 269F83E322D73E7400CC11A4 /* maps-icon-marker-circle-30@2x.png */; }; + 269F83E822D73E7400CC11A4 /* maps-icon-marker-circle-20@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 269F83E422D73E7400CC11A4 /* maps-icon-marker-circle-20@2x.png */; }; + 269F83E922D73E7400CC11A4 /* maps-icon-marker-circle-40@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 269F83E522D73E7400CC11A4 /* maps-icon-marker-circle-40@2x.png */; }; + 269F83EE22D73EB400CC11A4 /* NSDate+UIUCUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 269F83EA22D73EB400CC11A4 /* NSDate+UIUCUtils.m */; }; + 269F83EF22D73EB400CC11A4 /* UIColor+InaParse.m in Sources */ = {isa = PBXBuildFile; fileRef = 269F83EC22D73EB400CC11A4 /* UIColor+InaParse.m */; }; + 269F83F222D744E200CC11A4 /* CGGeometry+InaUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 269F83F122D744E200CC11A4 /* CGGeometry+InaUtils.m */; }; + 269F83FC22D77E5500CC11A4 /* MapDirectionsController.m in Sources */ = {isa = PBXBuildFile; fileRef = 269F83FB22D77E5500CC11A4 /* MapDirectionsController.m */; }; + 26A170EB22DDFB6500299773 /* button-icon-nav-next.png in Resources */ = {isa = PBXBuildFile; fileRef = 26A170E822DDFB6500299773 /* button-icon-nav-next.png */; }; + 26A170EC22DDFB6500299773 /* button-icon-nav-prev.png in Resources */ = {isa = PBXBuildFile; fileRef = 26A170E922DDFB6500299773 /* button-icon-nav-prev.png */; }; + 26A170ED22DDFB6500299773 /* button-icon-nav-clear.png in Resources */ = {isa = PBXBuildFile; fileRef = 26A170EA22DDFB6500299773 /* button-icon-nav-clear.png */; }; + 26B340372331163A0031CF70 /* MapLocationPickerController.m in Sources */ = {isa = PBXBuildFile; fileRef = 26B340362331163A0031CF70 /* MapLocationPickerController.m */; }; + 26B3403A2331195C0031CF70 /* UILabel+InaMeasure.m in Sources */ = {isa = PBXBuildFile; fileRef = 26B340392331195C0031CF70 /* UILabel+InaMeasure.m */; }; + 26C07E2E247677A600E28D43 /* ExposurePlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = 26C07E2D247677A600E28D43 /* ExposurePlugin.m */; }; + 26C90CED2361CF480092E07F /* NSDictionary+InaPathKey.m in Sources */ = {isa = PBXBuildFile; fileRef = 26C90CEC2361CF480092E07F /* NSDictionary+InaPathKey.m */; }; + 26C90CF02361D13E0092E07F /* NSDictionary+UIUCConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 26C90CEE2361D13E0092E07F /* NSDictionary+UIUCConfig.m */; }; + 26ECB3232487938C00479487 /* CommonCrypto+UIUCUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 26ECB3222487938C00479487 /* CommonCrypto+UIUCUtils.m */; }; + 26ECB3262487AD0900479487 /* Security+UIUCUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 26ECB3252487AD0900479487 /* Security+UIUCUtils.m */; }; + 26FA06BE22CCA9B5003B78E4 /* NSDictionary+InaTypedValue.m in Sources */ = {isa = PBXBuildFile; fileRef = 26FA06BC22CCA9B5003B78E4 /* NSDictionary+InaTypedValue.m */; }; + 26FAB1C922EB3502008E987C /* NSDictionary+UIUCExplore.m in Sources */ = {isa = PBXBuildFile; fileRef = 26FAB1C822EB3502008E987C /* NSDictionary+UIUCExplore.m */; }; + 26FAB1CC22EB35C7008E987C /* NSDate+InaUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 26FAB1CA22EB35C7008E987C /* NSDate+InaUtils.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B6201F5B24E2D8080050F7DC /* GalleryPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = B6201F5A24E2D8080050F7DC /* GalleryPlugin.m */; }; + B681B2B923DEFD9E00093A67 /* NSData+InaHex.m in Sources */ = {isa = PBXBuildFile; fileRef = B681B2B823DEFD9E00093A67 /* NSData+InaHex.m */; }; + B6BDCD37242CCF5F002F7364 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6BDCD39242CCF5F002F7364 /* Localizable.strings */; }; + B6EA372423A7EF76001D78A5 /* Bluetooth+InaUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = B6EA372323A7EF76001D78A5 /* Bluetooth+InaUtils.m */; }; + BB7BF12F5D3356D25889D23A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE7B5BCF89DF9B7AC1FD247B /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2605FF52236C13A6002F71BE /* GoogleService-Info-Debug.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Debug.plist"; sourceTree = ""; }; + 2605FF53236C13A6002F71BE /* GoogleService-Info-Release.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Release.plist"; sourceTree = ""; }; + 2626D94922DC99C700F6BC2F /* NSString+InaJson.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+InaJson.m"; sourceTree = ""; }; + 2626D94A22DC99C700F6BC2F /* NSString+InaJson.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+InaJson.h"; sourceTree = ""; }; + 2626D94C22DCB80000F6BC2F /* MapMarkerView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MapMarkerView.h; sourceTree = ""; }; + 2626D94D22DCB80000F6BC2F /* MapMarkerView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MapMarkerView.m; sourceTree = ""; }; + 2668953022E1E074003CAB94 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 266FA50123E0388B00F800F5 /* MapController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MapController.h; sourceTree = ""; }; + 266FA50223E0388B00F800F5 /* MapController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MapController.m; sourceTree = ""; }; + 268F30ED22E5DD7900547FE1 /* travel-mode-walk@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "travel-mode-walk@2x.png"; sourceTree = ""; }; + 268F30EE22E5DD7900547FE1 /* travel-mode-transit@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "travel-mode-transit@2x.png"; sourceTree = ""; }; + 268F30EF22E5DD7900547FE1 /* travel-mode-bicycle@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "travel-mode-bicycle@2x.png"; sourceTree = ""; }; + 268F30F022E5DD7900547FE1 /* travel-mode-drive@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "travel-mode-drive@2x.png"; sourceTree = ""; }; + 268F30F522E5E47600547FE1 /* travel-mode-unknown@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "travel-mode-unknown@2x.png"; sourceTree = ""; }; + 268F30FE22E5E7BE00547FE1 /* NSUserDefaults+InaUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSUserDefaults+InaUtils.h"; sourceTree = ""; }; + 268F30FF22E5E7BF00547FE1 /* NSUserDefaults+InaUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserDefaults+InaUtils.m"; sourceTree = ""; }; + 268F647822DF050400A85AFD /* CLLocationCoordinate2D+InaUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CLLocationCoordinate2D+InaUtils.h"; sourceTree = ""; }; + 268F647922DF050400A85AFD /* CLLocationCoordinate2D+InaUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CLLocationCoordinate2D+InaUtils.m"; sourceTree = ""; }; + 268F647B22DF09D900A85AFD /* NSArray+InaTypedValue.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSArray+InaTypedValue.m"; sourceTree = ""; }; + 268F647C22DF09D900A85AFD /* NSArray+InaTypedValue.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSArray+InaTypedValue.h"; sourceTree = ""; }; + 268F647E22DF15F700A85AFD /* button-icon-nav-refresh.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "button-icon-nav-refresh.png"; sourceTree = ""; }; + 268F648022DF200300A85AFD /* button-icon-nav-location.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "button-icon-nav-location.png"; sourceTree = ""; }; + 26962B482306BA2F0026240A /* InaSymbols.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = InaSymbols.h; sourceTree = ""; }; + 26962B502306E8000026240A /* maps-icon-marker-pin-20@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-pin-20@2x.png"; sourceTree = ""; }; + 26962B512306E8000026240A /* maps-icon-marker-pin-30@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-pin-30@2x.png"; sourceTree = ""; }; + 26962B522306E8000026240A /* maps-icon-marker-pin-10@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-pin-10@2x.png"; sourceTree = ""; }; + 26962B532306E8000026240A /* maps-icon-marker-pin-40@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-pin-40@2x.png"; sourceTree = ""; }; + 26962B582306EB190026240A /* maps-icon-marker-pin-50@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-pin-50@2x.png"; sourceTree = ""; }; + 2696995922C38B4000B3290E /* AppKeys.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppKeys.m; sourceTree = ""; }; + 2696995A22C38B4000B3290E /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 2696995B22C38B4000B3290E /* AppKeys.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppKeys.h; sourceTree = ""; }; + 2696995C22C38B4000B3290E /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + 2696995F22C38C1D00B3290E /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 2696997E22C3A14A00B3290E /* MapView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MapView.h; sourceTree = ""; }; + 2696997F22C3A14A00B3290E /* MapView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MapView.m; sourceTree = ""; }; + 269F83E222D73E7400CC11A4 /* maps-icon-marker-circle-10@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-circle-10@2x.png"; sourceTree = ""; }; + 269F83E322D73E7400CC11A4 /* maps-icon-marker-circle-30@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-circle-30@2x.png"; sourceTree = ""; }; + 269F83E422D73E7400CC11A4 /* maps-icon-marker-circle-20@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-circle-20@2x.png"; sourceTree = ""; }; + 269F83E522D73E7400CC11A4 /* maps-icon-marker-circle-40@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "maps-icon-marker-circle-40@2x.png"; sourceTree = ""; }; + 269F83EA22D73EB400CC11A4 /* NSDate+UIUCUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+UIUCUtils.m"; sourceTree = ""; }; + 269F83EB22D73EB400CC11A4 /* UIColor+InaParse.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIColor+InaParse.h"; sourceTree = ""; }; + 269F83EC22D73EB400CC11A4 /* UIColor+InaParse.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIColor+InaParse.m"; sourceTree = ""; }; + 269F83ED22D73EB400CC11A4 /* NSDate+UIUCUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+UIUCUtils.h"; sourceTree = ""; }; + 269F83F022D744E200CC11A4 /* CGGeometry+InaUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CGGeometry+InaUtils.h"; sourceTree = ""; }; + 269F83F122D744E200CC11A4 /* CGGeometry+InaUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CGGeometry+InaUtils.m"; sourceTree = ""; }; + 269F83FA22D77E5500CC11A4 /* MapDirectionsController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MapDirectionsController.h; sourceTree = ""; }; + 269F83FB22D77E5500CC11A4 /* MapDirectionsController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MapDirectionsController.m; sourceTree = ""; }; + 26A170E822DDFB6500299773 /* button-icon-nav-next.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "button-icon-nav-next.png"; sourceTree = ""; }; + 26A170E922DDFB6500299773 /* button-icon-nav-prev.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "button-icon-nav-prev.png"; sourceTree = ""; }; + 26A170EA22DDFB6500299773 /* button-icon-nav-clear.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "button-icon-nav-clear.png"; sourceTree = ""; }; + 26B34034233112320031CF70 /* FlutterCompletion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FlutterCompletion.h; sourceTree = ""; }; + 26B340352331163A0031CF70 /* MapLocationPickerController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MapLocationPickerController.h; sourceTree = ""; }; + 26B340362331163A0031CF70 /* MapLocationPickerController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MapLocationPickerController.m; sourceTree = ""; }; + 26B340382331195C0031CF70 /* UILabel+InaMeasure.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UILabel+InaMeasure.h"; sourceTree = ""; }; + 26B340392331195C0031CF70 /* UILabel+InaMeasure.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UILabel+InaMeasure.m"; sourceTree = ""; }; + 26C07E2C247677A600E28D43 /* ExposurePlugin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExposurePlugin.h; sourceTree = ""; }; + 26C07E2D247677A600E28D43 /* ExposurePlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExposurePlugin.m; sourceTree = ""; }; + 26C90CEB2361CF480092E07F /* NSDictionary+InaPathKey.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+InaPathKey.h"; sourceTree = ""; }; + 26C90CEC2361CF480092E07F /* NSDictionary+InaPathKey.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+InaPathKey.m"; sourceTree = ""; }; + 26C90CEE2361D13E0092E07F /* NSDictionary+UIUCConfig.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+UIUCConfig.m"; sourceTree = ""; }; + 26C90CEF2361D13E0092E07F /* NSDictionary+UIUCConfig.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+UIUCConfig.h"; sourceTree = ""; }; + 26ECB3212487938C00479487 /* CommonCrypto+UIUCUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CommonCrypto+UIUCUtils.h"; sourceTree = ""; }; + 26ECB3222487938C00479487 /* CommonCrypto+UIUCUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CommonCrypto+UIUCUtils.m"; sourceTree = ""; }; + 26ECB3242487AD0900479487 /* Security+UIUCUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Security+UIUCUtils.h"; sourceTree = ""; }; + 26ECB3252487AD0900479487 /* Security+UIUCUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "Security+UIUCUtils.m"; sourceTree = ""; }; + 26FA06BC22CCA9B5003B78E4 /* NSDictionary+InaTypedValue.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+InaTypedValue.m"; sourceTree = ""; }; + 26FA06BD22CCA9B5003B78E4 /* NSDictionary+InaTypedValue.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+InaTypedValue.h"; sourceTree = ""; }; + 26FAB1C722EB3502008E987C /* NSDictionary+UIUCExplore.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+UIUCExplore.h"; sourceTree = ""; }; + 26FAB1C822EB3502008E987C /* NSDictionary+UIUCExplore.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+UIUCExplore.m"; sourceTree = ""; }; + 26FAB1CA22EB35C7008E987C /* NSDate+InaUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSDate+InaUtils.m"; sourceTree = ""; }; + 26FAB1CB22EB35C7008E987C /* NSDate+InaUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSDate+InaUtils.h"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 819274ECAE180D73781EE669 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AF2509313F75A25D855964F6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + B6201F5924E2D8080050F7DC /* GalleryPlugin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GalleryPlugin.h; sourceTree = ""; }; + B6201F5A24E2D8080050F7DC /* GalleryPlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GalleryPlugin.m; sourceTree = ""; }; + B681B2B723DEFD9E00093A67 /* NSData+InaHex.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSData+InaHex.h"; sourceTree = ""; }; + B681B2B823DEFD9E00093A67 /* NSData+InaHex.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSData+InaHex.m"; sourceTree = ""; }; + B6BDCD38242CCF5F002F7364 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + B6BDCD3F242CDC89002F7364 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + B6BDCD40242CDCA6002F7364 /* zh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh; path = zh.lproj/Localizable.strings; sourceTree = ""; }; + B6EA372223A7EF76001D78A5 /* Bluetooth+InaUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Bluetooth+InaUtils.h"; sourceTree = ""; }; + B6EA372323A7EF76001D78A5 /* Bluetooth+InaUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "Bluetooth+InaUtils.m"; sourceTree = ""; }; + CE7B5BCF89DF9B7AC1FD247B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + EBA3000FA94D4892DC3E0208 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BB7BF12F5D3356D25889D23A /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2696995822C38A7F00B3290E /* Resources */ = { + isa = PBXGroup; + children = ( + 269F83E122D73DC900CC11A4 /* Images */, + 26C0B694230584BF005F0A88 /* Assets */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + B6BDCD39242CCF5F002F7364 /* Localizable.strings */, + ); + path = Resources; + sourceTree = ""; + }; + 269F83E122D73DC900CC11A4 /* Images */ = { + isa = PBXGroup; + children = ( + 268F30ED22E5DD7900547FE1 /* travel-mode-walk@2x.png */, + 268F30EF22E5DD7900547FE1 /* travel-mode-bicycle@2x.png */, + 268F30F022E5DD7900547FE1 /* travel-mode-drive@2x.png */, + 268F30EE22E5DD7900547FE1 /* travel-mode-transit@2x.png */, + 268F30F522E5E47600547FE1 /* travel-mode-unknown@2x.png */, + 26A170EA22DDFB6500299773 /* button-icon-nav-clear.png */, + 268F648022DF200300A85AFD /* button-icon-nav-location.png */, + 26A170E822DDFB6500299773 /* button-icon-nav-next.png */, + 26A170E922DDFB6500299773 /* button-icon-nav-prev.png */, + 268F647E22DF15F700A85AFD /* button-icon-nav-refresh.png */, + 269F83E222D73E7400CC11A4 /* maps-icon-marker-circle-10@2x.png */, + 269F83E422D73E7400CC11A4 /* maps-icon-marker-circle-20@2x.png */, + 269F83E322D73E7400CC11A4 /* maps-icon-marker-circle-30@2x.png */, + 269F83E522D73E7400CC11A4 /* maps-icon-marker-circle-40@2x.png */, + 26962B522306E8000026240A /* maps-icon-marker-pin-10@2x.png */, + 26962B502306E8000026240A /* maps-icon-marker-pin-20@2x.png */, + 26962B512306E8000026240A /* maps-icon-marker-pin-30@2x.png */, + 26962B532306E8000026240A /* maps-icon-marker-pin-40@2x.png */, + 26962B582306EB190026240A /* maps-icon-marker-pin-50@2x.png */, + ); + path = Images; + sourceTree = ""; + }; + 26C0B694230584BF005F0A88 /* Assets */ = { + isa = PBXGroup; + children = ( + ); + path = Assets; + sourceTree = ""; + }; + 26FA06BB22CCA8B5003B78E4 /* Utils */ = { + isa = PBXGroup; + children = ( + B6EA372223A7EF76001D78A5 /* Bluetooth+InaUtils.h */, + B6EA372323A7EF76001D78A5 /* Bluetooth+InaUtils.m */, + 269F83F022D744E200CC11A4 /* CGGeometry+InaUtils.h */, + 269F83F122D744E200CC11A4 /* CGGeometry+InaUtils.m */, + 268F647822DF050400A85AFD /* CLLocationCoordinate2D+InaUtils.h */, + 268F647922DF050400A85AFD /* CLLocationCoordinate2D+InaUtils.m */, + 26FAB1CB22EB35C7008E987C /* NSDate+InaUtils.h */, + 26FAB1CA22EB35C7008E987C /* NSDate+InaUtils.m */, + B681B2B723DEFD9E00093A67 /* NSData+InaHex.h */, + B681B2B823DEFD9E00093A67 /* NSData+InaHex.m */, + 26FA06BD22CCA9B5003B78E4 /* NSDictionary+InaTypedValue.h */, + 26FA06BC22CCA9B5003B78E4 /* NSDictionary+InaTypedValue.m */, + 26C90CEB2361CF480092E07F /* NSDictionary+InaPathKey.h */, + 26C90CEC2361CF480092E07F /* NSDictionary+InaPathKey.m */, + 268F647C22DF09D900A85AFD /* NSArray+InaTypedValue.h */, + 268F647B22DF09D900A85AFD /* NSArray+InaTypedValue.m */, + 2626D94A22DC99C700F6BC2F /* NSString+InaJson.h */, + 2626D94922DC99C700F6BC2F /* NSString+InaJson.m */, + 268F30FE22E5E7BE00547FE1 /* NSUserDefaults+InaUtils.h */, + 268F30FF22E5E7BF00547FE1 /* NSUserDefaults+InaUtils.m */, + 269F83EB22D73EB400CC11A4 /* UIColor+InaParse.h */, + 269F83EC22D73EB400CC11A4 /* UIColor+InaParse.m */, + 26B340382331195C0031CF70 /* UILabel+InaMeasure.h */, + 26B340392331195C0031CF70 /* UILabel+InaMeasure.m */, + 26962B482306BA2F0026240A /* InaSymbols.h */, + ); + path = Utils; + sourceTree = ""; + }; + 26FAB1C622EB34A3008E987C /* UIUC */ = { + isa = PBXGroup; + children = ( + 26FAB1C722EB3502008E987C /* NSDictionary+UIUCExplore.h */, + 26FAB1C822EB3502008E987C /* NSDictionary+UIUCExplore.m */, + 26C90CEF2361D13E0092E07F /* NSDictionary+UIUCConfig.h */, + 26C90CEE2361D13E0092E07F /* NSDictionary+UIUCConfig.m */, + 269F83ED22D73EB400CC11A4 /* NSDate+UIUCUtils.h */, + 269F83EA22D73EB400CC11A4 /* NSDate+UIUCUtils.m */, + 26ECB3212487938C00479487 /* CommonCrypto+UIUCUtils.h */, + 26ECB3222487938C00479487 /* CommonCrypto+UIUCUtils.m */, + 26ECB3242487AD0900479487 /* Security+UIUCUtils.h */, + 26ECB3252487AD0900479487 /* Security+UIUCUtils.m */, + ); + path = UIUC; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + A4FE37309F85C78E8A871D27 /* Pods */, + F8180DF05DB99BBF17C34614 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 2696995C22C38B4000B3290E /* AppDelegate.h */, + 2696995A22C38B4000B3290E /* AppDelegate.m */, + 2696995B22C38B4000B3290E /* AppKeys.h */, + 2696995922C38B4000B3290E /* AppKeys.m */, + 266FA50123E0388B00F800F5 /* MapController.h */, + 266FA50223E0388B00F800F5 /* MapController.m */, + 269F83FA22D77E5500CC11A4 /* MapDirectionsController.h */, + 269F83FB22D77E5500CC11A4 /* MapDirectionsController.m */, + 26B340352331163A0031CF70 /* MapLocationPickerController.h */, + 26B340362331163A0031CF70 /* MapLocationPickerController.m */, + 2696997E22C3A14A00B3290E /* MapView.h */, + 2696997F22C3A14A00B3290E /* MapView.m */, + 2626D94C22DCB80000F6BC2F /* MapMarkerView.h */, + 2626D94D22DCB80000F6BC2F /* MapMarkerView.m */, + 26C07E2C247677A600E28D43 /* ExposurePlugin.h */, + 26C07E2D247677A600E28D43 /* ExposurePlugin.m */, + B6201F5924E2D8080050F7DC /* GalleryPlugin.h */, + B6201F5A24E2D8080050F7DC /* GalleryPlugin.m */, + 26B34034233112320031CF70 /* FlutterCompletion.h */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 2696995F22C38C1D00B3290E /* main.m */, + 97C147021CF9000F007C117D /* Info.plist */, + 2605FF52236C13A6002F71BE /* GoogleService-Info-Debug.plist */, + 2605FF53236C13A6002F71BE /* GoogleService-Info-Release.plist */, + 2668953022E1E074003CAB94 /* Runner.entitlements */, + 26FAB1C622EB34A3008E987C /* UIUC */, + 26FA06BB22CCA8B5003B78E4 /* Utils */, + 2696995822C38A7F00B3290E /* Resources */, + 97C146F11CF9000F007C117D /* Supporting Files */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + ); + name = "Supporting Files"; + sourceTree = ""; + }; + A4FE37309F85C78E8A871D27 /* Pods */ = { + isa = PBXGroup; + children = ( + 819274ECAE180D73781EE669 /* Pods-Runner.debug.xcconfig */, + EBA3000FA94D4892DC3E0208 /* Pods-Runner.release.xcconfig */, + AF2509313F75A25D855964F6 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + F8180DF05DB99BBF17C34614 /* Frameworks */ = { + isa = PBXGroup; + children = ( + CE7B5BCF89DF9B7AC1FD247B /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 6B05DA47C845612A0CC4C440 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + A6EAEE2D144BCA7DA3F58188 /* [CP] Embed Pods Frameworks */, + 263F851F234C9C8B00397B65 /* ShellScript */, + 7566F6C06D2127BA706144EC /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + ORGANIZATIONNAME = "The Chromium Authors"; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + DevelopmentTeam = UPV4CB4H6W; + LastSwiftMigration = 0910; + SystemCapabilities = { + com.apple.Push = { + enabled = 1; + }; + }; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + es, + zh, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 268F30F422E5DD7900547FE1 /* travel-mode-drive@2x.png in Resources */, + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 2605FF55236C13A6002F71BE /* GoogleService-Info-Release.plist in Resources */, + 268F30F322E5DD7900547FE1 /* travel-mode-bicycle@2x.png in Resources */, + 26962B572306E8000026240A /* maps-icon-marker-pin-40@2x.png in Resources */, + 269F83E722D73E7400CC11A4 /* maps-icon-marker-circle-30@2x.png in Resources */, + 268F30F122E5DD7900547FE1 /* travel-mode-walk@2x.png in Resources */, + 26962B552306E8000026240A /* maps-icon-marker-pin-30@2x.png in Resources */, + 268F647F22DF15F700A85AFD /* button-icon-nav-refresh.png in Resources */, + 268F648122DF200300A85AFD /* button-icon-nav-location.png in Resources */, + 269F83E622D73E7400CC11A4 /* maps-icon-marker-circle-10@2x.png in Resources */, + 269F83E922D73E7400CC11A4 /* maps-icon-marker-circle-40@2x.png in Resources */, + 26A170EC22DDFB6500299773 /* button-icon-nav-prev.png in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 269F83E822D73E7400CC11A4 /* maps-icon-marker-circle-20@2x.png in Resources */, + 26962B542306E8000026240A /* maps-icon-marker-pin-20@2x.png in Resources */, + 26962B592306EB190026240A /* maps-icon-marker-pin-50@2x.png in Resources */, + 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */, + 26A170ED22DDFB6500299773 /* button-icon-nav-clear.png in Resources */, + 268F30F222E5DD7900547FE1 /* travel-mode-transit@2x.png in Resources */, + 26962B562306E8000026240A /* maps-icon-marker-pin-10@2x.png in Resources */, + 268F30F622E5E47600547FE1 /* travel-mode-unknown@2x.png in Resources */, + 26A170EB22DDFB6500299773 /* button-icon-nav-next.png in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 2605FF54236C13A6002F71BE /* GoogleService-Info-Debug.plist in Resources */, + B6BDCD37242CCF5F002F7364 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 263F851F234C9C8B00397B65 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "./build.sh\n"; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 6B05DA47C845612A0CC4C440 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7566F6C06D2127BA706144EC /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + }; + A6EAEE2D144BCA7DA3F58188 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 269F83EF22D73EB400CC11A4 /* UIColor+InaParse.m in Sources */, + B6201F5B24E2D8080050F7DC /* GalleryPlugin.m in Sources */, + 268F310022E5E7BF00547FE1 /* NSUserDefaults+InaUtils.m in Sources */, + 26C90CF02361D13E0092E07F /* NSDictionary+UIUCConfig.m in Sources */, + 269F83F222D744E200CC11A4 /* CGGeometry+InaUtils.m in Sources */, + B6EA372423A7EF76001D78A5 /* Bluetooth+InaUtils.m in Sources */, + 269F83FC22D77E5500CC11A4 /* MapDirectionsController.m in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 268F647D22DF09D900A85AFD /* NSArray+InaTypedValue.m in Sources */, + 2696995D22C38B4000B3290E /* AppKeys.m in Sources */, + 268F647A22DF050400A85AFD /* CLLocationCoordinate2D+InaUtils.m in Sources */, + 2626D94E22DCB80000F6BC2F /* MapMarkerView.m in Sources */, + 269F83EE22D73EB400CC11A4 /* NSDate+UIUCUtils.m in Sources */, + B681B2B923DEFD9E00093A67 /* NSData+InaHex.m in Sources */, + 26FA06BE22CCA9B5003B78E4 /* NSDictionary+InaTypedValue.m in Sources */, + 2626D94B22DC99C800F6BC2F /* NSString+InaJson.m in Sources */, + 26C07E2E247677A600E28D43 /* ExposurePlugin.m in Sources */, + 26C90CED2361CF480092E07F /* NSDictionary+InaPathKey.m in Sources */, + 26ECB3262487AD0900479487 /* Security+UIUCUtils.m in Sources */, + 26FAB1C922EB3502008E987C /* NSDictionary+UIUCExplore.m in Sources */, + 266FA50323E0388B00F800F5 /* MapController.m in Sources */, + 2696996022C38C1D00B3290E /* main.m in Sources */, + 2696995E22C38B4000B3290E /* AppDelegate.m in Sources */, + 26B3403A2331195C0031CF70 /* UILabel+InaMeasure.m in Sources */, + 26FAB1CC22EB35C7008E987C /* NSDate+InaUtils.m in Sources */, + 2696998022C3A14A00B3290E /* MapView.m in Sources */, + 26B340372331163A0031CF70 /* MapLocationPickerController.m in Sources */, + 26ECB3232487938C00479487 /* CommonCrypto+UIUCUtils.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; + B6BDCD39242CCF5F002F7364 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + B6BDCD38242CCF5F002F7364 /* en */, + B6BDCD3F242CDC89002F7364 /* es */, + B6BDCD40242CDCA6002F7364 /* zh */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = UPV4CB4H6W; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = edu.illinois.covid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 4.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = UPV4CB4H6W; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = edu.illinois.covid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 4.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = UPV4CB4H6W; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = edu.illinois.covid; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 4.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..6b30c745 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,10 @@ + + + + + BuildSystemType + Original + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..ad6250a1 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..ee73d111 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDETemplateMacros.plist b/ios/Runner.xcworkspace/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 00000000..a96f0b50 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,27 @@ + + + + + FILEHEADER + // ___FILENAME___ +// ___PACKAGENAME___ +// +// Created by ___FULLUSERNAME___ on ___DATE___. +// ___COPYRIGHT___ +// + COPYRIGHT + Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner/AppDelegate.h b/ios/Runner/AppDelegate.h new file mode 100644 index 00000000..76c46d7b --- /dev/null +++ b/ios/Runner/AppDelegate.h @@ -0,0 +1,31 @@ +// +// AppDelegate.h +// Runner +// +// Created by Mihail Varbanov on 2/19/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import + +@class FlutterMethodChannel; + +@interface AppDelegate : FlutterAppDelegate +@property (nonatomic, readonly) FlutterMethodChannel* flutterMethodChannel; +@property (nonatomic, readonly) NSDictionary* keys; ++ (instancetype)sharedInstance; +@end + diff --git a/ios/Runner/AppDelegate.m b/ios/Runner/AppDelegate.m new file mode 100644 index 00000000..79cf3afb --- /dev/null +++ b/ios/Runner/AppDelegate.m @@ -0,0 +1,1430 @@ +// +// AppDelegate.m +// Runner +// +// Created by Mihail Varbanov on 2/19/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "AppDelegate.h" +#import "GeneratedPluginRegistrant.h" +#import "AppKeys.h" +#import "MapView.h" +#import "MapController.h" +#import "MapDirectionsController.h" +#import "MapLocationPickerController.h" +#import "ExposurePlugin.h" +#import "GalleryPlugin.h" + +#import "NSArray+InaTypedValue.h" +#import "NSDictionary+InaTypedValue.h" +#import "NSDictionary+UIUCConfig.h" +#import "CGGeometry+InaUtils.h" +#import "UIColor+InaParse.h" +#import "Bluetooth+InaUtils.h" + +#import +#import +#import +#import +#import + +#import +#import +#import +#import + +static NSString *const kFIRMessagingFCMTokenNotification = @"com.firebase.iid.notif.fcm-token"; + +@interface RootNavigationController : UINavigationController +@end + +@interface LaunchScreenView : UIView +@end + +UIInterfaceOrientation _interfaceOrientationFromString(NSString *value); +NSString* _interfaceOrientationToString(UIInterfaceOrientation value); + +UIInterfaceOrientation _interfaceOrientationFromMask(UIInterfaceOrientationMask value); +UIInterfaceOrientationMask _interfaceOrientationToMask(UIInterfaceOrientation value); + +@interface AppDelegate() { +} + +// Flutter +@property (nonatomic) UINavigationController *navigationViewController; +@property (nonatomic) FlutterViewController *flutterViewController; +@property (nonatomic) FlutterMethodChannel *flutterMethodChannel; + +// Launch View +@property (nonatomic) UIView *launchScreenView; + +// PassKit +@property (nonatomic) PKAddPassesViewController *passViewController; +@property (nonatomic) FlutterResult passFlutterResult; + +// BlinkId +@property (nonatomic) bool blinkSDKInitialized; +@property (nonatomic) MBBlinkIdCombinedRecognizer *blinkCombinedRecognizer; +@property (nonatomic) MBPassportRecognizer *blinkPassportRecognizer; +@property (nonatomic) UIViewController *blinkRecognizerRunnerViewController; +@property (nonatomic) FlutterResult blinkFlutterResult; + +// Init Keys +@property (nonatomic) NSDictionary* keys; + +// Interface Orientations +@property (nonatomic) NSSet *supportedInterfaceOrientations; +@property (nonatomic) UIInterfaceOrientation preferredInterfaceOrientation; + +// Location Services +@property (nonatomic) CLLocationManager *clLocationManager; +@property (nonatomic) NSMutableSet *locationFlutterResults; + +// Bluetooth Services +@property (nonatomic) CBPeripheralManager *peripheralManager; +@property (nonatomic) NSMutableSet *bluetoothFlutterResults; +@end + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + + __weak typeof(self) weakSelf = self; + +// Initialize Google Maps SDK +// [GMSServices provideAPIKey:kGoogleAPIKey]; + +// Initialize Maps Indoors SDK +// [MapsIndoors provideAPIKey:kMapsIndoorsAPIKey googleAPIKey:kGoogleAPIKey]; + +// Initialize MicroBlink SDK +// [MBMicroblinkSDK.sharedInstance setLicenseKey:kMicroBlinkLicenseKey]; + + + // Initialize Firebase SDK + [FIRApp configure]; + [FIRMessaging messaging].delegate = self; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceiveFCMTokenNotification:) name:kFIRMessagingFCMTokenNotification object:nil]; + + // Initialize Flutter plugins + [GeneratedPluginRegistrant registerWithRegistry:self]; + + // Setup MapPlugin + NSObject*registrar = [self registrarForPlugin:@"MapPlugin"]; + MapViewFactory *factory = [[MapViewFactory alloc] initWithMessenger:registrar.messenger]; + [registrar registerViewFactory:factory withId:@"mapview"]; + + // Setup ExposurePlugin + [ExposurePlugin registerWithRegistrar:[self registrarForPlugin:@"ExposurePlugin"]]; + + // Setup ExposurePlugin + [GalleryPlugin registerWithRegistrar:[self registrarForPlugin:@"GalleryPlugin"]]; + + // Setup supported & preffered orientation + _preferredInterfaceOrientation = UIInterfaceOrientationPortrait; + _supportedInterfaceOrientations = [NSSet setWithObject:@(_preferredInterfaceOrientation)]; + + // Setup root ViewController + UIViewController *rootViewController = self.window.rootViewController; + _flutterViewController = [rootViewController isKindOfClass:[FlutterViewController class]] ? (FlutterViewController*)rootViewController : nil; + + _navigationViewController = [[RootNavigationController alloc] initWithRootViewController:rootViewController]; + _navigationViewController.navigationBarHidden = YES; + _navigationViewController.delegate = self; + + _navigationViewController.navigationBar.translucent = NO; + _navigationViewController.navigationBar.barTintColor = [UIColor inaColorWithHex:@"13294b"]; + _navigationViewController.navigationBar.tintColor = [UIColor whiteColor]; + _navigationViewController.navigationBar.titleTextAttributes = @{ + NSForegroundColorAttributeName : [UIColor whiteColor] + }; + + [self setupLaunchScreen]; + + self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; + self.window.rootViewController = _navigationViewController; + [self.window makeKeyAndVisible]; + + // Listen Method Channel + _flutterMethodChannel = [FlutterMethodChannel methodChannelWithName:kFlutterMetodChannelName binaryMessenger:_flutterViewController.binaryMessenger]; + [_flutterMethodChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { + [weakSelf handleFlutterAPIFromCall:call result:result]; + }]; + + // Push Notifications + [UNUserNotificationCenter currentNotificationCenter].delegate = self; + [self queryNotificationsAuthorizationStatusWithCompletionHandler:^(bool authorized){ + if (authorized) { + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf registerForRemoteNotifications]; + }); + } + }]; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +- (void)applicationWillTerminate:(UIApplication *)application { + + // Push Notifications + if (UNUserNotificationCenter.currentNotificationCenter.delegate == self) { + UNUserNotificationCenter.currentNotificationCenter.delegate = nil; + } + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + + [super applicationWillTerminate:application]; +} + ++ (instancetype)sharedInstance { + id sharedInstance = [UIApplication sharedApplication].delegate; + return [sharedInstance isKindOfClass:self] ? sharedInstance : nil; +} + +#pragma mark LifeCycle + +-(void)applicationDidEnterForeground:(UIApplication *)application{ + NSLog(@"applicationDidEnterForeground:"); +} + +-(void)applicationDidEnterBackground:(UIApplication *)application{ + NSLog(@"applicationDidEnterBackground:"); +} + +#pragma mark Launch Screen + +- (void)setupLaunchScreen { + + if (_launchScreenView != nil) { + [_launchScreenView removeFromSuperview]; + } + + UIView *parentView = _navigationViewController.viewControllers.firstObject.view; + _launchScreenView = [[LaunchScreenView alloc] initWithFrame:CGRectMake(0, 0, parentView.bounds.size.width, parentView.bounds.size.height)]; + _launchScreenView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [parentView addSubview:_launchScreenView]; +} + +- (void)removeLaunchScreen { + if (_launchScreenView != nil) { + __weak typeof(self) weakSelf = self; + [UIView animateWithDuration:0.5 animations:^{ + weakSelf.launchScreenView.alpha = 0; + } completion:^(BOOL finished) { + [weakSelf.launchScreenView removeFromSuperview]; + weakSelf.launchScreenView = nil; + }]; + } +} + +#pragma mark Flutter APIs + +- (void)handleFlutterAPIFromCall:(FlutterMethodCall*)call result:(FlutterResult)result { + NSDictionary *parameters = [call.arguments isKindOfClass:[NSDictionary class]] ? call.arguments : nil; + if ([call.method isEqualToString:@"init"]) { + [self handleInitWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"directions"]) { + [self handleDirectionsWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"pickLocation"]) { + [self handlePickLocationWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"map"]) { + [self handleMapWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"showNotification"]) { + [self handleShowNotificationWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"dismissSafariVC"]) { + [self handleDismissSafariVCWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"dismissLaunchScreen"]) { + [self handleDismissLaunchScreenWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"firebaseInfo"]) { + [self handleFirebaseInfoWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"notifications_authorization"]) { + [self handleNotificationsWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"location_services_permission"]) { + [self handleLocationServicesWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"bluetooth_authorization"]) { + [self handleBluetoothAuthorizationWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"addToWallet"]) { + [self handleAddToWalletWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"microBlinkScan"]) { + [self handleMicroBlinkScanWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"deviceId"]) { + [self handleDeviceIdWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"healthRSAPrivateKey"]) { + [self handleHealthRSAPrivateKeyWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"enabledOrientations"]) { + [self handleEnabledOrientationsWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"barcode"]) { + [self handleBarcodeWithParameters:parameters result:result]; + } + else if ([call.method isEqualToString:@"test"]) { + [self handleTestWithParameters:parameters result:result]; + } +} + +- (void)handleInitWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + self.keys = [parameters inaDictForKey:@"keys"]; + + // Initialize Google Maps SDK + NSString *googleMapsAPIKey = [_keys uiucConfigStringForPathKey:@"google.maps.api_key"]; + if (0 < googleMapsAPIKey.length) { + [GMSServices provideAPIKey:googleMapsAPIKey]; + } + + // Initialize Maps Indoors SDK + NSString *mapsIndoorsAPIKey = [_keys uiucConfigStringForPathKey:@"mapsindoors.api_key"]; + if ((0 < mapsIndoorsAPIKey.length) && (0 < googleMapsAPIKey.length)) { + [MapsIndoors provideAPIKey:mapsIndoorsAPIKey googleAPIKey:googleMapsAPIKey]; + } + + // Initialize MicroBlink SDK + /*NSString *microBlinkLicenseKey = [_keys uiucConfigStringForPathKey:@"microblink.blink_id.license_key.ios"]; + if (0 < microBlinkLicenseKey.length) { + @try { + [MBMicroblinkSDK.sharedInstance setLicenseKey:microBlinkLicenseKey]; + _blinkSDKInitialized = YES; + } + @catch(NSException *e) { NSLog(@"%@", e); } + }*/ + + result(@(YES)); +} + +- (void)handleDirectionsWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + MapDirectionsController *directionsController = [[MapDirectionsController alloc] initWithParameters:parameters completionHandler:^(id returnValue) { + result(returnValue); + }]; + [self.navigationViewController pushViewController:directionsController animated:YES]; +} + +- (void)handlePickLocationWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + MapLocationPickerController *pickLocationController = [[MapLocationPickerController alloc] initWithParameters:parameters completionHandler:^(id returnValue) { + result(returnValue); + }]; + [self.navigationViewController pushViewController:pickLocationController animated:YES]; +} + +- (void)handleMapWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + MapController *mapController = [[MapController alloc] initWithParameters:parameters completionHandler:^(id returnValue) { + result(returnValue); + }]; + [self.navigationViewController pushViewController:mapController animated:YES]; +} + +- (void)handleShowNotificationWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; + content.title = [parameters inaStringForKey:@"title"] ?: [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]; + content.subtitle = [parameters inaStringForKey:@"subtitle"]; + content.body = [parameters inaStringForKey:@"body"]; + content.sound = [parameters inaBoolForKey:@"sound" defaults:true] ? [UNNotificationSound defaultSound] : nil; + + UNTimeIntervalNotificationTrigger* trigger = [UNTimeIntervalNotificationTrigger + triggerWithTimeInterval:1 repeats:NO]; + + UNNotificationRequest* request = [UNNotificationRequest + requestWithIdentifier:@"Poll_Created" content:content trigger:trigger]; + + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) { + if (error != nil) { + NSLog(@"%@", error.localizedDescription); + } + }]; +} + +- (void)handleDismissSafariVCWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + UIViewController *presentedController = self.flutterViewController.presentedViewController; + if ([presentedController isKindOfClass:[SFSafariViewController class]]) { + [presentedController dismissViewControllerAnimated:YES completion:^{ + result(@(YES)); + }]; + } + else { + result(@(NO)); + } +} + +- (void)handleDismissLaunchScreenWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + [self removeLaunchScreen]; +} + +- (void)handleFirebaseInfoWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + FIRApp *firApp = [FIRApp defaultApp]; + FIROptions *options = (firApp != nil) ? [firApp options] : nil; + NSString *projectID = (options != nil) ? [options projectID] : nil; + result(projectID); +} + +- (void)handleNotificationsWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + NSString *method = [parameters inaStringForKey:@"method"]; + if ([method isEqualToString:@"query"]) { + [self queryNotificationsAuthorizationWithFlutterResult:result]; + } + else if ([method isEqualToString:@"request"]) { + [self requestNotificationsAuthorizationWithFlutterResult:result]; + } + else { + result(nil); + } +} + +- (void)handleLocationServicesWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + NSString *method = [parameters inaStringForKey:@"method"]; + if ([method isEqualToString:@"query"]) { + [self queryLocationServicesPermisionWithFlutterResult:result]; + } + else if ([method isEqualToString:@"request"]) { + [self requestLocationServicesPermisionWithFlutterResult:result]; + } + else { + result(nil); + } +} + +- (void)handleBluetoothAuthorizationWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + NSString *method = [parameters inaStringForKey:@"method"]; + if ([method isEqualToString:@"query"]) { + [self queryBluetoothAuthorizationWithFlutterResult:result]; + } + else if ([method isEqualToString:@"request"]) { + [self requestBluetoothAuthorizationWithFlutterResult:result]; + } + else { + result(nil); + } +} + + +- (void)handleAddToWalletWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + NSString *base64CardData = [parameters inaStringForKey:@"cardBase64Data"]; + NSData *cardData = [[NSData alloc] initWithBase64EncodedString:base64CardData options:0]; + [self addPassToWallet:cardData result:result]; + result(nil); +} + +- (void)handleMicroBlinkScanWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + [self microBlinkScanWithParameters:parameters result:result]; +} + + +- (void)handleDeviceIdWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + result(self.deviceUUID.UUIDString); +} + +- (void)handleHealthRSAPrivateKeyWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + result([self healthRSAPrivateKeyWithParameters:parameters]); +} + +- (void)handleTestWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + result(nil); +} + +#pragma mark Barcode + +- (void)handleBarcodeWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + NSString *content = [parameters inaStringForKey:@"content"]; + NSString *formatName = [parameters inaStringForKey:@"format"]; + int width = [parameters inaIntForKey:@"width"]; + int height = [parameters inaIntForKey:@"height"]; + + ZXBarcodeFormat format = 0; + if ([formatName isEqualToString:@"aztec"]) { + format = kBarcodeFormatAztec; + } else if ([formatName isEqualToString:@"codabar"]) { + format = kBarcodeFormatCodabar; + } else if ([formatName isEqualToString:@"code39"]) { + format = kBarcodeFormatCode39; + } else if ([formatName isEqualToString:@"code93"]) { + format = kBarcodeFormatCode93; + } else if ([formatName isEqualToString:@"code128"]) { + format = kBarcodeFormatCode128; + } else if ([formatName isEqualToString:@"dataMatrix"]) { + format = kBarcodeFormatDataMatrix; + } else if ([formatName isEqualToString:@"ean8"]) { + format = kBarcodeFormatEan8; + } else if ([formatName isEqualToString:@"ean13"]) { + format = kBarcodeFormatEan13; + } else if ([formatName isEqualToString:@"itf"]) { + format = kBarcodeFormatITF; + } else if ([formatName isEqualToString:@"maxiCode"]) { + format = kBarcodeFormatMaxiCode; + } else if ([formatName isEqualToString:@"pdf417"]) { + format = kBarcodeFormatPDF417; + } else if ([formatName isEqualToString:@"qrCode"]) { + format = kBarcodeFormatQRCode; + } else if ([formatName isEqualToString:@"rss14"]) { + format = kBarcodeFormatRSS14; + } else if ([formatName isEqualToString:@"rssExpanded"]) { + format = kBarcodeFormatRSSExpanded; + } else if ([formatName isEqualToString:@"upca"]) { + format = kBarcodeFormatUPCA; + } else if ([formatName isEqualToString:@"upce"]) { + format = kBarcodeFormatUPCE; + } else if ([formatName isEqualToString:@"upceanExtension"]) { + format = kBarcodeFormatUPCEANExtension; + } + + NSError *error = nil; + UIImage *image = nil; + ZXEncodeHints *hints = [ZXEncodeHints hints]; + hints.margin = @(0); + ZXBitMatrix* matrix = [[ZXMultiFormatWriter writer] encode:content format:format width:width height:height hints:hints error:&error]; + if (matrix != nil) { + CGImageRef imageRef = CGImageRetain([[ZXImage imageWithMatrix:matrix] cgimage]); + image = [UIImage imageWithCGImage:imageRef]; + CGImageRelease(imageRef); + } + + NSData *imageData = (image != nil) ? UIImagePNGRepresentation(image) : nil; + NSString *base64ImageData = (imageData != nil) ? [imageData base64EncodedStringWithOptions:0] : nil; + result(base64ImageData); +} + +/* +//#import "NKDBarcodeFramework.h" +#import "NKDBarcode.h" +#import "NKDBarcodeOffscreenView.h" +#import "NKDCode39Barcode.h" +#import "NKDExtendedCode39Barcode.h" +#import "NKDInterleavedTwoOfFiveBarcode.h" +#import "NKDModifiedPlesseyBarcode.h" +#import "NKDPostnetBarcode.h" +#import "NKDUPCABarcode.h" +#import "NKDModifiedPlesseyHexBarcode.h" +#import "NKDIndustrialTwoOfFiveBarcode.h" +#import "NKDEAN13Barcode.h" +#import "NKDCode128Barcode.h" +#import "NKDCodabarBarcode.h" +#import "UIImage-NKDBarcode.h" +#import "UIImage-Normalize.h" +#import "NKDUPCEBarcode.h" +#import "NKDEAN8Barcode.h" +#import "NKDRoyalMailBarcode.h" +#import "NKDPlanetBarcode.h" + +- (void)handleBarcodeWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + NSString *content = [parameters inaStringForKey:@"content"]; + NSString *formatName = [parameters inaStringForKey:@"format"]; + float barWidth = [parameters inaFloatForKey:@"barWidth"]; + float height = [parameters inaFloatForKey:@"height"]; + + NKDBarcode *format = nil; + if ([formatName isEqualToString:@"codabar"]) { + format = [NKDCodabarBarcode alloc]; + } else if ([formatName isEqualToString:@"code39"]) { + format = [NKDCode39Barcode alloc]; + } else if ([formatName isEqualToString:@"code128"]) { + format = [NKDCode128Barcode alloc]; + } else if ([formatName isEqualToString:@"upca"]) { + format = [NKDUPCABarcode alloc]; + } else if ([formatName isEqualToString:@"upce"]) { + format = [NKDUPCEBarcode alloc]; + } else if ([formatName isEqualToString:@"ean13"]) { + format = [NKDEAN13Barcode alloc]; + } else if ([formatName isEqualToString:@"ean8"]) { + format = [NKDEAN8Barcode alloc]; + + } else if ([formatName isEqualToString:@"code93ext"]) { + format = [NKDExtendedCode39Barcode alloc]; + } else if ([formatName isEqualToString:@"plesseyMod"]) { + format = [NKDModifiedPlesseyBarcode alloc]; + } else if ([formatName isEqualToString:@"plesseyModHex"]) { + format = [NKDModifiedPlesseyHexBarcode alloc]; + } else if ([formatName isEqualToString:@"postnet"]) { + format = [NKDPostnetBarcode alloc]; + } else if ([formatName isEqualToString:@"industrial"]) { + format = [NKDIndustrialTwoOfFiveBarcode alloc]; + } + + format = [format initWithContent:content printsCaption:NO andBarWidth:barWidth andHeight:height andFontSize:0 andCheckDigit:(char)-1]; + + UIImage *image = (format != nil) ? [UIImage imageFromBarcode:format] : nil; // ..or as a less accu + NSData *imageData = (image != nil) ? UIImagePNGRepresentation(image) : nil; + NSString *base64ImageData = (imageData != nil) ? [imageData base64EncodedStringWithOptions:0] : nil; + result(base64ImageData); +} +*/ + +#pragma mark Orientations + +- (void)handleEnabledOrientationsWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + + NSMutableArray *resultList = [[NSMutableArray alloc] init]; + if (_preferredInterfaceOrientation != UIInterfaceOrientationUnknown) { + [resultList addObject:_interfaceOrientationToString(_preferredInterfaceOrientation)]; + } + for (NSNumber *supportedOrienation in _supportedInterfaceOrientations) { + if (supportedOrienation.intValue != _preferredInterfaceOrientation) { + [resultList addObject:_interfaceOrientationToString(supportedOrienation.intValue)]; + } + } + + NSArray *orientationsList = [parameters inaArrayForKey:@"orientations"]; + if (orientationsList != nil) { + UIInterfaceOrientation preferredInterfaceOrientation = UIInterfaceOrientationUnknown; + NSMutableSet *supportedOrientations = [[NSMutableSet alloc] init]; + for (NSString *orientationString in orientationsList) { + UIInterfaceOrientation orientation = ([orientationString isKindOfClass:[NSString class]]) ? _interfaceOrientationFromString(orientationString) : UIInterfaceOrientationUnknown; + if (orientation != UIInterfaceOrientationUnknown) { + [supportedOrientations addObject:@(orientation)]; + if (preferredInterfaceOrientation == UIInterfaceOrientationUnknown) { + preferredInterfaceOrientation = orientation; + } + } + } + + if ((preferredInterfaceOrientation != UIInterfaceOrientationUnknown) && (_preferredInterfaceOrientation != preferredInterfaceOrientation)) { + _preferredInterfaceOrientation = preferredInterfaceOrientation; + } + + if ((0 < supportedOrientations.count) && ![_supportedInterfaceOrientations isEqualToSet:supportedOrientations]) { + _supportedInterfaceOrientations = supportedOrientations; + UIDeviceOrientation currentOrientation = [[UIDevice currentDevice] orientation]; + if (![_supportedInterfaceOrientations containsObject:@(currentOrientation)]) { + [[UIDevice currentDevice] setValue:@(_preferredInterfaceOrientation) forKey:@"orientation"]; + } + } + } + + result(resultList); + +} + +/* +[_navigationViewController.topViewController presentViewController:[[UIViewController alloc] init] animated:NO completion:^{ + [NSTimer scheduledTimerWithTimeInterval:0.1 target:self selector:@selector(closeForceOrientationConrtoller:) userInfo:nil repeats:NO]; +}]; +- (void)closeForceOrientationConrtoller:(NSTimer*)timer { + [_navigationViewController.topViewController dismissViewControllerAnimated:NO completion:nil]; +} +*/ + +#pragma mark Push Notifications + +- (void)queryNotificationsAuthorizationWithFlutterResult:(FlutterResult)result { + [self queryNotificationsAuthorizationStatusWithCompletionHandler:^(bool authorized){ + result(authorized ? @(YES) : @(NO)); + }]; +} + +- (void)queryNotificationsAuthorizationStatusWithCompletionHandler:(void(^)(bool authorized)) completionHandler { + [UNUserNotificationCenter.currentNotificationCenter getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* settings) { + completionHandler((settings.authorizationStatus != UNAuthorizationStatusNotDetermined) && (settings.authorizationStatus != UNAuthorizationStatusDenied)); + }]; +} + +- (void)requestNotificationsAuthorizationWithFlutterResult:(FlutterResult)result { + __weak typeof(self) weakSelf = self; + [UNUserNotificationCenter.currentNotificationCenter getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* settings) { + if (settings.authorizationStatus == UNAuthorizationStatusDenied) { + result(@(NO)); + } + else { + UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionBadge | UNAuthorizationOptionSound; + [UNUserNotificationCenter.currentNotificationCenter requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError * _Nullable error){ + dispatch_async(dispatch_get_main_queue(), ^{ + result(granted ? @(YES) : @(NO)); + if (granted) { + [weakSelf registerForRemoteNotifications]; + } + }); + }]; + } + }]; +} + +- (void)registerForRemoteNotifications { + [UNUserNotificationCenter currentNotificationCenter].delegate = self; + [[UIApplication sharedApplication] registerForRemoteNotifications]; +} + +- (void)application:(UIApplication*)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken { + NSLog(@"UIApplication didRegisterForRemoteNotificationsWithDeviceToken: %@", [NSString stringWithFormat:@"%@", deviceToken]); + [FIRMessaging messaging].APNSToken = deviceToken; +} + +- (void)application:(UIApplication*)application didFailToRegisterForRemoteNotificationsWithError:(NSError*)error { + NSLog(@"UIApplication didFailToRegisterForRemoteNotificationsWithError: %@", error); +} + +- (void)processPushNotification:(NSDictionary*)userInfo { + [[FIRMessaging messaging] appDidReceiveMessage:userInfo]; +} + +#pragma mark LocationServices + +- (void)queryLocationServicesPermisionWithFlutterResult:(FlutterResult)result { + NSString *status = [CLLocationManager locationServicesEnabled] ? + [self.class locationServicesPermisionFromAuthorizationStatus:[CLLocationManager authorizationStatus]] : + @"disabled"; + result(status); +} + ++ (NSString*)locationServicesPermisionFromAuthorizationStatus:(CLAuthorizationStatus)authorizationStatus { + switch (authorizationStatus) { + case kCLAuthorizationStatusNotDetermined: return @"not_determined"; + case kCLAuthorizationStatusRestricted: return @"denied"; + case kCLAuthorizationStatusDenied: return @"denied"; + case kCLAuthorizationStatusAuthorizedAlways: return @"allowed"; + case kCLAuthorizationStatusAuthorizedWhenInUse: return @"allowed"; + } + return nil; +} + +- (void)requestLocationServicesPermisionWithFlutterResult:(FlutterResult)flutterResult { + if ([CLLocationManager locationServicesEnabled]) { + CLAuthorizationStatus status = [CLLocationManager authorizationStatus]; + if (status == kCLAuthorizationStatusNotDetermined) { + if (_locationFlutterResults == nil) { + _locationFlutterResults = [[NSMutableSet alloc] init]; + } + [_locationFlutterResults addObject:flutterResult]; + + if (_clLocationManager == nil) { + _clLocationManager = [[CLLocationManager alloc] init]; + _clLocationManager.delegate = self; + [_clLocationManager requestAlwaysAuthorization]; + } + } + else { + flutterResult([self.class locationServicesPermisionFromAuthorizationStatus:status]); + } + } + else { + flutterResult([self.class locationServicesPermisionFromAuthorizationStatus:kCLAuthorizationStatusRestricted]); + } +} + +#pragma mark CLLocationManagerDelegate + +- (void)locationManager:(CLLocationManager*)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status { + + if (status != kCLAuthorizationStatusNotDetermined) { + _clLocationManager.delegate = nil; + _clLocationManager = nil; + + NSSet *flutterResults = _locationFlutterResults; + _locationFlutterResults = nil; + + for(FlutterResult flutterResult in flutterResults) { + flutterResult([self.class locationServicesPermisionFromAuthorizationStatus:status]); + } + } +} + +#pragma mark Bluetooth Authorization + +- (void)queryBluetoothAuthorizationWithFlutterResult:(FlutterResult)flutterResult { + flutterResult(InaBluetoothAuthorizationStatusToString(InaBluetooth.peripheralAuthorizationStatus)); +} + +- (void)requestBluetoothAuthorizationWithFlutterResult:(FlutterResult)flutterResult { + if (InaBluetooth.peripheralAuthorizationStatus == InaBluetoothAuthorizationStatusNotDetermined) { + if (_bluetoothFlutterResults == nil) { + _bluetoothFlutterResults = [[NSMutableSet alloc] init]; + } + [_bluetoothFlutterResults addObject:flutterResult]; + if (_peripheralManager == nil) { + _peripheralManager = [[CBPeripheralManager alloc] initWithDelegate:self queue:nil]; + } + } + else { + flutterResult(InaBluetoothAuthorizationStatusToString(InaBluetooth.peripheralAuthorizationStatus)); + } +} + +- (void)didBluetoothServicesPermision { + _peripheralManager.delegate = nil; + _peripheralManager = nil; + + NSSet *flutterResults = _bluetoothFlutterResults; + _bluetoothFlutterResults = nil; + + NSString *status = InaBluetoothAuthorizationStatusToString(InaBluetooth.peripheralAuthorizationStatus); + for (FlutterResult flutterResult in flutterResults) { + flutterResult(status); + } +} + +#pragma mark CBPeripheralManagerDelegate + +- (void)peripheralManagerDidUpdateState:(CBPeripheralManager*)peripheral { + [self didBluetoothServicesPermision]; +} + +#pragma mark Deep Links + +- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options { + NSLog(@"UIApplication handleOpenURL: %@", url.absoluteString); + if ([super respondsToSelector:@selector(application:openURL:options:)]) { + return [super application:app openURL:url options:options]; + } + else { + return FALSE; + } +} + +#pragma mark PassKit + +- (void)addPassToWallet:(NSData*)passData result:(FlutterResult)result { + if (_passViewController != nil) { + NSLog(@"PassKit: currently adding a pass"); + result(@(NO)); + } + else if (!PKAddPassesViewController.canAddPasses) { + NSLog(@"PassKit: cannot add passes"); + result(@(NO)); + } + else { + NSError *error = nil; + PKPass *pass = [[PKPass alloc] initWithData:passData error:&error]; + if ((pass != nil) && (error == nil)) { + PKAddPassesViewController *passViewController = [[PKAddPassesViewController alloc] initWithPass:pass]; + if (passViewController != nil) { + __weak typeof(self) weakSelf = self; + passViewController.delegate = self; + [_navigationViewController.topViewController presentViewController:passViewController animated:YES completion:^{ + weakSelf.passFlutterResult = result; + weakSelf.passViewController = passViewController; + }]; + } + else { + NSLog(@"PassKit: failed to create add pass controller"); + result(@(NO)); + } + } + else { + NSLog(@"PassKit: failed to create pass: %@", error.localizedDescription); + result(@(NO)); + } + } +} + +#pragma mark MicroBlink + +- (void)microBlinkScanWithParameters:(NSDictionary*)parameters result:(FlutterResult)result { + if (_blinkFlutterResult != nil) { + NSLog(@"BlinkID: currently scanning"); + result(nil); + } + else { + if (!_blinkSDKInitialized) { + NSString *microBlinkLicenseKey = [_keys uiucConfigStringForPathKey:@"microblink.blink_id.license_key.ios"]; + if (0 < microBlinkLicenseKey.length) { + @try { + [MBMicroblinkSDK.sharedInstance setLicenseKey:microBlinkLicenseKey]; + _blinkSDKInitialized = YES; + } + @catch(NSException *e) { NSLog(@"%@", e); } + } + } + + if (_blinkSDKInitialized) { + _blinkFlutterResult = result; + [self invokeBlinkScanWithParameters:parameters]; + } + else { + NSLog(@"BlinkID: not initialized"); + result(nil); + } + } +} + +- (void)invokeBlinkScanWithParameters:(NSDictionary*)parameters { + NSMutableArray *recognizers = [[NSMutableArray alloc] init]; + NSArray *recognizersParam = [parameters inaArrayForKey:@"recognizers" defaults:@[@"combined", @"passport"]]; + for (NSString *recognizer in recognizersParam) { + if ([recognizer isEqualToString:@"combined"]) { + _blinkCombinedRecognizer = [[MBBlinkIdCombinedRecognizer alloc] init]; + _blinkCombinedRecognizer.encodeFaceImage = TRUE; + [recognizers addObject:_blinkCombinedRecognizer]; + } + else if ([recognizer isEqualToString:@"passport"]) { + _blinkPassportRecognizer = [[MBPassportRecognizer alloc] init]; + _blinkPassportRecognizer.encodeFaceImage = TRUE; + [recognizers addObject:_blinkPassportRecognizer]; + } + } + + MBBlinkIdOverlaySettings* settings = [[MBBlinkIdOverlaySettings alloc] init]; + MBRecognizerCollection *recognizerCollection = [[MBRecognizerCollection alloc] initWithRecognizers:recognizers]; + MBBlinkIdOverlayViewController *overlayViewController = [[MBBlinkIdOverlayViewController alloc] initWithSettings:settings recognizerCollection:recognizerCollection delegate:self]; + _blinkRecognizerRunnerViewController = [MBViewControllerFactory recognizerRunnerViewControllerWithOverlayViewController:overlayViewController]; + + + [_navigationViewController.topViewController presentViewController:_blinkRecognizerRunnerViewController animated:YES completion:nil]; +} + +- (void)didMicroBlinkScanWithResult:(MBRecognizerResult*)result { + + __weak typeof(self) weakSelf = self; + [_blinkRecognizerRunnerViewController dismissViewControllerAnimated:YES completion:^{ + + FlutterResult flutterResult = weakSelf.blinkFlutterResult; + + weakSelf.blinkFlutterResult = nil; + weakSelf.blinkCombinedRecognizer = nil; + weakSelf.blinkPassportRecognizer = nil; + weakSelf.blinkRecognizerRunnerViewController = nil; + + NSDictionary *scanResult = nil; + if ([result isKindOfClass:[MBBlinkIdCombinedRecognizerResult class]]) { + scanResult = [self scanResultFromBlinkCombinedResult:(MBBlinkIdCombinedRecognizerResult*)result]; + } + else if ([result isKindOfClass:[MBPassportRecognizerResult class]]) { + scanResult = [self scanResultFromBlinkPassportResult:(MBPassportRecognizerResult*)result]; + } + + if (flutterResult != nil) { + flutterResult(scanResult); + } + }]; +} + +- (NSDictionary*)scanResultFromBlinkCombinedResult:(MBBlinkIdCombinedRecognizerResult*)result { + NSString *base64FaceImage = [result.encodedFaceImage base64EncodedStringWithOptions:0]; + + return (result != nil) ? @{ + @"firstName": result.firstName ?: [NSNull null], + @"lastName": result.lastName ?: [NSNull null], + @"fullName": result.fullName ?: [NSNull null], + @"sex": result.sex ?: [NSNull null], + @"address": result.address ?: [NSNull null], + + @"dateOfBirth": [self scanStringBlinkDate:result.dateOfBirth] ?: [NSNull null], + @"dateOfExpiry": [self scanStringBlinkDate:result.dateOfExpiry] ?: [NSNull null], + @"dateOfIssue": [self scanStringBlinkDate:result.dateOfIssue] ?: [NSNull null], + + @"documentNumber": result.documentNumber ?: [NSNull null], + + @"placeOfBirth": result.placeOfBirth ?: [NSNull null], + @"nationality": result.nationality ?: [NSNull null], + @"race": result.race ?: [NSNull null], + @"religion": result.religion ?: [NSNull null], + @"profession": result.profession ?: [NSNull null], + @"maritalStatus": result.maritalStatus ?: [NSNull null], + @"residentialStatus": result.residentialStatus ?: [NSNull null], + @"employer": result.employer ?: [NSNull null], + @"personalIdNumber": result.personalIdNumber ?: [NSNull null], + @"documentAdditionalNumber": result.documentAdditionalNumber ?: [NSNull null], + @"issuingAuthority": result.issuingAuthority ?: [NSNull null], + + @"mrz" : [self scanResultFromBlinkMrzResult:result.mrzResult] ?: [NSNull null], + @"driverLicenseDetailedInfo": [self scanResultFromBlinkDriverLicenseDetailedInfo:result.driverLicenseDetailedInfo] ?: [NSNull null], + + @"base64FaceImage": base64FaceImage ?: [NSNull null], + } : nil; +} + +- (NSDictionary*)scanResultFromBlinkPassportResult:(MBPassportRecognizerResult*)result { + NSString *base64FaceImage = [result.encodedFaceImage base64EncodedStringWithOptions:0]; + + return (result != nil) ? @{ + @"firstName": [NSNull null], + @"lastName": [NSNull null], + @"fullName": [NSNull null], + @"sex": [NSNull null], + @"address": [NSNull null], + + @"dateOfBirth": [NSNull null], + @"dateOfExpiry": [NSNull null], + @"dateOfIssue": [NSNull null], + + @"documentNumber": [NSNull null], + + @"placeOfBirth": [NSNull null], + @"nationality": [NSNull null], + @"race": [NSNull null], + @"religion": [NSNull null], + @"profession": [NSNull null], + @"maritalStatus": [NSNull null], + @"residentialStatus": [NSNull null], + @"employer": [NSNull null], + @"personalIdNumber": [NSNull null], + @"documentAdditionalNumber": [NSNull null], + @"issuingAuthority": [NSNull null], + + @"mrz" : [self scanResultFromBlinkMrzResult:result.mrzResult] ?: [NSNull null], + @"driverLicenseDetailedInfo": [NSNull null], + + @"base64FaceImage": base64FaceImage ?: [NSNull null], + } : nil; +} + +- (NSDictionary*)scanResultFromBlinkMrzResult:(MBMrzResult*)result { + return (result != nil) ? @{ + @"primaryID" : result.primaryID ?: [NSNull null], + @"secondaryID" : result.secondaryID ?: [NSNull null], + @"issuer" : result.issuer ?: [NSNull null], + @"issuerName" : result.issuerName ?: [NSNull null], + @"dateOfBirth": [self scanStringBlinkDate:result.dateOfBirth] ?: [NSNull null], + @"dateOfExpiry": [self scanStringBlinkDate:result.dateOfExpiry] ?: [NSNull null], + @"documentNumber" : result.documentNumber ?: [NSNull null], + @"nationality" : result.nationality ?: [NSNull null], + @"nationalityName" : result.nationalityName ?: [NSNull null], + @"gender" : result.gender ?: [NSNull null], + @"documentCode" : result.documentCode ?: [NSNull null], + @"alienNumber" : result.alienNumber ?: [NSNull null], + @"applicationReceiptNumber" : result.applicationReceiptNumber ?: [NSNull null], + @"immigrantCaseNumber" : result.immigrantCaseNumber ?: [NSNull null], + + @"opt1" : result.opt1 ?: [NSNull null], + @"opt2" : result.opt2 ?: [NSNull null], + @"mrzText" : result.mrzText ?: [NSNull null], + + @"sanitizedOpt1" : result.sanitizedOpt1 ?: [NSNull null], + @"sanitizedOpt2" : result.sanitizedOpt2 ?: [NSNull null], + @"sanitizedNationality" : result.sanitizedNationality ?: [NSNull null], + @"sanitizedIssuer" : result.sanitizedIssuer ?: [NSNull null], + @"sanitizedDocumentCode" : result.sanitizedDocumentCode ?: [NSNull null], + @"sanitizedDocumentNumber" : result.sanitizedDocumentNumber ?: [NSNull null], + } : nil; +} + +- (NSDictionary*)scanResultFromBlinkDriverLicenseDetailedInfo:(MBDriverLicenseDetailedInfo*)info { + return (info != nil) ? @{ + @"restrictions" : info.restrictions ?: [NSNull null], + @"endorsements" : info.endorsements ?: [NSNull null], + @"vehicleClass" : info.vehicleClass ?: [NSNull null], + } : nil; +} + +- (NSString*)scanStringBlinkDate:(MBDateResult*)blinkDate { + return (blinkDate != nil) ? [NSString stringWithFormat:@"%02lu/%02lu/%04lu", blinkDate.month, blinkDate.day, blinkDate.year] : nil; +} + +#pragma mark Device UUID + +- (NSUUID*)deviceUUID { + static const NSString *deviceUUID = @"deviceUUID"; + + NSDictionary *spec = @{ + (id)kSecClass: (id)kSecClassGenericPassword, + (id)kSecAttrAccount: deviceUUID, + (id)kSecAttrGeneric: deviceUUID, + (id)kSecAttrService: NSBundle.mainBundle.bundleIdentifier, + }; + + NSMutableDictionary *searchRequest = [NSMutableDictionary dictionaryWithDictionary:spec]; + [searchRequest setObject:(id)kSecMatchLimitOne forKey:(id)kSecMatchLimit]; + [searchRequest setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnData]; + [searchRequest setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnAttributes]; + + CFDictionaryRef response = NULL; + OSStatus status = SecItemCopyMatching((CFDictionaryRef)searchRequest, (CFTypeRef*)&response); + if (status == errSecInteractionNotAllowed) { + // Could not access data. Error: errSecInteractionNotAllowed + return nil; + } + else if (status == 0) { + NSDictionary *attribs = CFBridgingRelease(response); + NSData *data = [attribs objectForKey:(id)kSecValueData]; + NSString *security = [attribs objectForKey:(id)kSecAttrAccessible]; + + // If not always accessible then update it to be so + if (![security isEqualToString:(id)kSecAttrAccessibleAlways]) { + NSDictionary *update = @{ + (id)kSecAttrAccessible:(id)kSecAttrAccessibleAlways, + (id)kSecValueData:data ?: [[NSData alloc] init], + }; + + SecItemUpdate((CFDictionaryRef)spec, (CFDictionaryRef)update); + } + + if (data.length == sizeof(uuid_t)) { + return [[NSUUID alloc] initWithUUIDBytes:data.bytes]; + } + else { + // update entry bellow + } + } + + uuid_t uuidData; + int rndStatus = SecRandomCopyBytes(kSecRandomDefault, sizeof(uuidData), uuidData); + if (rndStatus == errSecSuccess) { + if (status == 0) { + // update existing entry + NSDictionary *update = @{ + (id)kSecAttrAccessible:(id)kSecAttrAccessibleAlways, + (id)kSecValueData:[NSData dataWithBytes:uuidData length:sizeof(uuidData)] + }; + status = SecItemUpdate((CFDictionaryRef)spec, (CFDictionaryRef)update); + } + else { + // create new entry + NSMutableDictionary *createRequest = [NSMutableDictionary dictionaryWithDictionary:spec]; + [createRequest setObject:[NSData dataWithBytes:uuidData length:sizeof(uuidData)] forKey:(id)kSecValueData]; + [createRequest setObject:(id)kSecAttrAccessibleAlways forKey:(id)kSecAttrAccessible]; + status = SecItemAdd((CFDictionaryRef)createRequest, NULL); + } + return (status == 0) ? [[NSUUID alloc] initWithUUIDBytes:uuidData] : nil; + } + else { + return nil; + } +} + +#pragma mark Permanent Value + +- (id)healthRSAPrivateKeyWithParameters:(NSDictionary*)parameters { + static const NSString *healthRSAPrivateKey = @"healthRSAPrivateKey"; + + NSString *userId = [parameters inaStringForKey:@"userId"]; + if (userId == nil) { + return nil; + } + + NSDictionary *spec = @{ + (id)kSecClass: (id)kSecClassGenericPassword, + (id)kSecAttrAccount: userId, + (id)kSecAttrGeneric: healthRSAPrivateKey, + (id)kSecAttrService: NSBundle.mainBundle.bundleIdentifier, + }; + + NSMutableDictionary *searchRequest = [NSMutableDictionary dictionaryWithDictionary:spec]; + [searchRequest setObject:(id)kSecMatchLimitOne forKey:(id)kSecMatchLimit]; + [searchRequest setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnData]; + [searchRequest setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnAttributes]; + + CFDictionaryRef response = NULL; + OSStatus status = SecItemCopyMatching((CFDictionaryRef)searchRequest, (CFTypeRef*)&response); + NSString *existingValue = nil; + + if (status == errSecInteractionNotAllowed) { + // Could not access data. Error: errSecInteractionNotAllowed + return nil; + } + else if (status == 0) { + NSDictionary *attribs = CFBridgingRelease(response); + NSData *data = [attribs objectForKey:(id)kSecValueData]; + NSString *security = [attribs objectForKey:(id)kSecAttrAccessible]; + + // If not always accessible then update it to be so + if (![security isEqualToString:(id)kSecAttrAccessibleAlways]) { + NSDictionary *update = @{ + (id)kSecAttrAccessible:(id)kSecAttrAccessibleAlways, + (id)kSecValueData:data ?: [[NSData alloc] init], + }; + + SecItemUpdate((CFDictionaryRef)spec, (CFDictionaryRef)update); + } + + existingValue = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + } + + if([parameters inaBoolForKey:@"remove"]){ // remove + status = SecItemDelete((CFDictionaryRef)spec); + return [NSNumber numberWithBool:(status == 0)]; + } + else if ([parameters objectForKey:@"value"] == nil) { // getter + return existingValue; + } + else { // setter + + NSString *value = [parameters inaStringForKey:@"value"]; + if (value != nil) { + NSData *valueData = [value dataUsingEncoding:NSUTF8StringEncoding]; + + if (status == 0) { + // update existing entry + NSDictionary *update = @{ + (id)kSecAttrAccessible:(id)kSecAttrAccessibleAlways, + (id)kSecValueData:valueData + }; + status = SecItemUpdate((CFDictionaryRef)spec, (CFDictionaryRef)update); + } + else { + // create new entry + NSMutableDictionary *createRequest = [NSMutableDictionary dictionaryWithDictionary:spec]; + [createRequest setObject:valueData forKey:(id)kSecValueData]; + [createRequest setObject:(id)kSecAttrAccessibleAlways forKey:(id)kSecAttrAccessible]; + status = SecItemAdd((CFDictionaryRef)createRequest, NULL); + } + + return [NSNumber numberWithBool:(status == 0)]; + } + else { + if (status == 0) { + // delete existing entry + status = SecItemDelete((CFDictionaryRef)spec); + return [NSNumber numberWithBool:(status == 0)]; + } + else { + // nothing to do + return [NSNumber numberWithBool:YES]; + } + } + } +} + + + +#pragma mark PKAddPassesViewControllerDelegate + +- (void)addPassesViewControllerDidFinish:(PKAddPassesViewController *)controller { + if (controller == _passViewController) { + __weak typeof(self) weakSelf = self; + [controller dismissViewControllerAnimated:YES completion:^{ + FlutterResult result = weakSelf.passFlutterResult; + weakSelf.passFlutterResult = nil; + weakSelf.passViewController = nil; + if (result != nil) { + result(@(YES)); + } + }]; + } +} + +#pragma mark MBBlinkIdOverlayViewControllerDelegate + +- (void)blinkIdOverlayViewControllerDidFinishScanning:(MBBlinkIdOverlayViewController *)blinkIdOverlayViewController state:(MBRecognizerResultState)state { + + if (state == MBRecognizerResultStateValid) { + MBRecognizerResult *result = nil; + if ((_blinkCombinedRecognizer.result != nil) && (_blinkCombinedRecognizer.result.resultState == MBRecognizerResultStateValid)) { + result = _blinkCombinedRecognizer.result; + } + else if ((_blinkPassportRecognizer.result != nil) && (_blinkPassportRecognizer.result.resultState == MBRecognizerResultStateValid)) { + result = _blinkPassportRecognizer.result; + } + + if (result != nil) { + [blinkIdOverlayViewController.recognizerRunnerViewController pauseScanning]; + + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf didMicroBlinkScanWithResult:result]; + }); + } + } +} + +- (void)blinkIdOverlayViewControllerDidTapClose:(nonnull MBBlinkIdOverlayViewController *)blinkIdOverlayViewController { + + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf didMicroBlinkScanWithResult:nil]; + }); +} + + +#pragma mark UINavigationControllerDelegate + +- (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated { + UIViewController *rootViewController = navigationController.viewControllers.firstObject; + BOOL navigationBarHidden = (viewController == rootViewController); + if (navigationController.navigationBarHidden != navigationBarHidden) { + [navigationController setNavigationBarHidden:navigationBarHidden animated:YES]; + } +} + +- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated { + +} + +- (id)navigationController:(UINavigationController *)navigationController + animationControllerForOperation:(UINavigationControllerOperation)operation + fromViewController:(UIViewController *)fromVC + toViewController:(UIViewController *)toVC +{ + if ([fromVC conformsToProtocol:@protocol(FlutterCompletionHandler)] && [toVC isKindOfClass:[FlutterViewController class]]) { + id directionsVC = (id)fromVC; + if (directionsVC.completionHandler != nil) { + directionsVC.completionHandler(nil); + directionsVC.completionHandler = nil; + } + } + + return nil; +} + + +#pragma mark UNUserNotificationCenterDelegate + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler { + NSDictionary *userInfo = notification.request.content.userInfo; + NSData *userInfoData = [NSJSONSerialization dataWithJSONObject:userInfo options:0 error:NULL]; + NSString *userInfoString = [[NSString alloc] initWithData:userInfoData encoding:NSUTF8StringEncoding]; + NSLog(@"UIApplication: UNUserNotificationCenter willPresentNotification:\n%@", userInfoString); + + completionHandler(UNNotificationPresentationOptionAlert|UNNotificationPresentationOptionBadge|UNNotificationPresentationOptionSound); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler { + NSDictionary *userInfo = response.notification.request.content.userInfo; + NSData *userInfoData = [NSJSONSerialization dataWithJSONObject:userInfo options:0 error:NULL]; + NSString *userInfoString = [[NSString alloc] initWithData:userInfoData encoding:NSUTF8StringEncoding]; + NSLog(@"UIApplication: UNUserNotificationCenter didReceiveNotificationResponse (%@):\n%@", response.actionIdentifier, userInfoString); + + if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) { + // The user dismissed the notification without taking action. + } + else if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) { + // The user launched the app. + [self processPushNotification:response.notification.request.content.userInfo]; + } + + completionHandler(); +} + +#pragma mark FIRMessagingDelegate + +- (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSString *)fcmToken { + NSLog(@"UIApplication: FIRMessaging: didReceiveRegistrationToken: %@", fcmToken); + NSDictionary *userInfo = [NSDictionary dictionaryWithObject:fcmToken forKey:@"token"]; + [[NSNotificationCenter defaultCenter] postNotificationName:@"FCMToken" object:nil userInfo:userInfo]; +} + +#pragma mark NSNotificationCenter + +- (void)didReceiveFCMTokenNotification:(NSNotification *)notification { + NSString *fcmToken = [notification.object isKindOfClass:[NSString class]] ? notification.object : nil; + NSLog(@"UIApplication: didReceiveFCMTokenNotification: %@", fcmToken); +} + +@end + +////////////////////////////////////// +// RootNavigationController + +@implementation RootNavigationController + +- (BOOL)shouldAutorotate { + return true; +} + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + UIInterfaceOrientationMask result = 0; + for (NSNumber *orientation in AppDelegate.sharedInstance.supportedInterfaceOrientations) { + result |= _interfaceOrientationToMask(orientation.integerValue); + } + + return result; +} + +- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { + return AppDelegate.sharedInstance.preferredInterfaceOrientation; +} + +@end + +////////////////////////////////////// +// UIInterfaceOrientation + +@interface LaunchScreenView() +@property (nonatomic) UIImageView *imageView; +@property (nonatomic) UIActivityIndicatorView *activityView; +@end + +@implementation LaunchScreenView + +- (id)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + _imageView = [[UIImageView alloc] initWithFrame:frame]; + _imageView.image = [UIImage imageNamed:@"LaunchImage"]; + [self addSubview:_imageView]; + + _activityView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + [self addSubview:_activityView]; + + [_activityView startAnimating]; + } + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + CGSize contentSize = self.frame.size; + + CGSize imageSize = InaSizeScaleToFill(_imageView.image.size, contentSize); + _imageView.frame = CGRectMake((contentSize.width - imageSize.width) / 2, (contentSize.height - imageSize.height) / 2, imageSize.width, imageSize.height); + + CGSize activitySize = [_activityView sizeThatFits:contentSize]; + _activityView.frame = CGRectMake((contentSize.width - activitySize.width) / 2, 7 * (contentSize.height - activitySize.height) / 10, activitySize.width, activitySize.height); + + +} + +@end + + +////////////////////////////////////// +// UIInterfaceOrientation + +UIInterfaceOrientation _interfaceOrientationFromString(NSString *value) { + if ([value isEqualToString:@"portraitUp"]) { + return UIInterfaceOrientationPortrait; + } + else if ([value isEqualToString:@"portraitDown"]) { + return UIInterfaceOrientationPortraitUpsideDown; + } + else if ([value isEqualToString:@"landscapeLeft"]) { + return UIInterfaceOrientationLandscapeLeft; + } + else if ([value isEqualToString:@"landscapeRight"]) { + return UIInterfaceOrientationLandscapeRight; + } + else { + return UIInterfaceOrientationUnknown; + } +} + +NSString* _interfaceOrientationToString(UIInterfaceOrientation value) { + switch (value) { + case UIInterfaceOrientationPortrait: return @"portraitUp"; + case UIInterfaceOrientationPortraitUpsideDown: return @"portraitDown"; + case UIInterfaceOrientationLandscapeLeft: return @"landscapeLeft"; + case UIInterfaceOrientationLandscapeRight: return @"landscapeRight"; + default: return nil; + } +} + +UIInterfaceOrientation _interfaceOrientationFromMask(UIInterfaceOrientationMask value) { + switch (value) { + case UIInterfaceOrientationMaskPortrait: return UIInterfaceOrientationPortrait; + case UIInterfaceOrientationMaskPortraitUpsideDown: return UIInterfaceOrientationPortraitUpsideDown; + case UIInterfaceOrientationMaskLandscapeLeft: return UIInterfaceOrientationLandscapeLeft; + case UIInterfaceOrientationMaskLandscapeRight: return UIInterfaceOrientationLandscapeRight; + default: return UIInterfaceOrientationUnknown; + } +} + +UIInterfaceOrientationMask _interfaceOrientationToMask(UIInterfaceOrientation value) { + switch (value) { + case UIInterfaceOrientationPortrait: return UIInterfaceOrientationMaskPortrait; + case UIInterfaceOrientationPortraitUpsideDown: return UIInterfaceOrientationMaskPortraitUpsideDown; + case UIInterfaceOrientationLandscapeLeft: return UIInterfaceOrientationMaskLandscapeLeft; + case UIInterfaceOrientationLandscapeRight: return UIInterfaceOrientationMaskLandscapeRight; + default: return 0; + } +} + diff --git a/ios/Runner/AppKeys.h b/ios/Runner/AppKeys.h new file mode 100644 index 00000000..e6b8dcb9 --- /dev/null +++ b/ios/Runner/AppKeys.h @@ -0,0 +1,31 @@ +// +// AppKeys.h +// Runner +// +// Created by Mihail Varbanov on 4/25/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import + +extern NSString * const kFlutterMetodChannelName; + +extern CLLocationCoordinate2D const kInitialCameraLocation; +extern float const kInitialCameraZoom; +extern float const kMarkerThresold1Zoom; +extern float const kMarkerThresold2Zoom; + +extern double const kExploreLocationThresoldDistance; diff --git a/ios/Runner/AppKeys.m b/ios/Runner/AppKeys.m new file mode 100644 index 00000000..eb8f2457 --- /dev/null +++ b/ios/Runner/AppKeys.m @@ -0,0 +1,35 @@ +// +// AppKeys.m +// Runner +// +// Created by Mihail Varbanov on 4/25/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "AppKeys.h" + +NSString * const kFlutterMetodChannelName = @"edu.illinois.covid/core"; + +// -------------------------------------------- + +// Camera: Campus Center +CLLocationCoordinate2D const kInitialCameraLocation = { 40.102116, -88.227129 }; +float const kInitialCameraZoom = 17; +float const kMarkerThresold1Zoom = 16.0; +float const kMarkerThresold2Zoom = 16.89f; + +// -------------------------------------------- + +double const kExploreLocationThresoldDistance = 200.0; // in meters diff --git a/ios/Runner/ExposurePlugin.h b/ios/Runner/ExposurePlugin.h new file mode 100644 index 00000000..b76480a7 --- /dev/null +++ b/ios/Runner/ExposurePlugin.h @@ -0,0 +1,28 @@ +// +// ExposurePlugin.h +// Runner +// +// Created by Mihail Varbanov on 5/21/20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import + + +@interface ExposurePlugin : NSObject + +@end + diff --git a/ios/Runner/ExposurePlugin.m b/ios/Runner/ExposurePlugin.m new file mode 100644 index 00000000..e8fdfa35 --- /dev/null +++ b/ios/Runner/ExposurePlugin.m @@ -0,0 +1,1425 @@ +// +// ExposurePlugin.m +// Runner +// +// Created by Mihail Varbanov on 5/21/20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "ExposurePlugin.h" +#import +#import +#import +#import + +#import "Bluetooth+InaUtils.h" +#import "NSDictionary+InaTypedValue.h" +#import "CommonCrypto+UIUCUtils.h" +#import "Security+UIUCUtils.h" + +static NSString* const kMethodChanelName = @"edu.illinois.covid/exposure"; +static NSString* const kServiceUuid = @"CD19"; +static NSString* const kCharacteristicUuid = @"1f5bb1de-cdf0-4424-9d43-d8cc81a7f207"; +static NSString* const kRangingBeaconUuid = @"c965bc2c-5f28-4854-b046-7e68e0e60074"; +static NSString* const kLocalNotificationId = @"exposureNotification"; +static NSString* const kExposureTEK1 = @"exposureTEKs"; +static NSString* const kExposureTEK2 = @"exposureTEK2s"; + +static NSString* const kStartMethodName = @"start"; +static NSString* const kStopMethodName = @"stop"; +static NSString* const kTEKsMethodName = @"TEKs"; +static NSString* const kTekRPIsMethodName = @"tekRPIs"; +static NSString* const kExpireTEKMethodName = @"expireTEK"; +static NSString* const kRPILogMethodName = @"exposureRPILog"; +static NSString* const kRSSILogMethodName = @"exposureRSSILog"; +static NSString* const kSettingsParamName = @"settings"; +static NSString* const kTEKParamName = @"tek"; +static NSString* const kTimestampParamName = @"timestamp"; + +static NSString* const kTEKNotificationName = @"tek"; +static NSString* const kTEKTimestampParamName = @"timestamp"; +static NSString* const kTEKExpirestampParamName = @"expirestamp"; +static NSString* const kTEKValueParamName = @"tek"; + +static NSString* const kExposureNotificationName = @"exposure"; +static NSString* const kExposureThickNotificationName = @"exposureThick"; +static NSString* const kExposureTimestampParamName = @"timestamp"; +static NSString* const kExposureRPIParamName = @"rpi"; +static NSString* const kExposureDurationParamName = @"duration"; +static NSString* const kExposureRSSIParamName = @"rssi"; + +static NSString* const kExposureTimeoutIntervalSettingName = @"covid19ExposureServiceTimeoutInterval"; +static NSString* const kExposurePingIntervalSettingName = @"covid19ExposureServicePingInterval"; +static NSString* const kExposureProcessIntervalSettingName = @"covid19ExposureServiceProcessInterval"; +static NSString* const kLocalNotificationIntervalSettingName = @"covid19ExposureServiceLocalNotificationInterval"; +static NSString* const kExposureMinDurationSettingName = @"covid19ExposureServiceLogMinDuration"; +static NSString* const kExposureMinRssiSettingName = @"covid19ExposureServiceMinRSSI"; + +static NSInteger const kRPIRefreshInterval = (10 * 60); // 10 mins +static NSInteger const kTEKRollingPeriod = (24 * 60 * 60) / kRPIRefreshInterval; // = 144 (kRPIRefreshInterval * kTEKRollingPeriod = 24 hours) + +static NSTimeInterval const kExposureTimeoutInterval = 300; // 5 minutes +static NSTimeInterval const kExposurePingInterval = 60; // 1 minute +static NSTimeInterval const kExposureProcessInterval = 10; // 10 sec +static NSTimeInterval const kLocalNotificationInterval = 60; // 1 minute +static NSTimeInterval const kExposureNotifyTickInterval = 1; // 1 sec +static NSTimeInterval const kExposureMinDuration = 120; // 2 minutes + +static int const kNoRssi = 127; +static int const kMinRssi = -50; + +//////////////////////////////////// +// ExposureRecord + +@interface ExposureRecord : NSObject +@property (nonatomic, readonly) NSInteger timestampCreated; +@property (nonatomic, readonly) NSTimeInterval timeUpdated; +@property (nonatomic, readonly) NSInteger duration; +@property (nonatomic, readonly) NSTimeInterval durationInterval; +@property (nonatomic, readonly) int rssi; + +- (instancetype)initWithTimestamp:(NSTimeInterval)timestamp rssi:(int)rssi; +- (void)updateTimestamp:(NSTimeInterval)timestamp rssi:(int)rssi; +@end + +//////////////////////////////////// +// TEKRecord + +@interface TEKRecord : NSObject +@property (nonatomic) NSData* tek; +@property (nonatomic) int expire; + +- (instancetype)initWithTEK:(NSData*)tek expire:(int)expire; + ++ (instancetype)fromJson:(NSDictionary*)json; +- (NSDictionary*)toJson; +@end + +//////////////////////////////////// +// ExposurePlugin + +@interface ExposurePlugin() { + + FlutterMethodChannel* _methodChannel; + FlutterResult _startResult; + + NSData* _rpi; + NSTimer* _rpiTimer; + NSMutableDictionary* _teks; + + CBPeripheralManager* _peripheralManager; + CBMutableService* _peripheralService; + CBMutableCharacteristic* _peripheralCharacteristic; + + CBCentralManager* _centralManager; + + NSMutableDictionary* _peripherals; + NSMutableDictionary* _peripheralRPIs; + NSMutableDictionary* _iosExposures; + NSMutableDictionary* _androidExposures; + + NSTimer* _exposuresTimer; + NSTimeInterval _lastNotifyExposireThickTime; + + CLLocationManager* _locationManager; + CLBeaconRegion* _beaconRegion; + bool _monitoringLocation; + + NSTimeInterval _lastLocalNotificationTime; + + NSTimeInterval _exposureTimeoutInterval; + NSTimeInterval _exposurePingInterval; + NSTimeInterval _exposureProcessInterval; + NSTimeInterval _localNotificationInterval; + NSTimeInterval _exposureMinDuration; + int _exposureMinRssi; +} +@property (nonatomic, readonly) int exposureMinRssi; +@property (nonatomic) UIBackgroundTaskIdentifier bgTaskId; +@end + +@implementation ExposurePlugin + +static ExposurePlugin *g_Instance = nil; + ++ (void)registerWithRegistrar:(NSObject *)registrar { + FlutterMethodChannel *channel = [FlutterMethodChannel methodChannelWithName:kMethodChanelName binaryMessenger:registrar.messenger]; + ExposurePlugin *instance = [[ExposurePlugin alloc] initWithMethodChannel:channel]; + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (instancetype)init { + if (self = [super init]) { + if (g_Instance == nil) { + g_Instance = self; + } + _peripherals = [[NSMutableDictionary alloc] init]; + _peripheralRPIs = [[NSMutableDictionary alloc] init]; + _iosExposures = [[NSMutableDictionary alloc] init]; + _androidExposures = [[NSMutableDictionary alloc] init]; + _teks = [self.class loadTEK2sFromStorage]; + _bgTaskId = UIBackgroundTaskInvalid; + } + return self; +} + +- (void)dealloc { + if (g_Instance == self) { + g_Instance = nil; + } +} + +- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)channel { + if (self = [self init]) { + _methodChannel = channel; + } + return self; +} + ++ (instancetype)sharedInstance { + return g_Instance; +} + +#pragma mark MethodCall + +- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + NSDictionary *parameters = [call.arguments isKindOfClass:[NSDictionary class]] ? call.arguments : nil; + if ([call.method isEqualToString:kStartMethodName]) { + NSDictionary *settings = [parameters inaDictForKey:kSettingsParamName]; + [self startWithSettings:settings flutterResult:result]; + } + else if ([call.method isEqualToString:kStopMethodName]) { + [self stop]; + result([NSNumber numberWithBool:YES]); + } + else if ([call.method isEqualToString:kTEKsMethodName]) { + bool remove = [parameters inaBoolForKey:@"remove"]; + if (remove) { + [self.class saveTEK2sToStorage:nil]; + result(nil); + } + else { + result(self.teksList); + } + } + else if ([call.method isEqualToString:kTekRPIsMethodName]) { + NSString *tekString = [parameters inaStringForKey:kTEKParamName]; + NSData *tek = [[NSData alloc] initWithBase64EncodedString:tekString options:0]; + NSInteger timestamp = [parameters inaIntegerForKey:kTimestampParamName]; + NSInteger expirestamp = [parameters inaIntegerForKey:kTEKExpirestampParamName]; + result([self rpisForTek:tek timestamp:timestamp expirestamp:expirestamp]); + } + else if ([call.method isEqualToString:kExpireTEKMethodName]) { + [self updateTEKExpireTime]; + result(nil); + } + + else { + result(nil); + } +} + +#pragma mark API + +- (void)startWithSettings:(NSDictionary*)settings flutterResult:(FlutterResult)result { + + if (self.isPeripheralAuthorized && self.isCentralAuthorized && (_peripheralManager == nil) && (_centralManager == nil) && (_startResult == nil)) { + NSLog(@"ExposurePlugin: Start"); + _startResult = result; + [self initSettings:settings]; + [self initRPI]; + [self startPeripheral]; + [self startCentral]; + [self startLocationManager]; + [self connectAppLiveCycleEvents]; + } + else if (result != nil) { + result([NSNumber numberWithBool:YES]); + } +} + +- (void)checkStarted { + if ((_startResult != nil) && self.isStarted) { + FlutterResult flutterResult = _startResult; + _startResult = nil; + flutterResult([NSNumber numberWithBool:YES]); + } +} + +- (void)startFailed { + if (_startResult != nil) { + FlutterResult flutterResult = _startResult; + _startResult = nil; + flutterResult([NSNumber numberWithBool:NO]); + } + +} + +- (bool)isStarted { + return self.isPeripheralStarted && self.isCentralStarted; +} + +- (void)stop { + NSLog(@"ExposurePlugin: Stop"); + [self stopPeripheral]; + [self stopCentral]; + [self stopLocationManager]; + [self clearRPI]; + [self clearExposures]; + [self disconnectAppLiveCycleEvents]; +} + +#pragma mark Peripheral + +- (void)startPeripheral { + if (self.isPeripheralAuthorized && (_peripheralManager == nil)) { + _peripheralManager = [[CBPeripheralManager alloc] initWithDelegate:self queue:nil]; + } + else { + [self startFailed]; + } +} + +- (void)updatePeripheral { + if (self.isPeripheralInitialized && (_rpi != nil) && _peripheralManager.isAdvertising) { + [_peripheralManager updateValue:_rpi forCharacteristic:_peripheralCharacteristic onSubscribedCentrals:nil]; + } +} + +- (void)stopPeripheral { + + if (_peripheralManager != nil) { + if (_peripheralManager.isAdvertising) { + [_peripheralManager stopAdvertising]; + } + + if (_peripheralService != nil) { + [_peripheralManager removeService:_peripheralService]; + _peripheralService = nil; + } + + _peripheralCharacteristic = nil; + + _peripheralManager.delegate = nil; + _peripheralManager = nil; + } +} + +- (bool)isPeripheralAuthorized { + return InaBluetooth.peripheralAuthorizationStatus == InaBluetoothAuthorizationStatusAuthorized; +} + +- (bool)isPeripheralInitialized { + return (_peripheralManager != nil) && (_peripheralManager.state == CBManagerStatePoweredOn) && (_peripheralService != nil) && (_peripheralCharacteristic != nil); +} + +- (bool)isPeripheralStarted { + return self.isPeripheralInitialized && _peripheralManager.isAdvertising; +} + +#pragma mark CBPeripheralManagerDelegate + +- (void)peripheralManagerDidUpdateState:(CBPeripheralManager *)peripheral { + NSLog(@"ExposurePlugin: CBPeripheralManager didUpdateState: %@", @(peripheral.state)); + + if (_peripheralManager.state == CBManagerStatePoweredOn) { + CBUUID *serviceUuid = [CBUUID UUIDWithString:kServiceUuid]; + CBMutableService *service = [[CBMutableService alloc] initWithType:serviceUuid primary:YES]; + + CBUUID *characteristicUuid = [CBUUID UUIDWithString:kCharacteristicUuid]; + CBMutableCharacteristic *characteristic = [[CBMutableCharacteristic alloc] initWithType:characteristicUuid properties:CBCharacteristicPropertyRead | CBCharacteristicPropertyNotify value:nil permissions:CBAttributePermissionsReadable]; + service.characteristics = @[characteristic]; + + [_peripheralManager addService:service]; + } + else { + [self startFailed]; + } +} + +- (void)peripheralManager:(CBPeripheralManager *)peripheral didAddService:(CBService *)service error:(NSError *)error { + NSLog(@"ExposurePlugin: CBPeripheralManager didAddService"); + _peripheralService = [service isKindOfClass:[CBMutableService class]] ? ((CBMutableService*)service) : nil; + _peripheralCharacteristic = [_peripheralService inaMutableCharacteristicWithUUID:[CBUUID UUIDWithString:kCharacteristicUuid]]; + + if (self.isPeripheralInitialized) { + [_peripheralManager startAdvertising: + @{ CBAdvertisementDataServiceUUIDsKey :@[[CBUUID UUIDWithString:kServiceUuid]], + }]; + } + else { + [self startFailed]; + } +} + +- (void)peripheralManagerDidStartAdvertising:(CBPeripheralManager *)peripheral error:(NSError *)error { + NSLog(@"ExposurePlugin: CBPeripheralManager peripheralManagerDidStartAdvertising"); + if (self.isPeripheralStarted) { + [self checkStarted]; + } + else { + [self startFailed]; + } +} + +- (void)peripheralManager:(CBPeripheralManager *)peripheral didReceiveReadRequest:(CBATTRequest *)request{ + NSLog(@"ExposurePlugin: CBPeripheralManager didReceiveReadRequest"); + request.value = _rpi; + [peripheral respondToRequest:request withResult:CBATTErrorSuccess]; +} + +#pragma mark Central + +- (void)startCentral { + if (self.isCentralAuthorized && (_centralManager == nil)) { + _centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil options:@{ + CBCentralManagerOptionShowPowerAlertKey : [NSNumber numberWithBool:NO], + }]; + } + else { + [self startFailed]; + } +} + +- (void)stopCentral { + if (_centralManager != nil) { + if (_centralManager.isScanning) { + [_centralManager stopScan]; + } + + _centralManager.delegate = nil; + _centralManager = nil; + } + + if (_exposuresTimer != nil) { + [_exposuresTimer invalidate]; + _exposuresTimer = nil; + } +} + +- (bool)isCentralAuthorized { + return InaBluetooth.centralAuthorizationStatus == InaBluetoothAuthorizationStatusAuthorized; +} + +- (bool)isCentralInitialized { + return (_centralManager != nil) && (_centralManager.state == CBManagerStatePoweredOn); +} + +- (bool)isCentralStarted { + return self.isCentralInitialized && _centralManager.isScanning; +} + +- (void)disconnectPeripheralWithUuid:(NSUUID*)peripheralUuid { + [self _disconnectPeripheral:[_peripherals objectForKey:peripheralUuid]]; + [self _removePeripheralWithUuid:peripheralUuid]; +} + +- (void)disconnectPeripheral:(CBPeripheral*)peripheral { + [self _disconnectPeripheral:peripheral]; + [self _removePeripheralWithUuid:peripheral.identifier]; +} + +- (void)_disconnectPeripheral:(CBPeripheral*)peripheral { + if (peripheral != nil) { + peripheral.delegate = nil; + + CBService *service = [peripheral inaServiceWithUUID:[CBUUID UUIDWithString:kServiceUuid]]; + CBCharacteristic *characteristic = [service inaCharacteristicWithUUID:[CBUUID UUIDWithString:kCharacteristicUuid]]; + if (characteristic != nil) { + [peripheral setNotifyValue:NO forCharacteristic:characteristic]; + } + + [_centralManager cancelPeripheralConnection:peripheral]; + } +} + +- (void)_removePeripheralWithUuid:(NSUUID*)peripheralUuid { + if (peripheralUuid != nil) { + [_peripherals removeObjectForKey:peripheralUuid]; + + NSData *rpi = [_peripheralRPIs objectForKey:peripheralUuid]; + if (rpi != nil) { + [_peripheralRPIs removeObjectForKey:peripheralUuid]; + } + + ExposureRecord *record = [_iosExposures objectForKey:peripheralUuid]; + if (record != nil) { + [_iosExposures removeObjectForKey:peripheralUuid]; + [self updateExposuresTimer]; + } + + if ((rpi != nil) && (record != nil)) { + [self notifyExposure:record rpi:rpi peripheralUuid:peripheralUuid]; + } + } +} + +- (void)_removeAndroidRpi:(NSData*)rpi { + NSUUID *peripheralUuid = [self peripheralUuidForRPI:rpi]; + [self disconnectPeripheralWithUuid:peripheralUuid]; + + ExposureRecord *record = [_androidExposures objectForKey:rpi]; + if (record != nil) { + [_androidExposures removeObjectForKey:rpi]; + [self updateExposuresTimer]; + } + + if ((rpi != nil) && (record != nil)) { + [self notifyExposure:record rpi:rpi peripheralUuid:nil]; + } +} + +- (NSUUID*)peripheralUuidForRPI:(NSData*)rpi { + for (NSUUID* peripheralUuid in _peripheralRPIs) { + NSData *peripheralRpi = [_peripheralRPIs objectForKey:peripheralUuid]; + if ([peripheralRpi isEqualToData:rpi]) { + return peripheralUuid; + } + } + return nil; +} + +#pragma mark CBCentralManagerDelegate + +- (void)centralManagerDidUpdateState:(CBCentralManager *)central { + NSLog(@"ExposurePlugin: CBCentralManager didUpdateState: %@", @(central.state)); + if (self.isCentralInitialized) { + [_centralManager scanForPeripheralsWithServices:@[[CBUUID UUIDWithString:kServiceUuid]] options:@{ CBCentralManagerScanOptionAllowDuplicatesKey : @YES }]; + [self checkStarted]; + } + else { + [self startFailed]; + } +} + +- (void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary *)advertisementData RSSI:(NSNumber *)RSSI { + + CBUUID *serviceUuid = [CBUUID UUIDWithString:kServiceUuid]; + if ([advertisementData inaAdvertisementDataContainsServiceWithUuid:serviceUuid]) { + + NSUUID *peripheralUuid = peripheral.identifier; + if ([_peripherals objectForKey:peripheralUuid] == nil) { + NSLog(@"ExposurePlugin: CBCentralManager didDiscoverPeripheral"); + [_peripherals setObject:peripheral forKey:peripheralUuid]; + [_centralManager connectPeripheral:peripheral options:nil]; + } + + NSDictionary *serviceData = [advertisementData inaDictForKey:CBAdvertisementDataServiceDataKey]; + NSData *rpiData = (serviceData != nil) ? [serviceData objectForKey:serviceUuid] : nil; + if (rpiData != nil) { // Android + if ([_peripheralRPIs objectForKey:peripheralUuid] == nil) { // new record + NSLog(@"ExposurePlugin: New Android peripheral RPI received"); + [_peripheralRPIs setObject:rpiData forKey:peripheralUuid]; + } + else if (![rpiData isEqualToData:[_peripheralRPIs objectForKey:peripheralUuid]]) { // update existing record + NSLog(@"ExposurePlugin: Connected Android peripheral RPI changed"); + NSData * rpi = [_peripheralRPIs objectForKey:peripheralUuid]; + ExposureRecord * record = [_androidExposures objectForKey:rpi]; + if (record != nil) { + [_androidExposures removeObjectForKey:rpi]; + [self updateExposuresTimer]; + } + if (rpi != nil && record != nil) { + [self notifyExposure:record rpi:rpi peripheralUuid:peripheralUuid]; + } + [_peripheralRPIs setObject:rpiData forKey:peripheralUuid]; + } + [self logAndroidExposure:rpiData rssi:RSSI.intValue]; + } + else { // iOS + [self logiOSExposure:peripheralUuid rssi:RSSI.intValue]; + } + } +} + +- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(nonnull CBPeripheral *)peripheral { + NSLog(@"ExposurePlugin: CBCentralManager didConnectPeripheral"); + peripheral.delegate = self; + [peripheral discoverServices:@[[CBUUID UUIDWithString:kServiceUuid]]]; +} + +- (void)centralManager:(CBCentralManager *)central didFailToConnectPeripheral:(CBPeripheral *)peripheral error:(nullable NSError *)error { + NSLog(@"ExposurePlugin: CBCentralManager didFailToConnectPeripheral"); +} + +- (void)centralManager:(CBCentralManager *)central didDisconnectPeripheral:(CBPeripheral *)peripheral error:(NSError *)error { + NSLog(@"ExposurePlugin: CBCentralManager didDisconnectPeripheral"); + [self disconnectPeripheral:peripheral]; +} + +#pragma mark CBPeripheralDelegate + +- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(nullable NSError *)error { + NSLog(@"ExposurePlugin: CBPeripheral didDiscoverServices"); + if (error == nil) { + CBService *service = [peripheral inaServiceWithUUID:[CBUUID UUIDWithString:kServiceUuid]]; + if (service != nil) { + [peripheral discoverCharacteristics:@[[CBUUID UUIDWithString:kCharacteristicUuid]] forService:service]; + } + } +} + +- (void)peripheral:(CBPeripheral *)peripheral didModifyServices:(NSArray *)invalidatedServices { + NSLog(@"ExposurePlugin: CBPeripheral didModifyServices"); + CBUUID *serviceUuid = [CBUUID UUIDWithString:kServiceUuid]; + for (CBService *service in invalidatedServices) { + if ([service.UUID isEqual:serviceUuid]) { + [peripheral discoverServices:@[serviceUuid]]; + break; + } + } +} + +- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(nonnull CBService *)service error:(nullable NSError *)error { + NSLog(@"ExposurePlugin: CBPeripheral didDiscoverCharacteristicsForService"); + if (error == nil) { + CBCharacteristic *characteristic = [service inaCharacteristicWithUUID:[CBUUID UUIDWithString:kCharacteristicUuid]]; + if (characteristic != nil) { + [peripheral setNotifyValue:YES forCharacteristic:characteristic]; + [peripheral readValueForCharacteristic:characteristic]; + } + } +} + +- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error { + NSLog(@"ExposurePlugin: CBPeripheral didUpdateValueForCharacteristic"); + if ((error == nil) && [characteristic.UUID isEqual:[CBUUID UUIDWithString:kCharacteristicUuid]] && (characteristic.value != nil)) { + NSUUID *peripheralUuid = peripheral.identifier; + NSData *rpi = [_peripheralRPIs objectForKey:peripheralUuid]; + if (rpi == nil) { + [_peripheralRPIs setObject:characteristic.value forKey:peripheralUuid]; + } + else if (![rpi isEqualToData:characteristic.value]) { + // update existing record + [_peripheralRPIs setObject:characteristic.value forKey:peripheralUuid]; + + NSLog(@"ExposurePlugin: Connected iOS peripheral RPI changed"); + ExposureRecord *record = [_iosExposures objectForKey:peripheralUuid]; + if (record != nil) { + [_iosExposures removeObjectForKey:peripheralUuid]; + [self updateExposuresTimer]; + } + if ((rpi != nil) && (record != nil)) { + [self notifyExposure:record rpi:rpi peripheralUuid:peripheralUuid]; + } + + NSTimeInterval currentTimestamp = [[[NSDate alloc] init] timeIntervalSince1970]; + record = [[ExposureRecord alloc] initWithTimestamp:currentTimestamp rssi:record.rssi]; + [_iosExposures setObject:record forKey:peripheralUuid]; + [self updateExposuresTimer]; + } + } +} + +- (void)peripheral:(CBPeripheral *)peripheral didReadRSSI:(NSNumber *)RSSI error:(NSError *)error { + NSLog(@"ExposurePlugin: CBPeripheral didReadRSSI"); + if (error == nil) { + [self logiOSExposure:peripheral.identifier rssi:RSSI.intValue]; + } +} + +- (void)peripheral:(CBPeripheral *)peripheral didUpdateNotificationStateForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error { +} + +#pragma mark Location Monitor + +- (void)startLocationManager { + if (_locationManager == nil) { + _locationManager = [[CLLocationManager alloc] init]; + _locationManager.delegate = self; + _locationManager.distanceFilter = kCLDistanceFilterNone; + _locationManager.desiredAccuracy = kCLLocationAccuracyBest; + _locationManager.pausesLocationUpdatesAutomatically = NO; + _locationManager.allowsBackgroundLocationUpdates = YES; + [self startBeaconRanging]; + [self startLocationMonitor]; + } +} + +- (void)stopLocationManager { + if (_locationManager != nil) { + [self stopBeaconRanging]; + [self stopLocationMonitor]; + _locationManager.delegate = nil; + _locationManager = nil; + } +} + +#pragma mark Beacon Ranging + +// +// http://www.davidgyoungtech.com/2020/05/07/hacking-the-overflow-area +// + +- (void)startBeaconRanging { + if ((_locationManager != nil) && (_beaconRegion == nil) && self.canBeaconRanging) { + _beaconRegion = [[CLBeaconRegion alloc] initWithProximityUUID:[[NSUUID alloc] initWithUUIDString:kRangingBeaconUuid] identifier:kRangingBeaconUuid]; + [_locationManager startRangingBeaconsInRegion:_beaconRegion]; + } +} + +- (void)stopBeaconRanging { + if ((_locationManager != nil) && (_beaconRegion != nil)) { + [_locationManager stopRangingBeaconsInRegion:_beaconRegion]; + _beaconRegion = nil; + } +} + +- (bool)isBeaconRangingStarted { + return (_locationManager != nil) && (_beaconRegion != nil); +} + +- (void)updateBeaconRanging { + if (self.canBeaconRanging && !self.isBeaconRangingStarted) { + [self startBeaconRanging]; + } + else if (!self.canBeaconRanging && self.isBeaconRangingStarted) { + [self stopBeaconRanging]; + } +} + +- (bool)canBeaconRanging { + return self.canLocationMonitor && + [CLLocationManager isMonitoringAvailableForClass:[CLBeaconRegion class]]; +} + + +#pragma mark Location & Heading Monitor + +- (void)startLocationMonitor { + if ((_locationManager != nil) && !_monitoringLocation && self.canLocationMonitor) { + [_locationManager startUpdatingLocation]; + [_locationManager startUpdatingHeading]; + [_locationManager startMonitoringSignificantLocationChanges]; + _monitoringLocation = YES; + } +} + +- (void)stopLocationMonitor { + if ((_locationManager != nil) && _monitoringLocation) { + [_locationManager stopUpdatingLocation]; + [_locationManager stopUpdatingHeading]; + _monitoringLocation = NO; + } +} + +- (bool)isLocationMonitorStarted { + return (_locationManager != nil) && _monitoringLocation; +} + +- (void)updateLocationMonitor { + if (self.canLocationMonitor && !self.isLocationMonitorStarted) { + [self startLocationMonitor]; + } + else if (!self.canLocationMonitor && self.isLocationMonitorStarted) { + [self stopLocationMonitor]; + } +} + +- (bool)canLocationMonitor { + return + [CLLocationManager locationServicesEnabled] && + ( + ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusAuthorizedAlways) || + ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusAuthorizedWhenInUse) + ); +} + +#pragma mark CLLocationManagerDelegate + +- (void)locationManager:(CLLocationManager*)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status { + NSLog(@"ExposurePlugin didChangeAuthorizationStatus: %@", @(status)); + [self updateBeaconRanging]; + [self updateLocationMonitor]; +} + +- (void)locationManager:(CLLocationManager *)manager didRangeBeacons:(NSArray*)beacons inRegion:(CLBeaconRegion *)clBeaconRegion { + //NSLog(@"ExposurePlugin didRangeBeacons:[<%@>] inRegion: %@", @(beacons.count), clBeaconRegion.identifier); + [self updateLocalNotificationRequest]; +} + +- (void)locationManager:(CLLocationManager *)manager rangingBeaconsDidFailForRegion:(CLBeaconRegion *)clBeaconRegion withError:(NSError *)error { + //NSLog(@"ExposurePlugin rangingBeaconsDidFailForRegion: %@ withError: %@", clBeaconRegion.identifier, error.localizedDescription); +} + +- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { +// UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; +// content.body = @"didUpdateLocations"; +// content.sound = nil; +// +// UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:kLocalNotificationId content:content trigger:nil]; +// [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:request withCompletionHandler:^(NSError* error) { +// }]; +} + +#pragma mark Local Notifications + +- (void)updateLocalNotificationRequest { + bool shouldHaveLocalNotificationRequest = + (UIApplication.sharedApplication.applicationState == UIApplicationStateBackground) && + (UIScreen.mainScreen.brightness == 0) && + (0 < _localNotificationInterval); + if (shouldHaveLocalNotificationRequest) { + NSTimeInterval currentTimeInterval = [[[NSDate alloc] init] timeIntervalSince1970]; + if (_lastLocalNotificationTime == 0) { + NSLog(@"Exposure: scheduling a local notification"); + _lastLocalNotificationTime = currentTimeInterval; + } + else if (_localNotificationInterval <= (currentTimeInterval - _lastLocalNotificationTime)) { + NSLog(@"Exposure: triggering a local notification"); + _lastLocalNotificationTime = currentTimeInterval; + [self scheduleLocalNotificationRequest]; + } + } + else if (0 < _lastLocalNotificationTime) { + NSLog(@"Exposure: stopping local notifications"); + _lastLocalNotificationTime = 0; + } +} + +- (void)scheduleLocalNotificationRequest { + UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; + content.body = @"Checking for exposures..."; + content.sound = nil; + + UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:kLocalNotificationId content:content trigger:nil]; + [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:request withCompletionHandler:^(NSError* error) { + //UIScreen.mainScreen.brightness = 0.01; + }]; +} + +#pragma mark App Livecycle Events + +- (void)connectAppLiveCycleEvents { + NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; + [notificationCenter addObserver:self selector:@selector(applicationDidEnterBackground) name:UIApplicationDidEnterBackgroundNotification object:nil]; + [notificationCenter addObserver:self selector:@selector(applicationWillEnterForeground) name:UIApplicationWillEnterForegroundNotification object:nil]; + [notificationCenter addObserver:self selector:@selector(applicationWillResignActive) name:UIApplicationWillResignActiveNotification object:nil]; + [notificationCenter addObserver:self selector:@selector(applicationDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; +} + +- (void)disconnectAppLiveCycleEvents { + NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; + [notificationCenter removeObserver:self name:UIApplicationDidEnterBackgroundNotification object:nil]; + [notificationCenter removeObserver:self name:UIApplicationWillEnterForegroundNotification object:nil]; + [notificationCenter removeObserver:self name:UIApplicationWillResignActiveNotification object:nil]; + [notificationCenter removeObserver:self name:UIApplicationDidBecomeActiveNotification object:nil]; + +} + +- (void)applicationDidEnterBackground { + if (_bgTaskId == UIBackgroundTaskInvalid) { + __weak typeof(self) weakSelf = self; + _bgTaskId = [UIApplication.sharedApplication beginBackgroundTaskWithExpirationHandler:^{ + weakSelf.bgTaskId = UIBackgroundTaskInvalid; + }]; + } +} + +- (void)applicationWillEnterForeground { + if (_bgTaskId != UIBackgroundTaskInvalid) { + [UIApplication.sharedApplication endBackgroundTask:_bgTaskId]; + _bgTaskId = UIBackgroundTaskInvalid; + } +} + +- (void)applicationWillResignActive { + [UIApplication.sharedApplication beginReceivingRemoteControlEvents]; + if ((_locationManager != nil) && self.canLocationMonitor && _monitoringLocation) { + [_locationManager startUpdatingLocation]; + [_locationManager startUpdatingHeading]; + [_locationManager startMonitoringSignificantLocationChanges]; + } +} + +- (void)applicationDidBecomeActive { + [UIApplication.sharedApplication endReceivingRemoteControlEvents]; +} + + +#pragma mark Settings + +- (void)initSettings:(NSDictionary*)settings { + _exposureTimeoutInterval = (settings != nil) ? [settings inaDoubleForKey:kExposureTimeoutIntervalSettingName defaults:kExposureTimeoutInterval] : kExposureTimeoutInterval; + _exposurePingInterval = (settings != nil) ? [settings inaDoubleForKey:kExposurePingIntervalSettingName defaults:kExposurePingInterval] : kExposurePingInterval; + _exposureProcessInterval = (settings != nil) ? [settings inaDoubleForKey:kExposureProcessIntervalSettingName defaults:kExposureProcessInterval] : kExposureProcessInterval; + _localNotificationInterval = (settings != nil) ? [settings inaDoubleForKey:kLocalNotificationIntervalSettingName defaults:kLocalNotificationInterval] : kLocalNotificationInterval; + _exposureMinDuration = (settings != nil) ? [settings inaDoubleForKey:kExposureMinDurationSettingName defaults:kExposureMinDuration] : kExposureMinDuration; + _exposureMinRssi = (settings != nil) ? [settings inaIntForKey: kExposureMinRssiSettingName defaults:kMinRssi] : kMinRssi; +} + +#pragma mark RPI + +- (void)initRPI { + _rpi = [self generateRPI]; + + if (_rpiTimer == nil) { + _rpiTimer = [NSTimer scheduledTimerWithTimeInterval:kRPIRefreshInterval target:self selector:@selector(refreshRPI) userInfo:nil repeats:YES]; + } +} + +- (void)refreshRPI { + _rpi = [self generateRPI]; + + [self updatePeripheral]; +} + +- (void)clearRPI { + if (_rpi != nil) { + _rpi = nil; + } + if (_rpiTimer != nil) { + [_rpiTimer invalidate]; + _rpiTimer = nil; + } +} + +- (NSData*)generateRPI { + NSTimeInterval currentTimestamp = [[[NSDate alloc] init] timeIntervalSince1970]; + + /* obtain ENInvertalNumber and timestamp i for teks generation */ + uint32_t ENInvertalNumber = currentTimestamp / kRPIRefreshInterval; + + /* _i : time aligned with TEKRollingPeriod */ + uint32_t _i = (ENInvertalNumber / kTEKRollingPeriod) * kTEKRollingPeriod; + uint32_t _iExpire = _i + kTEKRollingPeriod; + + /* if new day, generate a new tek */ + /* if in the rest of the day, using last valid TEK */ + if (_teks != nil) { + NSNumber *iMax = [self maxTEKsI]; + if (iMax != nil) { + TEKRecord *maxRecord = [_teks objectForKey:iMax]; + if (maxRecord.expire == (_iExpire)) { + _i = [iMax intValue]; + } else + { + _i = ENInvertalNumber; + } + } + } + + //NSLog(@"ExposurePlugin: ENIntervalNumber: %d, i: %d", ENInvertalNumber, _i); + + /* generate tek each day, and store 14 of them in a database with timestamp i */ + TEKRecord* tekRecord = [_teks objectForKey:[NSNumber numberWithInt: _i]]; + if (tekRecord == nil) { + UInt8 bytes[16]; + int status = SecRandomCopyBytes(kSecRandomDefault, (sizeof bytes)/(sizeof bytes[0]), &bytes); + NSData *tek = (status == errSecSuccess) ? [NSData dataWithBytes:bytes length:sizeof(bytes)] : nil; + if (tek != nil) { + tekRecord = [[TEKRecord alloc] initWithTEK:tek expire:_iExpire]; + [_teks setObject:tekRecord forKey:[NSNumber numberWithInt: _i]]; + + if (_teks.count > 15) { // [0 - 14] gives 15 entries alltogether + uint32_t thresholdI = _i - 14 * kTEKRollingPeriod; + for (NSNumber *tekI in _teks.allKeys) { + if ([tekI intValue] < thresholdI) { + [_teks removeObjectForKey:tekI]; + } + } + } + + [self.class saveTEK2sToStorage:_teks]; + + NSInteger timestamp = ((NSInteger)_i) * kRPIRefreshInterval * 1000; // in miliseconds + NSInteger expirestamp = ((NSInteger)_iExpire) * kRPIRefreshInterval * 1000; // in miliseconds + [self notifyTEK:tek timestamp:timestamp expirestamp:expirestamp]; + } + else { + //NSLog(@"ExposurePlugin: Failed to generate new tek for i: %d", _i); + } + } + //NSLog(@"ExposurePlugin: Obtain tek {%@}", tek); + + NSData* rpi = [self generateRPIForIntervalNumber:ENInvertalNumber tek:tekRecord.tek]; + [self notifyRPI:rpi tek:tekRecord.tek updateType:(_rpi != nil) ? @"update" : @"init" timestamp:(currentTimestamp * 1000.0) _i:_i ENInvertalNumber:ENInvertalNumber]; + return rpi; +} + +- (NSData*)generateRPIForIntervalNumber:(uint32_t)ENInvertalNumber tek:(NSData*)tek { + //NSLog(@"ExposurePlugin: Refresh TEK"); + + /* generate rpik and aemk based on tek */ + NSData* rpik = [HKDFKit deriveKey:tek info:[@"EN-RPIK" dataUsingEncoding:NSUTF8StringEncoding] salt:nil outputSize:16]; + //NSLog(@"ExposurePlugin: Obtain rpik {%@}", rpik); + + NSData* aemk = [HKDFKit deriveKey:tek info:[@"EN-AEMK" dataUsingEncoding:NSUTF8StringEncoding] salt:nil outputSize:16]; + //NSLog(@"ExposurePlugin: Obtain aemk {%@}", aemk); + + /* generate paddedData for rpi message */ + NSData* paddedData_0_5 = [@"EN-RPI" dataUsingEncoding:NSUTF8StringEncoding]; + + const char char_pd_6_11[6] = "\x00\x00\x00\x00\x00\x00"; + NSData *paddedData_6_11 = [NSData dataWithBytes:char_pd_6_11 length:6]; + + uint32_t reverseENIntervalNumber = 0; + reverseENIntervalNumber = CFSwapInt32HostToBig(ENInvertalNumber); + NSData *paddedData_12_15 = [NSData dataWithBytes: &reverseENIntervalNumber length: 4]; + + NSMutableData* paddedData = [NSMutableData data]; + [paddedData appendData:paddedData_0_5]; + [paddedData appendData:paddedData_6_11]; + [paddedData appendData:paddedData_12_15]; + //NSLog(@"ExposurePlugin: PaddedData {%@}", paddedData); + + /* generate encrypted en_rpi with AES-128 */ + NSError *error = nil; + NSData* en_rpi = uiuc_aes_operation(paddedData, kCCEncrypt, kCCModeECB, kCCAlgorithmAES, ccNoPadding, kCCKeySizeAES128, nil, rpik, &error); + //NSLog(@"ExposurePlugin: RPI_en {%@}", en_rpi); + + /* generate metadata for aem message */ + NSData *metadata = [NSData dataWithBytes:(char[]){0x00,0x00,0x00,0x00} length:4]; + //NSLog(@"ExposurePlugin: metadata {%@}", metadata); + + /* generate encrypted en_aem with AES-128-CTR */ + NSData* en_aem = uiuc_aes_operation(metadata, kCCEncrypt, kCCModeCTR, kCCAlgorithmAES, ccNoPadding, kCCKeySizeAES128, en_rpi, aemk, &error); + //NSLog(@"ExposurePlugin: AEM_en {%@}", en_aem); + + /* contaticate en_rpi and en_aem to form the payload */ + NSMutableData* ble_load = [NSMutableData data]; + [ble_load appendData:en_rpi]; + [ble_load appendData:en_aem]; + //NSLog(@"ExposurePlugin: BLE_Payload {%@}", ble_load); + return ble_load; +} + +- (NSNumber*)maxTEKsI { + NSNumber *result = nil; + if (_teks != nil) { + for (NSNumber *i in _teks) { + if ((result == nil) || ([result intValue] < [i intValue])) { + result = i; + } + } + } + return result; +} + +- (NSArray*)teksList { + NSMutableArray *teksList = [[NSMutableArray alloc] init]; + for (NSNumber *tekKey in _teks) { + NSInteger _i = [tekKey intValue]; + TEKRecord *tekRecord = [_teks objectForKey:tekKey]; + NSInteger timestamp = ((NSInteger)_i) * kRPIRefreshInterval * 1000; // in miliseconds + NSInteger expirestamp = ((NSInteger)tekRecord.expire) * kRPIRefreshInterval * 1000; // in miliseconds + NSString *tekString = [tekRecord.tek base64EncodedStringWithOptions:0]; + [teksList addObject:@{ + kTEKTimestampParamName : [NSNumber numberWithInteger:timestamp], + kTEKExpirestampParamName : [NSNumber numberWithInteger:expirestamp], + kTEKValueParamName: tekString ?: [NSNull null], + }]; + } + return teksList; +} + +- (NSDictionary*)rpisForTek:(NSData*)tek timestamp:(NSInteger)timestamp expirestamp:(NSInteger)expirestamp { + NSTimeInterval timestampInterval = (timestamp / 1000.0); + NSTimeInterval expirestampInterval = (expirestamp / 1000.0); + + /* obtain start/endENInvertalNumber and timestamp i for teks generation */ + uint32_t startENInvertalNumber = timestampInterval / kRPIRefreshInterval; + uint32_t endENInvertalNumber = expirestampInterval / kRPIRefreshInterval; + + NSMutableDictionary *rpis = [[NSMutableDictionary alloc] init]; + for (uint32_t intervalIndex = startENInvertalNumber; intervalIndex <= endENInvertalNumber; intervalIndex++) { + NSData *rpi = [self generateRPIForIntervalNumber:intervalIndex tek:tek]; + NSString *rpiString = [rpi base64EncodedStringWithOptions:0]; + NSInteger interval = (((NSInteger)intervalIndex) * kRPIRefreshInterval * 1000); + [rpis setObject:[NSNumber numberWithInteger:interval] forKey:rpiString]; + } + return rpis; +} + +- (void)updateTEKExpireTime { + if (_teks != nil) { + NSNumber * current_i = [self maxTEKsI]; + TEKRecord* tekRecord = [_teks objectForKey:current_i]; + NSTimeInterval currentTimestamp = [[[NSDate alloc] init] timeIntervalSince1970]; + tekRecord.expire = currentTimestamp / kRPIRefreshInterval; + [self.class saveTEK2sToStorage:_teks]; + } +} + ++ (void)saveTEK1sToStorage:(NSDictionary*)teks { + if (teks != nil) { + NSMutableDictionary *storageTeks = [[NSMutableDictionary alloc] init]; + for (NSNumber *_i in teks) { + NSData *value = [teks objectForKey:_i]; + NSString *storageKey = [_i stringValue]; + NSString *storageValue = [value base64EncodedStringWithOptions:0]; + if ((storageKey != nil) && (storageValue != nil)) { + [storageTeks setObject:storageValue forKey:storageKey]; + } + } + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:storageTeks options:0 error:NULL]; + uiucSecStorageData(kExposureTEK1, kExposureTEK1, jsonData); + } + else { + uiucSecStorageData(kExposureTEK1, kExposureTEK1, [NSNull null]); + } +} + ++ (NSMutableDictionary*)loadTEK1sFromStorage { + NSMutableDictionary* teks = [[NSMutableDictionary alloc] init]; + NSData *jsonData = uiucSecStorageData(kExposureTEK1, kExposureTEK1, nil); + if (jsonData != nil) { + NSDictionary *storageTeks = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:NULL]; + if ([storageTeks isKindOfClass:[NSDictionary class]]) { + for (NSString *storageKey in storageTeks) { + NSString *storageValue = [storageTeks inaStringForKey:storageKey]; + NSData *value = [[NSData alloc] initWithBase64EncodedString:storageValue options:0]; + if (value != nil) { + [teks setObject:value forKey:[NSNumber numberWithInt:[storageKey intValue]]]; + } + } + } + } + return teks; +} + ++ (void)saveTEK2sToStorage:(NSDictionary*)teks { + if (teks != nil) { + NSMutableDictionary *storageTeks = [[NSMutableDictionary alloc] init]; + for (NSNumber *_i in teks) { + TEKRecord *record = [teks objectForKey:_i]; + NSString *storageKey = [_i stringValue]; + NSDictionary *storageValue = record.toJson; + if ((storageKey != nil) && (storageValue != nil)) { + [storageTeks setObject:storageValue forKey:storageKey]; + } + } + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:storageTeks options:0 error:NULL]; + uiucSecStorageData(kExposureTEK2, kExposureTEK2, jsonData); + } + else { + uiucSecStorageData(kExposureTEK2, kExposureTEK2, [NSNull null]); + } +} + ++ (NSMutableDictionary*)loadTEK2sFromStorage { + NSMutableDictionary* teks = [[NSMutableDictionary alloc] init]; + NSData *jsonData = uiucSecStorageData(kExposureTEK2, kExposureTEK2, nil); + if (jsonData != nil) { + NSDictionary *storageTeks = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:NULL]; + if ([storageTeks isKindOfClass:[NSDictionary class]]) { + for (NSString *storageKey in storageTeks) { + NSDictionary *storageValue = [storageTeks inaDictForKey:storageKey]; + TEKRecord *record = [TEKRecord fromJson:storageValue]; + if (record != nil) { + [teks setObject:record forKey:[NSNumber numberWithInt:[storageKey intValue]]]; + } + } + } + } + else { + NSDictionary* teks1 = [self loadTEK1sFromStorage]; + if (teks1 != nil) { + for (NSNumber *i in teks1) { + NSData *tek = [teks1 inaDataForKey:i]; + int expire = [i intValue] + kTEKRollingPeriod; + [teks setObject:[[TEKRecord alloc] initWithTEK:tek expire:expire] forKey:i]; + } + } + } + return teks; +} + +#pragma mark Exposure + +- (void)logiOSExposure:(NSUUID*)peripheralUuid rssi:(int)rssi { + NSLog(@"ExposurePlugin: {%@} / rssi: %@", peripheralUuid, @(rssi)); + + NSTimeInterval currentTimestamp = [[[NSDate alloc] init] timeIntervalSince1970]; + ExposureRecord *record = [_iosExposures objectForKey:peripheralUuid]; + if (record == nil) { + // Create new + NSLog(@"ExposurePlugin: {%@} registred", peripheralUuid); + record = [[ExposureRecord alloc] initWithTimestamp:currentTimestamp rssi:rssi]; + [_iosExposures setObject:record forKey:peripheralUuid]; + [self updateExposuresTimer]; + } + else { + // Update existing + [record updateTimestamp:currentTimestamp rssi:rssi]; + } + + NSData *rpi = [_peripheralRPIs objectForKey:peripheralUuid]; + [self notifyExposureTick:rpi rssi:rssi peripheralUuid:peripheralUuid]; + [self notifyRSSI:rssi rpi:rpi timestamp:(currentTimestamp * 1000.0) peripheralUuid:peripheralUuid]; +} + +- (void)logAndroidExposure:(NSData*)rpi rssi:(int)rssi { + NSLog(@"ExposurePlugin: {%@} / rssi: %@", rpi, @(rssi)); + + NSTimeInterval currentTimestamp = [[[NSDate alloc] init] timeIntervalSince1970]; + ExposureRecord *record = [_androidExposures objectForKey:rpi]; + if (record == nil) { + // Create new + NSLog(@"ExposurePlugin: {%@} registred", rpi); + record = [[ExposureRecord alloc] initWithTimestamp:currentTimestamp rssi:rssi]; + [_androidExposures setObject:record forKey:rpi]; + [self updateExposuresTimer]; + } + else { + // Update existing + [record updateTimestamp:currentTimestamp rssi:rssi]; + } + + [self notifyExposureTick:rpi rssi:rssi peripheralUuid:nil]; + [self notifyRSSI:rssi rpi:rpi timestamp:(currentTimestamp * 1000.0) peripheralUuid:nil]; +} + +- (void)updateExposuresTimer { + NSInteger exposuresCount = _iosExposures.count + _androidExposures.count; + if ((0 < exposuresCount) && (_exposuresTimer == nil)) { + _exposuresTimer = [NSTimer scheduledTimerWithTimeInterval:_exposureProcessInterval target:self selector:@selector(processExposures) userInfo:nil repeats:YES]; + } + else if ((exposuresCount == 0) && (_exposuresTimer != nil)) { + [_exposuresTimer invalidate]; + _exposuresTimer = nil; + } +} + +- (void)processExposures { + + NSLog(@"ExposurePlugin: Processing exposures"); + NSTimeInterval currentTimestamp = [[[NSDate alloc] init] timeIntervalSince1970]; + + // collect all iOS expired records (not updated after _exposureTimeoutInterval) + NSMutableSet *expiredPeripheralUuid = nil; + for (NSUUID *peripheralUuid in _iosExposures) { + ExposureRecord *record = [_iosExposures objectForKey:peripheralUuid]; + NSTimeInterval lastHeardInterval = currentTimestamp - record.timeUpdated; + + if (_exposureTimeoutInterval <= lastHeardInterval) { + NSLog(@"ExposurePlugin: {%@} expired", peripheralUuid); + if (expiredPeripheralUuid == nil) { + expiredPeripheralUuid = [[NSMutableSet alloc] init]; + } + [expiredPeripheralUuid addObject:peripheralUuid]; + } + else if (_exposurePingInterval <= lastHeardInterval) { + NSLog(@"ExposurePlugin: {%@} ping", peripheralUuid); + CBPeripheral *peripheral = [_peripherals objectForKey:peripheralUuid]; + [peripheral readRSSI]; + } + } + + if (expiredPeripheralUuid != nil) { + // remove expired records from _iosExposures + for (NSUUID *peripheralUuid in expiredPeripheralUuid) { + [self disconnectPeripheralWithUuid:peripheralUuid]; + } + } + + // collect all Android expired records (not updated after _exposureTimeoutInterval) + NSMutableSet *expiredRPIs = nil; + for (NSData *rpi in _androidExposures) { + ExposureRecord *record = [_androidExposures objectForKey:rpi]; + NSTimeInterval lastHeardInterval = currentTimestamp - record.timeUpdated; + + if (_exposureTimeoutInterval <= lastHeardInterval) { + NSLog(@"ExposurePlugin: {%@} expired", rpi); + if (expiredRPIs == nil) { + expiredRPIs = [[NSMutableSet alloc] init]; + } + [expiredRPIs addObject:rpi]; + } + else if (_exposurePingInterval <= lastHeardInterval) { + NSLog(@"ExposurePlugin: {%@} ping", rpi); + NSUUID *peripheralUuid = [self peripheralUuidForRPI:rpi]; + CBPeripheral *peripheral = [_peripherals objectForKey:peripheralUuid]; + [peripheral readRSSI]; + } + } + + if (expiredRPIs != nil) { + // remove expired records from _androidExposures + for (NSData *rpi in expiredRPIs) { + [self _removeAndroidRpi:rpi]; + } + } +} + +- (void)clearExposures { + for (NSUUID *peripheralUuid in _iosExposures.allKeys) { + [self disconnectPeripheralWithUuid:peripheralUuid]; + } + for (NSData *rpi in _androidExposures.allKeys) { + [self _removeAndroidRpi:rpi]; + } +} + +#pragma mark Notifications + +- (void)notifyExposure:(ExposureRecord*)record rpi:(NSData*)rpi peripheralUuid:(NSUUID*)peripheralUuid { + if (_exposureMinDuration <= record.durationInterval) { + NSString *rpiString = [rpi base64EncodedStringWithOptions:0]; + NSTimeInterval currentTimeInterval = [[[NSDate alloc] init] timeIntervalSince1970]; + NSLog(@"ExposurePlugin: Report Exposure: rpi: {%@} duration: %@", rpiString, @(record.duration)); + + [_methodChannel invokeMethod:kExposureNotificationName arguments:@{ + kExposureTimestampParamName: [NSNumber numberWithInteger:record.timestampCreated], + kExposureRPIParamName: rpiString ?: [NSNull null], + kExposureDurationParamName: [NSNumber numberWithInteger:record.duration], + + @"peripheralUuid": [peripheralUuid UUIDString] ?: [NSNull null], + @"isiOSRecord": [NSNumber numberWithBool:(peripheralUuid != nil)], + @"endTimestamp": [NSNumber numberWithInteger:currentTimeInterval * 1000.0], + }]; + } +} + +- (void)notifyExposureTick:(NSData*)rpi rssi:(int)rssi peripheralUuid:(NSUUID*)peripheralUuid { + + // Do not allow more than 1 notification per second + NSTimeInterval currentTimeInterval = [[[NSDate alloc] init] timeIntervalSince1970]; + if (kExposureNotifyTickInterval <= (currentTimeInterval - _lastNotifyExposireThickTime)) { + + NSString *rpiString = (rpi != nil) ? [rpi base64EncodedStringWithOptions:0] : nil; + NSInteger currentTimestamp = (NSInteger)(currentTimeInterval * 1000.0); + + [_methodChannel invokeMethod:kExposureThickNotificationName arguments:@{ + kExposureTimestampParamName: [NSNumber numberWithInteger:currentTimestamp], + kExposureRPIParamName: rpiString ?: @"...", + kExposureRSSIParamName: [NSNumber numberWithInteger:rssi], + @"peripheralUuid": [peripheralUuid UUIDString] ?: [NSNull null], + }]; + + _lastNotifyExposireThickTime = currentTimeInterval; + } + +} + +- (void)notifyTEK:(NSData*)tek timestamp:(NSInteger)timestamp expirestamp:(NSInteger)expirestamp { + NSString *tekString = [tek base64EncodedStringWithOptions:0]; + NSLog(@"ExposurePlugin: Report TEK: {%@}", tekString); + [_methodChannel invokeMethod:kTEKNotificationName arguments:@{ + kTEKTimestampParamName: [NSNumber numberWithInteger:timestamp], // in milliseconds + kTEKExpirestampParamName: [NSNumber numberWithInteger:expirestamp], // in milliseconds + kTEKValueParamName: tekString ?: [NSNull null], + }]; +} + +- (void)notifyRPI:(NSData*)rpi tek:(NSData*)tek updateType:(NSString*)updateType timestamp:(NSInteger)timestamp _i:(uint32_t)_i ENInvertalNumber:(uint32_t)ENInvertalNumber { + NSString *rpiString = [rpi base64EncodedStringWithOptions:0]; + NSString *tekString = [tek base64EncodedStringWithOptions:0]; + NSLog(@"ExposurePlugin: Report RPI: {%@}", rpiString); + [_methodChannel invokeMethod:kRPILogMethodName arguments:@{ + kExposureTimestampParamName: [NSNumber numberWithInteger:timestamp], + @"updateType": updateType ?: [NSNull null], + @"rpi": rpiString ?: [NSNull null], + @"tek": tekString ?: [NSNull null], + @"_i": [NSNumber numberWithInteger:_i], + @"ENInvertalNumber": [NSNumber numberWithInteger:ENInvertalNumber], + }]; +} + +- (void)notifyRSSI:(int)rssi rpi:(NSData*)rpi timestamp:(NSInteger)timestamp peripheralUuid:(NSUUID*)peripheralUuid { + NSString *rpiString = [rpi base64EncodedStringWithOptions:0]; + [_methodChannel invokeMethod:kRSSILogMethodName arguments:@{ + kExposureTimestampParamName: [NSNumber numberWithInteger:timestamp], + @"rpi": rpiString ?: [NSNull null], + @"rssi": [NSNumber numberWithInt:rssi], + @"isiOSRecord": [NSNumber numberWithBool:(peripheralUuid != nil)], + @"address": [peripheralUuid UUIDString] ?: [NSNull null], + }]; +} + +@end + +//////////////////////////////////// +// ExposureRecord + +@interface ExposureRecord() { + NSTimeInterval _timeCreated; + NSTimeInterval _timeUpdated; + int _lastRSSI; + NSTimeInterval _durationInterval; +} +@end + +@implementation ExposureRecord + +- (instancetype)initWithTimestamp:(NSTimeInterval)timestamp rssi:(int)rssi { + if (self = [super init]) { + _lastRSSI = rssi; + _durationInterval = 0; + _timeCreated = _timeUpdated = timestamp; + } + return self; +} + +- (void)updateTimestamp:(NSTimeInterval)timestamp rssi:(int)rssi { + if ((ExposurePlugin.sharedInstance.exposureMinRssi <= _lastRSSI) && (_lastRSSI != kNoRssi)) { + _durationInterval += (timestamp - _timeUpdated); + } + _lastRSSI = rssi; + _timeUpdated = timestamp; +} + +- (NSInteger)timestampCreated { + return (NSInteger)(_timeCreated * 1000.0); // in milliseconds +} + +- (NSTimeInterval)timeUpdated { + return _timeUpdated; +} + +- (NSInteger)duration { + return (NSInteger)(_durationInterval * 1000.0); // in milliseconds +} + +- (NSTimeInterval)durationInterval { + return _durationInterval; // in seconds +} + +- (int)rssi { + return _lastRSSI; +} + +@end + +//////////////////////////////////// +// TEKRecord + +@implementation TEKRecord +//@property (nonatomic) int expire; +//@property (nonatomic) NSData* tek; + +- (instancetype)initWithTEK:(NSData*)tek expire:(int)expire { + if (self = [super init]) { + _tek = tek; + _expire = expire; + } + return self; + +} + ++ (instancetype)fromJson:(NSDictionary*)json { + return (json != nil) ? [[TEKRecord alloc] + initWithTEK: [[NSData alloc] initWithBase64EncodedString:[json inaStringForKey:@"tek"] options:0] + expire: [json inaIntForKey:@"expire"]] : nil; +} + +- (NSDictionary*)toJson { + return @{ + @"tek": [_tek base64EncodedStringWithOptions:0] ?: [NSNull null], + @"expire": @(_expire) + }; +} + +@end diff --git a/ios/Runner/FlutterCompletion.h b/ios/Runner/FlutterCompletion.h new file mode 100644 index 00000000..462fc487 --- /dev/null +++ b/ios/Runner/FlutterCompletion.h @@ -0,0 +1,25 @@ +// +// FlutterCompletion.h +// Runner +// +// Created by Mihail Varbanov on 9/17/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +typedef void (^FlutterCompletion)(id returnValue); + +@protocol FlutterCompletionHandler +@property (nonatomic) FlutterCompletion completionHandler; +@end diff --git a/ios/Runner/GalleryPlugin.h b/ios/Runner/GalleryPlugin.h new file mode 100644 index 00000000..5a14c737 --- /dev/null +++ b/ios/Runner/GalleryPlugin.h @@ -0,0 +1,29 @@ +//// GalleryPlugin.h +// Runner +// +// Created by Mladen Dryankov on 11.08.20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GalleryPlugin : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Runner/GalleryPlugin.m b/ios/Runner/GalleryPlugin.m new file mode 100644 index 00000000..acf8d8be --- /dev/null +++ b/ios/Runner/GalleryPlugin.m @@ -0,0 +1,168 @@ +//// GalleryPlugin.m +// Runner +// +// Created by Mladen Dryankov on 11.08.20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "GalleryPlugin.h" +#import + +#import "NSDictionary+InaTypedValue.h" + + +typedef void(^GalleryPluginCompletionHandler)(PHAssetCollection *assetCollection); + +static NSString* const kGalleryPluginMethodChanelName = @"edu.illinois.covid/gallery"; + +static NSString* const kGalleryPluginMethodName = @"store"; +static NSString* const kGalleryPluginParamBytes = @"bytes"; +static NSString* const kGalleryPluginParamName = @"name"; + +@interface GalleryPlugin(){ + FlutterMethodChannel *channel; + FlutterResult result; + + FlutterMethodCall *storeCall; + FlutterResult storeCallResult; +} + +@end + +@implementation GalleryPlugin + +static GalleryPlugin *g_Instance = nil; + ++ (void)registerWithRegistrar:(NSObject *)registrar { + FlutterMethodChannel *channel = [FlutterMethodChannel methodChannelWithName:kGalleryPluginMethodChanelName binaryMessenger:registrar.messenger]; + GalleryPlugin *instance = [[GalleryPlugin alloc] initWithMethodChannel:channel]; + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (instancetype)init { + if (self = [super init]) { + if (g_Instance == nil) { + g_Instance = self; + } + } + return self; +} + +- (void)dealloc { + if (g_Instance == self) { + g_Instance = nil; + } +} + +- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)_channel { + if (self = [self init]) { + channel = _channel; + } + return self; +} + ++ (instancetype)sharedInstance { + return g_Instance; +} + +- (void)requestAuthorizationIfNeed{ + +} + +#pragma mark MethodCall + +- (void)handleStoreMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result requested:(bool)requested { + storeCall = call; storeCallResult = result; + + NSDictionary *params = [call.arguments isKindOfClass:[NSDictionary class]] ? call.arguments : nil; + NSString *name = [params inaStringForKey: kGalleryPluginParamName]; + FlutterStandardTypedData *flutterData = [params inaObjectForKey:kGalleryPluginParamBytes class:FlutterStandardTypedData.class]; + NSData *data = flutterData.data; + UIImage *image = data ? [UIImage imageWithData:data] : nil; + + if(name.length > 0 && image != nil){ + NSLog(@"GalleryPlugin: Invoke store image (name: %@, image:<....>)", name); + + if(PHPhotoLibrary.authorizationStatus != PHAuthorizationStatusAuthorized){ + if(PHPhotoLibrary.authorizationStatus == PHAuthorizationStatusNotDetermined){ + [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) { + [self handleStoreMethodCall:call result:result requested:true]; + }]; + return; + } + else{ + result([NSNumber numberWithBool:NO]); + } + } + + [self createAlbum:name completion:^(PHAssetCollection *assetCollection) { + if(assetCollection != nil){ + [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ + [PHAssetChangeRequest creationRequestForAssetFromImage:image]; + } completionHandler:^(BOOL success, NSError *error) { + if (success) { + result([NSNumber numberWithBool:YES]); + } + else { + NSLog(@"GalleryPlugin: Error on save image: %@", error); + result([NSNumber numberWithBool:NO]); + } + }]; + } else { + result([NSNumber numberWithBool:NO]); + } + }]; + } else { + NSLog(@"GalleryPlugin: Bad Data"); + result([NSNumber numberWithBool:NO]); + } +} + +- (PHAssetCollection*)fetchAssetCollectionForAlbum:(NSString*)albumName{ + PHFetchOptions *options = [PHFetchOptions new]; + options.predicate = [NSPredicate predicateWithFormat:@"title = %@", albumName]; + PHFetchResult *result = [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeAlbum subtype:PHAssetCollectionSubtypeAny options:options]; + return result.firstObject; +} + +- (void)createAlbum:(NSString*)albumName completion:(GalleryPluginCompletionHandler)completion{ + PHAssetCollection *album = [self fetchAssetCollectionForAlbum:albumName]; + if(album != nil){ + completion(album); + } + + [PHPhotoLibrary.sharedPhotoLibrary performChanges:^{ + [PHAssetCollectionChangeRequest creationRequestForAssetCollectionWithTitle: albumName]; + } + completionHandler:^(BOOL success, NSError * _Nullable error) { + if(success){ + PHAssetCollection *album = [self fetchAssetCollectionForAlbum:albumName]; + completion(album); + } else { + NSLog(@"GalleryPlugin: Error on create album: %@", error); + completion(nil); + } + }]; +} + +- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + if([kGalleryPluginMethodName isEqualToString: call.method]){ + [self handleStoreMethodCall:call result:result requested:false]; + } else { + result(nil); + } +} + +@end diff --git a/ios/Runner/GeneratedPluginRegistrant.h b/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 00000000..ed9a5c61 --- /dev/null +++ b/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +#ifndef GeneratedPluginRegistrant_h +#define GeneratedPluginRegistrant_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end + +NS_ASSUME_NONNULL_END +#endif /* GeneratedPluginRegistrant_h */ diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 00000000..0016092e --- /dev/null +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,152 @@ +// +// Generated file. Do not edit. +// + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import barcode_scan; +#endif + +#if __has_include() +#import +#else +@import connectivity; +#endif + +#if __has_include() +#import +#else +@import device_info; +#endif + +#if __has_include() +#import +#else +@import firebase_core; +#endif + +#if __has_include() +#import +#else +@import firebase_crashlytics; +#endif + +#if __has_include() +#import +#else +@import firebase_messaging; +#endif + +#if __has_include() +#import +#else +@import firebase_ml_vision; +#endif + +#if __has_include() +#import +#else +@import flutter_image_compress; +#endif + +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import flutter_native_timezone; +#endif + +#if __has_include() +#import +#else +@import fluttertoast; +#endif + +#if __has_include() +#import +#else +@import image_picker; +#endif + +#if __has_include() +#import +#else +@import location; +#endif + +#if __has_include() +#import +#else +@import package_info; +#endif + +#if __has_include() +#import +#else +@import path_provider; +#endif + +#if __has_include() +#import +#else +@import shared_preferences; +#endif + +#if __has_include() +#import +#else +@import sqflite; +#endif + +#if __has_include() +#import +#else +@import uni_links; +#endif + +#if __has_include() +#import +#else +@import url_launcher; +#endif + +#if __has_include() +#import +#else +@import webview_flutter; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [BarcodeScanPlugin registerWithRegistrar:[registry registrarForPlugin:@"BarcodeScanPlugin"]]; + [FLTConnectivityPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTConnectivityPlugin"]]; + [FLTDeviceInfoPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTDeviceInfoPlugin"]]; + [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FirebaseCrashlyticsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseCrashlyticsPlugin"]]; + [FLTFirebaseMessagingPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMessagingPlugin"]]; + [FLTFirebaseMlVisionPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMlVisionPlugin"]]; + [FlutterImageCompressPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterImageCompressPlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [FlutterNativeTimezonePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterNativeTimezonePlugin"]]; + [FluttertoastPlugin registerWithRegistrar:[registry registrarForPlugin:@"FluttertoastPlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; + [LocationPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocationPlugin"]]; + [FLTPackageInfoPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTPackageInfoPlugin"]]; + [FLTPathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTPathProviderPlugin"]]; + [FLTSharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTSharedPreferencesPlugin"]]; + [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; + [UniLinksPlugin registerWithRegistrar:[registry registrarForPlugin:@"UniLinksPlugin"]]; + [FLTURLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTURLLauncherPlugin"]]; + [FLTWebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTWebViewFlutterPlugin"]]; +} + +@end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 00000000..36de638a --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,118 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + es + zh + + CFBundleName + Safer Illinois + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLName + edu.illinois.covid.auth + CFBundleURLSchemes + + edu.illinois.covid + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + + NSAppleMusicUsageDescription + Allow Illinois to access Media Library. + NSBluetoothAlwaysUsageDescription + Bluetooth will be used to enable the COVID-19 exposure notification system. + NSBluetoothPeripheralUsageDescription + Bluetooth will be used to enable the COVID-19 exposure notification system. + NSCalendarsUsageDescription + Your calendar will be used to let you set reminders for important events. + NSCameraUsageDescription + Allow Illinois to access Camera. + NSContactsUsageDescription + Allow Illinois to access Contacts. + NSLocationAlwaysAndWhenInUseUsageDescription + Permission for Location Services is needed to enable Bluetooth-based Exposure Notification. No location data is used or stored within the app, and no location data leaves the users device. + NSLocationAlwaysUsageDescription + Permission for Location Services is needed to enable Bluetooth-based Exposure Notification. No location data is used or stored within the app, and no location data leaves the users device. + NSLocationUsageDescription + Permission for Location Services is needed to enable Bluetooth-based Exposure Notification. No location data is used or stored within the app, and no location data leaves the users device. + NSLocationWhenInUseUsageDescription + Permission for Location Services is needed to enable Bluetooth-based Exposure Notification. No location data is used or stored within the app, and no location data leaves the users device. + NSMicrophoneUsageDescription + Allow Illinois to access Microphone. + NSMotionUsageDescription + Allow Illinois to access Accelerometer. + NSPhotoLibraryAddUsageDescription + Allow Illinois to access Photo Library. + NSPhotoLibraryUsageDescription + Allow Illinois to access Photo Library. + NSRemindersUsageDescription + Your calendar will be used to let you set reminders for important events. + NSSpeechRecognitionUsageDescription + Allow Illinois to send data to Apple’s speech recognition servers. + UIBackgroundModes + + bluetooth-central + bluetooth-peripheral + fetch + location + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiresFullScreen + + UIRequiresFullScreen~ipad + + UIStatusBarStyle + UIStatusBarStyleLightContent + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + io.flutter.embedded_views_preview + YES + + diff --git a/ios/Runner/MapController.h b/ios/Runner/MapController.h new file mode 100644 index 00000000..c8769e37 --- /dev/null +++ b/ios/Runner/MapController.h @@ -0,0 +1,44 @@ +// +// MapController.h +// Runner +// +// Created by Mihail Varbanov on 7/11/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import "FlutterCompletion.h" +#import +#import + +@interface MapController : UIViewController { +} +@property (nonatomic, strong) NSDictionary* parameters; +@property (nonatomic, strong) FlutterCompletion completionHandler; + +@property (nonatomic, strong) GMSMapView* gmsMapView; +@property (nonatomic, strong) MPMapControl* mpMapControl; +@property (nonatomic, strong) UILabel* debugStatusLabel; + +@property (nonatomic, strong) CLLocationManager* clLocationManager; +@property (nonatomic, strong) CLLocation* clLocation; +@property (nonatomic, strong) NSError* clLocationError; + +- (instancetype)initWithParameters:(NSDictionary*)parameters completionHandler:(FlutterCompletion)completionHandler; + +- (void)layoutSubViews; + +- (void)notifyLocationUpdate; +@end diff --git a/ios/Runner/MapController.m b/ios/Runner/MapController.m new file mode 100644 index 00000000..8f661e17 --- /dev/null +++ b/ios/Runner/MapController.m @@ -0,0 +1,207 @@ +// +// MapController.m +// Runner +// +// Created by Mihail Varbanov on 7/11/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MapController.h" +#import "AppDelegate.h" +#import "AppKeys.h" +#import "MapMarkerView.h" + +#import "NSDictionary+UIUCConfig.h" +#import "NSDictionary+InaTypedValue.h" +#import "NSArray+InaTypedValue.h" +#import "NSString+InaJson.h" +#import "NSUserDefaults+InaUtils.h" +#import "UIColor+InaParse.h" +#import "CLLocationCoordinate2D+InaUtils.h" +#import "NSDictionary+UIUCExplore.h" +#import "InaSymbols.h" + +#import + + +@interface MapController() + +@end + +///////////////////////////////// +// MapController + +@implementation MapController + +- (instancetype)init { + if (self = [super init]) { + self.navigationItem.title = NSLocalizedString(@"Maps", nil); + + _clLocationManager = [[CLLocationManager alloc] init]; + _clLocationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation; + _clLocationManager.delegate = self; + } + return self; +} + +- (instancetype)initWithParameters:(NSDictionary*)parameters completionHandler:(FlutterCompletion)completionHandler { + if (self = [self init]) { + _parameters = parameters; + _completionHandler = completionHandler; + } + return self; +} + +- (void)loadView { + + self.view = [[UIView alloc] initWithFrame:CGRectZero]; + self.view.backgroundColor = [UIColor whiteColor]; + + NSDictionary *target = [_parameters inaDictForKey:@"target"]; + CLLocationDegrees latitude = [target inaDoubleForKey:@"latitude"] ?: kInitialCameraLocation.latitude; + CLLocationDegrees longitude = [target inaDoubleForKey:@"longitude"] ?: kInitialCameraLocation.longitude; + float zoom = [target inaFloatForKey:@"zoom"] ?: kInitialCameraZoom; + + GMSCameraPosition *camera = [GMSCameraPosition cameraWithLatitude:latitude longitude:longitude zoom:zoom]; + _gmsMapView = [GMSMapView mapWithFrame:CGRectZero camera:camera]; + _gmsMapView.delegate = self; + //_gmsMapView.myLocationEnabled = YES; + //_gmsMapView.settings.compassButton = YES; + //_gmsMapView.settings.myLocationButton = YES; + [self.view addSubview:_gmsMapView]; + + _mpMapControl = [[MPMapControl alloc] initWithMap:_gmsMapView]; + _mpMapControl.delegate = self; + [_mpMapControl showUserPosition:YES]; + + NSDictionary *options = [_parameters inaDictForKey:@"options"]; + [_mpMapControl setFloorSelectorHidden:((options != nil) && [options inaBoolForKey:@"hideLevels"])]; + + if ((options != nil) && [options inaBoolForKey:@"showDebugLocation"]) { + _debugStatusLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + _debugStatusLabel.font = [UIFont boldSystemFontOfSize:12]; + _debugStatusLabel.textAlignment = NSTextAlignmentCenter; + _debugStatusLabel.textColor = [UIColor colorWithWhite:0.5 alpha:1.0]; + _debugStatusLabel.shadowColor = [UIColor colorWithWhite:1 alpha:0.5]; + _debugStatusLabel.shadowOffset = CGSizeMake(2, 2); + [_gmsMapView addSubview:_debugStatusLabel]; + } + + NSArray *markers = [_parameters inaArrayForKey:@"markers"]; + for (NSDictionary *markerJson in markers) { + if ([markerJson isKindOfClass:[NSDictionary class]]) { + GMSMarker *marker = [[GMSMarker alloc] init]; + CLLocationDegrees markerLatitude = [markerJson inaDoubleForKey:@"latitude"]; + CLLocationDegrees markerLongitude = [markerJson inaDoubleForKey:@"longitude"]; + marker.position = CLLocationCoordinate2DMake(markerLatitude, markerLongitude); + marker.title = [markerJson inaStringForKey:@"name"]; + marker.snippet = [markerJson inaStringForKey:@"description"]; + marker.map = _gmsMapView; + } + } +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + [self layoutSubViews]; +} + +- (void)layoutSubViews { + CGSize contentSize = self.view.frame.size; + _gmsMapView.frame = CGRectMake(0, 0, contentSize.width, contentSize.height); + + if (_debugStatusLabel != nil) { + CGFloat labelH = 12; + _debugStatusLabel.frame = CGRectMake(0, contentSize.height - 1 - labelH, contentSize.width, labelH); + } +} + +- (void)viewDidLoad { + [super viewDidLoad]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self startCoreLocation]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [self stopCoreLocation]; +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; +} + +#pragma mark Core Location + +- (void)startCoreLocation { + [_clLocationManager startUpdatingLocation]; +} + +- (void)stopCoreLocation { + [_clLocationManager stopUpdatingLocation]; +} + +#pragma mark Location + +- (void)notifyLocationUpdate { + if (_debugStatusLabel != nil) { + if (_debugStatusLabel != nil) { + if (_clLocation != nil) { + _debugStatusLabel.text = [NSString stringWithFormat:@"[%.6f, %.6f] @ %@", _clLocation.coordinate.latitude, _clLocation.coordinate.longitude, @(_clLocation.floor.level)]; + _debugStatusLabel.textColor = [UIColor colorWithWhite:0.5 alpha:1.0]; + } + else if (_clLocationError != nil) { + _debugStatusLabel.text = _clLocationError.debugDescription; + _debugStatusLabel.textColor = [UIColor colorWithRed:1 green:0 blue:0 alpha:1.0]; + } + } + } +} + +#pragma mark CLLocationManagerDelegate + +- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { + CLLocation* location = [locations lastObject]; + NSLog(@"CoreLocation: at location: [%.6f, %.6f]", location.coordinate.latitude, location.coordinate.longitude); + + _clLocation = location; + _clLocationError = nil; + + [self notifyLocationUpdate]; +} + +- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error { + NSLog(@"CoreLocation: Failed to retrieve location: %@", error.localizedDescription); + _clLocation = nil; + _clLocationError = error; + [self notifyLocationUpdate]; +} + +#pragma mark GMSMapViewDelegate + +- (void)mapView:(GMSMapView *)mapView idleAtCameraPosition:(GMSCameraPosition *)position { +} + +#pragma mark MPMapControlDelegate + +- (void)floorDidChange:(NSNumber*)floor { + NSLog(@"Maps Indoors: floorDidChange: %d", floor.intValue); +} + +@end + diff --git a/ios/Runner/MapDirectionsController.h b/ios/Runner/MapDirectionsController.h new file mode 100644 index 00000000..18572be8 --- /dev/null +++ b/ios/Runner/MapDirectionsController.h @@ -0,0 +1,26 @@ +// +// MapDirectionsController.h +// Runner +// +// Created by Mihail Varbanov on 7/11/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import "MapController.h" + +@interface MapDirectionsController : MapController +- (instancetype)initWithParameters:(NSDictionary*)parameters completionHandler:(FlutterCompletion)completionHandler; +@end diff --git a/ios/Runner/MapDirectionsController.m b/ios/Runner/MapDirectionsController.m new file mode 100644 index 00000000..ad15576f --- /dev/null +++ b/ios/Runner/MapDirectionsController.m @@ -0,0 +1,987 @@ +// +// MapDirectionsController.m +// Runner +// +// Created by Mihail Varbanov on 7/11/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MapDirectionsController.h" +#import "AppDelegate.h" +#import "AppKeys.h" +#import "MapMarkerView.h" + +#import "NSDictionary+UIUCConfig.h" +#import "NSDictionary+InaTypedValue.h" +#import "NSArray+InaTypedValue.h" +#import "NSString+InaJson.h" +#import "NSUserDefaults+InaUtils.h" +#import "UIColor+InaParse.h" +#import "CLLocationCoordinate2D+InaUtils.h" +#import "NSDictionary+UIUCExplore.h" +#import "InaSymbols.h" + + +typedef NS_ENUM(NSInteger, NavStatus) { + NavStatus_Unknown, + NavStatus_Start, + NavStatus_Progress, + NavStatus_Finished, +}; + +@interface MPRoute(InaUtils) +- (bool)isValidSegmentPath:(MPRouteSegmentPath)segmentPath; +- (NSString*)displayDecription; +@end + +static MPRouteSegmentPath MPRouteSegmentPathMake(NSInteger legIndex, NSInteger stepIndex); + +static MPTravelMode const kTravelModes[] = { MPTravelModeWalking, MPTravelModeBicycling, MPTravelModeDriving, MPTravelModeTransit }; +static NSString * const kTravelModeKey = @"mapDirections.travelMode"; + +@interface MapDirectionsController(){ + float _currentZoom; +} +@property (nonatomic, strong) NSDictionary* explore; +@property (nonatomic, strong) NSDictionary* exploreLocation; +@property (nonatomic, strong) NSString* exploreAddress; +@property (nonatomic) NSError* exploreAddressError; + +@property (nonatomic, strong) UIActivityIndicatorView* + activityIndicator; +@property (nonatomic, strong) UILabel* activityStatus; +@property (nonatomic, strong) UIAlertController* alertController; + +@property (nonatomic, strong) UISegmentedControl* navTravelModesCtrl; +@property (nonatomic, strong) UIButton* navRefreshButton; +@property (nonatomic, strong) UIButton* navAutoUpdateButton; +@property (nonatomic, strong) UIButton* navPrevButton; +@property (nonatomic, strong) UIButton* navNextButton; +@property (nonatomic, strong) UILabel* navStepLabel; +@property (nonatomic) NavStatus navStatus; +@property (nonatomic) bool navAutoUpdate; +@property (nonatomic) bool navDidFirstLocationUpdate; + +@property (nonatomic, strong) MPRoute* mpRoute; +@property (nonatomic, strong) NSError* mpRouteError; +@property (nonatomic, strong) GMSPolyline* gmsRoutePolyline; +@property (nonatomic, strong) GMSCameraPosition* gmsRouteCameraPosition; +@property (nonatomic, strong) NSArray* nsRouteStepCoordsCounts; +@property (nonatomic, strong) MPDirectionsService* mpRouteService; +@property (nonatomic, strong) MPDirectionsRenderer* mpDirectionsRenderer; +@property (nonatomic, strong) GMSMarker* gmsExploreMarker; +@property (nonatomic, strong) GMSPolygon* gmsExplorePolygone; + +@end + +///////////////////////////////// +// MapDirectionsController + +@implementation MapDirectionsController + +- (instancetype)init { + if (self = [super init]) { + self.navigationItem.title = NSLocalizedString(@"Directions", nil); + } + return self; +} + +- (instancetype)initWithParameters:(NSDictionary*)parameters completionHandler:(FlutterCompletion)completionHandler { + if (self = [super initWithParameters:parameters completionHandler:completionHandler]) { + + id exploreParam = [self.parameters objectForKey:@"explore"]; + if ([exploreParam isKindOfClass:[NSDictionary class]]) { + _explore = exploreParam; + } + else if ([exploreParam isKindOfClass:[NSArray class]]) { + _explore = [NSDictionary uiucExploreFromGroup:exploreParam]; + } + +//#ifdef DEBUG +// _explore = @{@"title" : @"Woman Restroom",@"location":@{@"latitude":@(40.1131343), @"longitude":@(-88.2259209), @"floor": @(30), @"building":@"DCL"}}; +// _explore = @{@"title" : @"Mens Restroom",@"location":@{@"latitude":@(40.0964976), @"longitude":@(-88.2364674), @"floor": @(20), @"building":@"State Farm"}}; +//#endif + + _exploreLocation = _explore.uiucExploreLocation; + _exploreAddress = _explore.uiucExploreAddress; + } + return self; +} + +- (void)loadView { + [super loadView]; + + _currentZoom = self.gmsMapView.camera.zoom; + + _activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; + _activityIndicator.color = [UIColor blackColor]; + [self.view addSubview:_activityIndicator]; + + _activityStatus = [[UILabel alloc] initWithFrame:CGRectZero]; + _activityStatus.font = [UIFont systemFontOfSize:14]; + _activityStatus.textAlignment = NSTextAlignmentCenter; + _activityStatus.textColor = [UIColor darkGrayColor]; + [self.view addSubview:_activityStatus]; + + _navTravelModesCtrl = [[UISegmentedControl alloc] initWithFrame:CGRectZero]; + _navTravelModesCtrl.tintColor = [UIColor inaColorWithHex:@"#606060"]; + [_navTravelModesCtrl addTarget:self action:@selector(didNavTravelMode) forControlEvents:UIControlEventValueChanged]; + NSInteger selectedTravelModeIndex = [self buildTravelModeSegments]; + [_navTravelModesCtrl setSelectedSegmentIndex:selectedTravelModeIndex]; + [self.gmsMapView addSubview:_navTravelModesCtrl]; + + _navRefreshButton = [[UIButton alloc] initWithFrame:CGRectZero]; + [_navRefreshButton setExclusiveTouch:YES]; + [_navRefreshButton setImage:[UIImage imageNamed:@"button-icon-nav-refresh"] forState:UIControlStateNormal]; + [_navRefreshButton addTarget:self action:@selector(didNavRefresh) forControlEvents:UIControlEventTouchUpInside]; + [self.gmsMapView addSubview:_navRefreshButton]; + + _navAutoUpdateButton = [[UIButton alloc] initWithFrame:CGRectZero]; + [_navAutoUpdateButton setExclusiveTouch:YES]; + [_navAutoUpdateButton setImage:[UIImage imageNamed:@"button-icon-nav-location"] forState:UIControlStateNormal]; + [_navAutoUpdateButton addTarget:self action:@selector(didNavAutoUpdate) forControlEvents:UIControlEventTouchUpInside]; + [self.gmsMapView addSubview:_navAutoUpdateButton]; + + _navPrevButton = [[UIButton alloc] initWithFrame:CGRectZero]; + [_navPrevButton setExclusiveTouch:YES]; + [_navPrevButton setImage:[UIImage imageNamed:@"button-icon-nav-prev"] forState:UIControlStateNormal]; + [_navPrevButton addTarget:self action:@selector(didNavPrev) forControlEvents:UIControlEventTouchUpInside]; + [self.gmsMapView addSubview:_navPrevButton]; + + _navNextButton = [[UIButton alloc] initWithFrame:CGRectZero]; + [_navNextButton setExclusiveTouch:YES]; + [_navNextButton setImage:[UIImage imageNamed:@"button-icon-nav-next"] forState:UIControlStateNormal]; + [_navNextButton addTarget:self action:@selector(didNavNext) forControlEvents:UIControlEventTouchUpInside]; + [self.gmsMapView addSubview:_navNextButton]; + + _navStepLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + _navStepLabel.font = [UIFont systemFontOfSize:18]; + _navStepLabel.numberOfLines = 2; + _navStepLabel.textAlignment = NSTextAlignmentCenter; + _navStepLabel.textColor = [UIColor blackColor]; + _navStepLabel.shadowColor = [UIColor colorWithWhite:1 alpha:0.5]; + _navStepLabel.shadowOffset = CGSizeMake(2, 2); + [self.gmsMapView addSubview:_navStepLabel]; + +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; +} + +- (void)layoutSubViews { + [super layoutSubViews]; + + CGSize contentSize = self.view.frame.size; + + CGSize activityIndSize = [_activityIndicator sizeThatFits:contentSize]; + CGFloat activityIndY = contentSize.height / 2 - activityIndSize.height - 8; + _activityIndicator.frame = CGRectMake((contentSize.width - activityIndSize.width) / 2, activityIndY, activityIndSize.width, activityIndSize.height); + + CGFloat activityTxtY = contentSize.height / 2 + 8, activityTxtGutterW = 16, activityTxtH = 16; + _activityStatus.frame = CGRectMake(activityTxtGutterW, activityTxtY, MAX(contentSize.width - 2 * activityTxtGutterW, 0), activityTxtH); + + CGFloat navBtnSize = 42; + CGFloat navX = 0, navY, navW = contentSize.width; + navX += navBtnSize / 2; navW = MAX(navW - navBtnSize, 0); + + navY = navBtnSize / 2; + _navRefreshButton.frame = CGRectMake(navX, navY, navBtnSize, navBtnSize); + + CGFloat navAutoUpdateX = navX + 3 * navBtnSize / 2; + _navAutoUpdateButton.frame = CGRectMake(navAutoUpdateX, navY, navBtnSize, navBtnSize); + + CGFloat navTravelModeBtnSize = 36; + CGSize navTravelModesSize = CGSizeMake(navTravelModeBtnSize * _navTravelModesCtrl.numberOfSegments * 3 / 2, navTravelModeBtnSize); + _navTravelModesCtrl.frame = CGRectMake(contentSize.width - navTravelModesSize.width - 4, navY + (navBtnSize - navTravelModeBtnSize) / 2, navTravelModesSize.width, navTravelModesSize.height); + + navY = contentSize.height - 2 * navBtnSize; + _navPrevButton.frame = CGRectMake(navX, navY, navBtnSize, navBtnSize); + _navNextButton.frame = CGRectMake(navX + navW - navBtnSize, navY, navBtnSize, navBtnSize); + + navX += navBtnSize; navW = MAX(navW - 2 * navBtnSize, 0); + _navStepLabel.frame = CGRectMake(navX, navY - navBtnSize / 2, navW, 2 * navBtnSize); +} + +- (void)viewDidLoad { + [super viewDidLoad]; + [self updateNav]; + [self prepare]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; +} + +#pragma mark Navigation + +- (void)prepare { + if (_exploreLocation != nil) { + self.gmsMapView.hidden = true; + [_activityIndicator startAnimating]; + [_activityStatus setText:NSLocalizedString(@"Detecting current location...",nil)]; + [self buildExploreMarker]; + } + else if (_exploreAddress != nil) { + self.gmsMapView.hidden = true; + [_activityIndicator startAnimating]; + [_activityStatus setText:NSLocalizedString(@"Resolving target address ...",nil)]; + + __weak typeof(self) weakSelf = self; + CLGeocoder *geoCoder = [[CLGeocoder alloc] init]; + [geoCoder geocodeAddressString:_exploreAddress completionHandler:^(NSArray* placemarks, NSError* error) { + CLPlacemark *placemark = placemarks.firstObject; + if (placemark.location != nil) { + weakSelf.exploreLocation = @{ + @"latitude" : @(placemark.location.coordinate.latitude), + @"longitude" : @(placemark.location.coordinate.longitude), + }; + [weakSelf buildExploreMarker]; + } + else { + weakSelf.exploreAddressError = error ?: [NSError errorWithDomain:@"com.illinois.rokwire" code:1 userInfo:@{ NSLocalizedDescriptionKey : NSLocalizedString(@"Failed to resolve target address.", nil) }]; + } + + if (weakSelf.navDidFirstLocationUpdate) { + [weakSelf didFirstLocationUpdate]; + } + else { + [weakSelf.activityStatus setText:NSLocalizedString(@"Detecting current location...", nil)]; + } + }]; + } + else { + // Simply do nothing + } + + [self buildExplorePolygon]; +} + +- (void)didFirstLocationUpdate { + if (_exploreLocation == nil) { + if (_exploreAddress != nil) { + if (_exploreAddressError != nil) { + if (self.gmsMapView.hidden) { + self.gmsMapView.hidden = false; + [_activityIndicator stopAnimating]; + [_activityStatus setText:@""]; + + [self alertMessage:_exploreAddressError.debugDescription]; + } + } + else { + // Still Loading + } + } + else { + // Do nothing + if (self.gmsMapView.hidden) { + self.gmsMapView.hidden = false; + [_activityIndicator stopAnimating]; + [_activityStatus setText:@""]; + } + + if (self.clLocation != nil) { + // Position camera on user location + GMSCameraUpdate *cameraUpdate = [GMSCameraUpdate setTarget:self.clLocation.coordinate]; + [self.gmsMapView moveCamera:cameraUpdate]; + } + } + } + else if (self.clLocation == nil) { + // Show map and present error message + if (self.gmsMapView.hidden) { + self.gmsMapView.hidden = false; + [_activityIndicator stopAnimating]; + [_activityStatus setText:@""]; + + // Position camera on explore location + CLLocationCoordinate2D exploreLocationCoord = CLLocationCoordinate2DMake([_exploreLocation inaDoubleForKey:@"latitude"], [_exploreLocation inaDoubleForKey:@"longitude"]); + GMSCameraUpdate *cameraUpdate = [GMSCameraUpdate setTarget:exploreLocationCoord]; + [self.gmsMapView moveCamera:cameraUpdate]; + + // Alert error + NSString *message = nil; + if (0 < self.clLocationError.localizedDescription.length) { + message = self.clLocationError.localizedDescription; + } + else { + message = NSLocalizedString(@"Failed to detect current location.", nil); + } + [self alertMessage:message]; + } + } + else { + // Build route + if ((_mpRouteService == nil) && (_mpRoute == nil) && (_mpRouteError == nil)) { + [self buildRoute]; + } + } +} + +- (void)buildRoute { + MPTravelMode travelMode = ((0 <= _navTravelModesCtrl.selectedSegmentIndex) && (_navTravelModesCtrl.selectedSegmentIndex < _countof(kTravelModes))) ? kTravelModes[_navTravelModesCtrl.selectedSegmentIndex] : MPTravelModeWalking;; + [self buildRouteWithTravelMode:travelMode]; +} + +- (void)buildRouteWithTravelMode:(MPTravelMode)travelMode { + [_activityStatus setText:NSLocalizedString(@"Looking for route...", nil)]; + [_activityIndicator startAnimating]; + + MPPoint *orgPoint = [[MPPoint alloc] initWithLat:self.clLocation.coordinate.latitude lon:self.clLocation.coordinate.longitude zValue:self.clLocation.floor.level]; + MPPoint *dstPoint = [[MPPoint alloc] initWithLat:[_exploreLocation inaDoubleForKey:@"latitude"] lon:[_exploreLocation inaDoubleForKey:@"longitude"] zValue:[_exploreLocation inaIntegerForKey:@"floor"]]; + + NSLog(@"Lookup Route: [%.6f, %.6f] @ level %d -> [%.6f, %.6f] @ level %d", orgPoint.lat, orgPoint.lng, orgPoint.zIndex, dstPoint.lat, dstPoint.lng, dstPoint.zIndex); + + MPDirectionsQuery *query = [[MPDirectionsQuery alloc] initWithOriginPoint:orgPoint destination:dstPoint]; + query.travelMode = travelMode; + + __weak typeof(self) weakSelf = self; + MPDirectionsService *mpRouteService = _mpRouteService = [[MPDirectionsService alloc] init]; + [_mpRouteService routingWithQuery:query completionHandler:^(MPRoute * _Nullable route, NSError * _Nullable error) { + if (mpRouteService == weakSelf.mpRouteService) { + weakSelf.mpRouteService = nil; + weakSelf.mpRoute = route; + weakSelf.mpRouteError = error; + [weakSelf didBuildRoute]; + } + }]; +} + +- (void)didBuildRoute { + + self.gmsMapView.hidden = false; + [_activityIndicator stopAnimating]; + [_activityStatus setText:@""]; + + if (_mpRoute != nil) { + [self buildRoutePolyline]; + + + _mpDirectionsRenderer = [[MPDirectionsRenderer alloc] init]; + _mpDirectionsRenderer.map = self.gmsMapView; + _mpDirectionsRenderer.route = _mpRoute; + + _gmsRouteCameraPosition = self.gmsMapView.camera; + + _navStatus = NavStatus_Start; + + } + [self updateNav]; + + GMSMutablePath *path = [[GMSMutablePath alloc] init]; + [path addLatitude:self.clLocation.coordinate.latitude longitude:self.clLocation.coordinate.longitude]; // current location + [path addLatitude:[_exploreLocation inaDoubleForKey:@"latitude"] longitude:[_exploreLocation inaDoubleForKey:@"longitude"]]; // explore location + + NSArray *explorePolygon = _explore.uiucExplorePolygon; + if (0 < explorePolygon.count) { + for (NSDictionary *point in explorePolygon) { + if ([point isKindOfClass:[NSDictionary class]]) { + [path addLatitude:[point inaDoubleForKey:@"latitude"] longitude:[point inaDoubleForKey:@"longitude"]]; + } + } + } + + GMSCoordinateBounds *bounds = [[GMSCoordinateBounds alloc] initWithPath:path]; + + if (_mpRoute.bounds != nil) { + bounds = [bounds includingCoordinate:CLLocationCoordinate2DMake(_mpRoute.bounds.northeast.lat.doubleValue, _mpRoute.bounds.northeast.lng.doubleValue)]; + bounds = [bounds includingCoordinate:CLLocationCoordinate2DMake(_mpRoute.bounds.southwest.lat.doubleValue, _mpRoute.bounds.southwest.lng.doubleValue)]; + } + GMSCameraUpdate *cameraUpdate = [GMSCameraUpdate fitBounds:bounds withPadding:50.0f]; + [self.gmsMapView moveCamera:cameraUpdate]; + + if (_mpRoute == nil) { + NSString *message = nil; + if (0 < _mpRouteError.localizedDescription.length) { + message = _mpRouteError.localizedDescription; + } + else { + message = NSLocalizedString(@"Failed to find route.", nil); + } + + [self alertMessage:message]; + } +} + +- (void)buildExploreMarker { + if (_exploreLocation != nil) { + _gmsExploreMarker = [[GMSMarker alloc] init]; + _gmsExploreMarker.position = CLLocationCoordinate2DMake([_exploreLocation inaDoubleForKey:@"latitude"], [_exploreLocation inaDoubleForKey:@"longitude"]); + + MapMarkerView *iconView = [MapMarkerView createFromExplore:_explore]; + _gmsExploreMarker.iconView = iconView; + _gmsExploreMarker.title = iconView.title; + _gmsExploreMarker.snippet = iconView.descr; + _gmsExploreMarker.groundAnchor = iconView.anchor; + _gmsExploreMarker.zIndex = 1; + _gmsExploreMarker.userData = @{ @"explore" : _explore }; + _gmsExploreMarker.map = self.gmsMapView; + [self updateExploreMarker]; + } +} + +- (void)updateExploreMarker { + NSDictionary *explore = [_gmsExploreMarker.userData inaDictForKey:@"explore"]; + NSDictionary *exploreLocation = [explore inaDictForKey:@"location"]; + NSNumber *markerFloor = [exploreLocation inaNumberForKey:@"floor"]; + + MapMarkerView *iconView = [_gmsExploreMarker.iconView isKindOfClass:[MapMarkerView class]] ? ((MapMarkerView*)_gmsExploreMarker.iconView) : nil; + if (iconView != nil) { + iconView.displayMode = (self.gmsMapView.camera.zoom < kMarkerThresold1Zoom) ? MapMarkerDisplayMode_Plain : ((self.gmsMapView.camera.zoom < kMarkerThresold2Zoom) ? MapMarkerDisplayMode_Title : MapMarkerDisplayMode_Extended); + iconView.blurred = ((markerFloor != nil) && (markerFloor.intValue != self.mpMapControl.currentFloor.intValue)); + } +} + +- (void)buildExplorePolygon { + NSArray *explorePolygon = _explore.uiucExplorePolygon; + if (0 < explorePolygon.count) { + GMSMutablePath *path = [[GMSMutablePath alloc] init]; + for (NSDictionary *point in explorePolygon) { + if ([point isKindOfClass:[NSDictionary class]]) { + [path addLatitude:[point inaDoubleForKey:@"latitude"] longitude:[point inaDoubleForKey:@"longitude"]]; + } + } + if (0 < path.count) { + _gmsExplorePolygone = [[GMSPolygon alloc] init]; + _gmsExplorePolygone.path = path; + _gmsExplorePolygone.title = _explore.uiucExploreTitle; + _gmsExplorePolygone.fillColor = [UIColor colorWithWhite:0.0 alpha:0.03]; + _gmsExplorePolygone.strokeColor = [UIColor inaColorWithHex:_explore.uiucExploreMarkerHexColor]; + _gmsExplorePolygone.strokeWidth = 2; + _gmsExplorePolygone.zIndex = 1; + _gmsExplorePolygone.userData = @{ @"explore" : _explore }; + _gmsExplorePolygone.map = self.gmsMapView; + } + } +} + +- (void)buildRoutePolyline { + NSMutableArray *routeCounts = [[NSMutableArray alloc] init]; + GMSMutablePath *routePath = [[GMSMutablePath alloc] init]; + for (MPRouteLeg *mpLeg in _mpRoute.legs) { + for (MPRouteStep *mpStep in mpLeg.steps) { + GMSPath *gmStepPath = [GMSPath pathFromEncodedPath:mpStep.polyline.points]; + for (NSInteger index = 0; index < gmStepPath.count; index++) { + [routePath addCoordinate:[gmStepPath coordinateAtIndex:index]]; + } + [routeCounts addObject:@(gmStepPath.count)]; + } + } + + _nsRouteStepCoordsCounts = routeCounts; + _gmsRoutePolyline = [GMSPolyline polylineWithPath:routePath]; + //_gmsRoutePolyline.strokeColor = [UIColor inaColorWithHex:@"e84a27"]; + _gmsRoutePolyline.map = self.gmsMapView; +} + +- (void)clearRoutePolyline { + +} + +#pragma mark Navigation + +- (void)updateNav { + _navRefreshButton.hidden = NO; + _navRefreshButton.enabled = (_mpRouteService == nil); + + _navTravelModesCtrl.hidden = (_navStatus != NavStatus_Unknown) && (_navStatus != NavStatus_Start); + _navTravelModesCtrl.enabled = (_mpRouteService == nil); + + _navAutoUpdateButton.hidden = (_navStatus != NavStatus_Progress) || _navAutoUpdate; + _navPrevButton.hidden = _navNextButton.hidden = _navStepLabel.hidden = (_navStatus == NavStatus_Unknown); + + if (_navStatus == NavStatus_Start) { + NSString *routeDescription = _mpRoute.displayDecription; + [self setStepHtml:[NSString stringWithFormat:@"%@%@", + NSLocalizedString(@"START", nil), + (0 < routeDescription.length) ? [NSString stringWithFormat:@"
(%@)", routeDescription] : @"" + ]]; + + _navPrevButton.enabled = false; + _navNextButton.enabled = true; + } + else if (_navStatus == NavStatus_Progress) { + NSInteger legIndex = _mpDirectionsRenderer.routeLegIndex; + MPRouteLeg *leg = ((0 <= legIndex) && (legIndex < _mpDirectionsRenderer.route.legs.count)) ? [_mpDirectionsRenderer.route.legs objectAtIndex:legIndex] : nil; + + NSInteger stepIndex = _mpDirectionsRenderer.routeStepIndex; + MPRouteStep *step = ((0 <= stepIndex) && (stepIndex < leg.steps.count)) ? [leg.steps objectAtIndex:stepIndex] : nil; + + if (0 < step.html_instructions.length) { + [self setStepHtml:step.html_instructions]; + } + else if ((0 < step.maneuver.length) || (0 < step.highway.length) || (0 < step.routeContext.length)) { + _navStepLabel.text = [NSString stringWithFormat:@"%@ | %@ | %@", step.routeContext, step.highway, step.maneuver]; + } + else if ((0 < step.distance.intValue) || (0 < step.duration.intValue)) { + _navStepLabel.text = [NSString stringWithFormat:NSLocalizedString(@"%d m / %d sec", nil), step.distance.intValue, step.duration.intValue]; + } + else { + _navStepLabel.text = [NSString stringWithFormat:NSLocalizedString(@"Leg %d / Step %d", nil), (int)legIndex + 1, (int)stepIndex + 1]; + } + + _navPrevButton.enabled = _navNextButton.enabled = true; + + NSLog(@"At Route Step (%d:%d): [%.6f, %.6f] @ level %d -> [%.6f, %.6f] @ level %d", (int)legIndex, (int)stepIndex, step.start_location.lat.doubleValue, step.start_location.lng.doubleValue, step.start_location.zLevel.intValue, step.end_location.lat.doubleValue, step.end_location.lng.doubleValue, step.end_location.zLevel.intValue); + + [self updateCurerntFloor:step.start_location.zLevel]; + } + else if (_navStatus == NavStatus_Finished) { + [self setStepHtml:[NSString stringWithFormat:@"%@", NSLocalizedString(@"FINISH", nil)]]; + + _navPrevButton.enabled = true; + _navNextButton.enabled = false; + } +} + +- (void)updateNavAutoUpdate { + MPRouteSegmentPath segmentPath = [self findNearestRouteSegmentByCurrentLocation]; + _navAutoUpdate = [_mpRoute isValidSegmentPath:segmentPath] && (_mpDirectionsRenderer.routeLegIndex == segmentPath.legIndex) && (_mpDirectionsRenderer.routeStepIndex == segmentPath.stepIndex); +} + +- (void)didNavPrev { + if (_navStatus == NavStatus_Start) { + } + else if (_navStatus == NavStatus_Progress) { + NSInteger legIndex = _mpDirectionsRenderer.routeLegIndex; + NSInteger stepIndex = _mpDirectionsRenderer.routeStepIndex; + + if (0 < stepIndex) { + _mpDirectionsRenderer.routeStepIndex = --stepIndex; + } + else if (0 < legIndex) { + _mpDirectionsRenderer.routeLegIndex = --legIndex; + MPRouteLeg *leg = [_mpDirectionsRenderer.route.legs objectAtIndex:legIndex]; + _mpDirectionsRenderer.routeStepIndex = leg.steps.count - 1; + } + else { + _navStatus = NavStatus_Start; + _mpDirectionsRenderer.routeLegIndex = _mpDirectionsRenderer.routeStepIndex = -1; + } + } + else if (_navStatus == NavStatus_Finished) { + _navStatus = NavStatus_Progress; + + _mpDirectionsRenderer.routeLegIndex = _mpDirectionsRenderer.route.legs.count - 1; + + MPRouteLeg *leg = _mpDirectionsRenderer.route.legs.lastObject; + _mpDirectionsRenderer.routeStepIndex = leg.steps.count - 1; + } + + [self updateNavAutoUpdate]; + [self updateNav]; +} + +- (void)didNavNext { + if (_navStatus == NavStatus_Start) { + _navStatus = NavStatus_Progress; + _mpDirectionsRenderer.routeLegIndex = _mpDirectionsRenderer.routeStepIndex = 0; + [self notifyRouteStart]; + } + else if (_navStatus == NavStatus_Progress) { + NSInteger legIndex = _mpDirectionsRenderer.routeLegIndex; + NSInteger stepIndex = _mpDirectionsRenderer.routeStepIndex; + + MPRouteLeg *leg = ((0 <= legIndex) && (legIndex < _mpDirectionsRenderer.route.legs.count)) ? [_mpDirectionsRenderer.route.legs objectAtIndex:legIndex] : nil; + + if ((stepIndex + 1) < leg.steps.count) { + _mpDirectionsRenderer.routeStepIndex = ++stepIndex; + } + else if ((legIndex + 1) < _mpDirectionsRenderer.route.legs.count) { + _mpDirectionsRenderer.routeLegIndex = ++legIndex; + _mpDirectionsRenderer.routeStepIndex = 0; + } + else { + _navStatus = NavStatus_Finished; + _mpDirectionsRenderer.routeLegIndex = _mpDirectionsRenderer.routeStepIndex = -1; + [self notifyRouteFinish]; + } + } + else if (_navStatus == NavStatus_Finished) { + } + + [self updateNavAutoUpdate]; + [self updateNav]; +} + +- (void)didNavRefresh { + _mpRoute = nil; + _mpRouteError = nil; + _gmsRoutePolyline.map = nil; + _gmsRoutePolyline = nil; + _nsRouteStepCoordsCounts = nil; + + _mpDirectionsRenderer.map = nil; + _mpDirectionsRenderer.route = nil; + _mpDirectionsRenderer = nil; + _navStatus = NavStatus_Unknown; + _navAutoUpdate = false; + + if (_gmsRouteCameraPosition != nil) { + [self.gmsMapView animateWithCameraUpdate:[GMSCameraUpdate setTarget:_gmsRouteCameraPosition.target zoom:_gmsRouteCameraPosition.zoom]]; + _gmsRouteCameraPosition = nil; + } + + [self updateNav]; + [self buildRoute]; +} + +- (void)didNavTravelMode { + + if ((0 <= _navTravelModesCtrl.selectedSegmentIndex) && (_navTravelModesCtrl.selectedSegmentIndex < _countof(kTravelModes))) { + + _mpRoute = nil; + _mpRouteError = nil; + _gmsRoutePolyline.map = nil; + _gmsRoutePolyline = nil; + _nsRouteStepCoordsCounts = nil; + + _mpDirectionsRenderer.map = nil; + _mpDirectionsRenderer.route = nil; + _mpDirectionsRenderer = nil; + _navStatus = NavStatus_Unknown; + _navAutoUpdate = false; + + [self updateNav]; + + MPTravelMode travelMode = kTravelModes[_navTravelModesCtrl.selectedSegmentIndex]; + [self buildRouteWithTravelMode:travelMode]; + + [[NSUserDefaults standardUserDefaults] setInteger:travelMode forKey:self.travelModeKey]; + } +} + +- (void)didNavAutoUpdate { + if (_navStatus == NavStatus_Progress) { + MPRouteSegmentPath segmentPath = [self findNearestRouteSegmentByCurrentLocation]; + if ([_mpRoute isValidSegmentPath:segmentPath]) { + _mpDirectionsRenderer.routeLegIndex = segmentPath.legIndex; + _mpDirectionsRenderer.routeStepIndex = segmentPath.stepIndex; + _navAutoUpdate = true; + } + [self updateNav]; + } +} + +- (void)updateNavByCurrentLocation { + if ((_navStatus == NavStatus_Progress) && _navAutoUpdate && (self.clLocation != nil) && (_mpRoute != nil) && (_mpDirectionsRenderer != nil)) { + MPRouteSegmentPath segmentPath = [self findNearestRouteSegmentByCurrentLocation]; + if ([_mpRoute isValidSegmentPath:segmentPath]) { + [self updateNavFromPathSegment:segmentPath]; + } + } +} + +- (MPRouteSegmentPath)findNearestRouteSegmentByCurrentLocation { + + if ((self.clLocation != nil) && (_mpRoute != nil)) { + + CLLocationCoordinate2D locationCoord = self.clLocation.coordinate; + MPPoint *locPoint = [[MPPoint alloc] initWithLat:locationCoord.latitude lon:locationCoord.longitude zValue:self.clLocation.floor.level]; + MPRouteSegmentPath segmentPath = [_mpRoute findNearestRouteSegmentPathFromPoint:locPoint floor:@(self.clLocation.floor.level)]; + if ([_mpRoute isValidSegmentPath:segmentPath]) { + return segmentPath; + } + + double minLegDistance = -1; + MPRouteSegmentPath minSegmentPath = MPRouteSegmentPathMake(-1, -1); + NSInteger globalStepIndex = 0, coordIndex = 0; + + for (NSInteger legIndex = 0; legIndex < _mpRoute.legs.count; legIndex++) { + + MPRouteLeg *mpLeg = [_mpRoute.legs objectAtIndex:legIndex]; + for (NSInteger stepIndex = 0; stepIndex < mpLeg.steps.count; stepIndex++) { + + NSInteger lastCoord = coordIndex + [_nsRouteStepCoordsCounts inaIntegerAtIndex:globalStepIndex]; + while (coordIndex < lastCoord) { + + CLLocationCoordinate2D coord = [_gmsRoutePolyline.path coordinateAtIndex:coordIndex]; + double coordDistance = CLLocationCoordinate2DInaDistance(locationCoord, coord); + if ((minLegDistance < 0.0) || (coordDistance < minLegDistance)) { + minLegDistance = coordDistance; + minSegmentPath = MPRouteSegmentPathMake(legIndex, stepIndex); + + // nothing more to do inside current step, go to next one + coordIndex = lastCoord; + break; + } + coordIndex++; + } + globalStepIndex++; + } + } + return minSegmentPath; + } + + return MPRouteSegmentPathMake(-1, -1); +} + +- (void)updateNavFromPathSegment:(MPRouteSegmentPath)segmentPath { + bool modified = false; + if (_mpDirectionsRenderer.routeLegIndex != segmentPath.legIndex) { + _mpDirectionsRenderer.routeLegIndex = segmentPath.legIndex; + modified = true; + } + if (_mpDirectionsRenderer.routeStepIndex != segmentPath.stepIndex) { + _mpDirectionsRenderer.routeStepIndex = segmentPath.stepIndex; + modified = true; + } + if (modified) { + [self updateNav]; + } +} + +- (void)notifyRouteStart { + [self notifyRouteEvent:@"map.route.start"]; +} + +- (void)notifyRouteFinish { + [self notifyRouteEvent:@"map.route.finish"]; +} + +- (void)notifyRouteEvent:(NSString*)event { + + MPRouteCoordinate *org = _mpRoute.legs.firstObject.start_location; + MPRouteCoordinate *dest = _mpRoute.legs.lastObject.end_location; + CLLocation *loc = self.clLocation; + + NSInteger timestamp = floor(NSDate.date.timeIntervalSince1970 * 1000.0); // in milliseconds since 1970-01-01T00:00:00Z + + NSDictionary *parameters = @{ + @"origin": (org != nil) ? @{ + @"latitude": org.lat, + @"longitude": org.lng, + @"floor": org.zLevel, + } : [NSNull null], + @"destination": (dest != nil) ? @{ + @"latitude": dest.lat, + @"longitude": dest.lng, + @"floor": dest.zLevel, + } : [NSNull null], + @"location": (loc != nil) ? @{ + @"latitude": @(loc.coordinate.latitude), + @"longitude": @(loc.coordinate.longitude), + @"floor": @(loc.floor.level), + @"timestamp": @(timestamp), + } : [NSNull null], + }; + + [AppDelegate.sharedInstance.flutterMethodChannel invokeMethod:event arguments:parameters.inaJsonString]; +} + + +#pragma mark Location + +- (void)notifyLocationUpdate { + [super notifyLocationUpdate]; + + if (!_navDidFirstLocationUpdate /* && ((_mrLocation != nil) || (0 < _mrLocationTimeoutsCount)) */) { + _navDidFirstLocationUpdate = true; + [self didFirstLocationUpdate]; + } + + if ((_navStatus == NavStatus_Progress) && _navAutoUpdate) { + [self updateNavByCurrentLocation]; + } +} + +#pragma mark Utils + +- (void)updateCurerntFloor:(NSNumber*)floor { + if ((floor != nil) && ([floor integerValue] != [self.mpMapControl.currentFloor integerValue])) { + self.mpMapControl.currentFloor = floor; + [self floorDidChange:floor]; + } +} + +- (NSString*)travelModeKey { + UIUCExploreType exploreType = _explore.uiucExploreType; + if (exploreType == UIUCExploreType_Explores) { + exploreType = _explore.uiucExploreContentType; + } + NSString *exploreTypeString = UIUCExploreTypeToString(exploreType); + return (0 < exploreTypeString.length) ? [NSString stringWithFormat:@"%@.%@", kTravelModeKey, exploreTypeString] : kTravelModeKey; +} + +- (MPTravelMode)travelModeDefault { + UIUCExploreType exploreType = _explore.uiucExploreType; + if (exploreType == UIUCExploreType_Explores) { + exploreType = _explore.uiucExploreContentType; + } + return (exploreType == UIUCExploreType_Parking) ? MPTravelModeDriving : MPTravelModeWalking; +} + +- (NSInteger)buildTravelModeSegments { + NSInteger selectedTravelModeIndex = 0; + MPTravelMode selectedTravelMode = [[NSUserDefaults standardUserDefaults] inaIntegerForKey:self.travelModeKey defaults:self.travelModeDefault]; + for (NSInteger index = 0; index < _countof(kTravelModes); index++) { + UIImage *segmentImage = nil; + switch (kTravelModes[index]) { + case MPTravelModeWalking: segmentImage = [UIImage imageNamed:@"travel-mode-walk"]; break; + case MPTravelModeBicycling: segmentImage = [UIImage imageNamed:@"travel-mode-bicycle"]; break; + case MPTravelModeDriving: segmentImage = [UIImage imageNamed:@"travel-mode-drive"]; break; + case MPTravelModeTransit: segmentImage = [UIImage imageNamed:@"travel-mode-transit"]; break; + default: segmentImage = [UIImage imageNamed:@"travel-mode-unknown"]; break; + } + [_navTravelModesCtrl insertSegmentWithImage:segmentImage atIndex:index animated:NO]; + if (selectedTravelMode == kTravelModes[index]) { + selectedTravelModeIndex = index; + } + } + return selectedTravelModeIndex; +} + +- (void)setStepHtml:(NSString*)htmlContent { + + NSString *html = [NSString stringWithFormat:@"\ + \ +
%@
\ + ", htmlContent]; + + _navStepLabel.attributedText = [[NSAttributedString alloc] + initWithData:[html dataUsingEncoding:NSUTF8StringEncoding] + options:@{ + NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, + NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding) + } + documentAttributes:nil + error:nil + ]; +} + +- (void)alertMessage:(NSString*)message { + __weak typeof(self) weakSelf = self; + if (_alertController != nil) { + [self dismissViewControllerAnimated:YES completion:^{ + weakSelf.alertController = nil; + [weakSelf alertMessage:message]; + }]; + } + else { + _alertController = [UIAlertController alertControllerWithTitle:self.appTitle message:message preferredStyle:UIAlertControllerStyleAlert]; + [_alertController addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil) style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { + weakSelf.alertController = nil; + }]]; + [self presentViewController:_alertController animated:YES completion:nil]; + } +} + +- (NSString*)appTitle { + NSString *title = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]; + if (title.length == 0) { + title = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"]; + } + return title; +} + +#pragma mark GMSMapViewDelegate + +- (void)mapView:(GMSMapView *)mapView idleAtCameraPosition:(GMSCameraPosition *)position { + if ([super respondsToSelector:@selector(mapView:idleAtCameraPosition:)]) { + [super mapView:(GMSMapView *)mapView idleAtCameraPosition:(GMSCameraPosition *)position]; + } + + if (_currentZoom != position.zoom) { + _currentZoom = position.zoom; + [self updateExploreMarker]; + } +} + +#pragma mark MPMapControlDelegate + +- (void)floorDidChange:(NSNumber*)floor { + if ([super respondsToSelector:@selector(floorDidChange:)]) { + [super floorDidChange:floor]; + } + [self updateExploreMarker]; +} + +@end + +///////////////////////////////// +// MPRoute+InaUtils + +@implementation MPRoute(InaUtils) + +- (bool)isValidSegmentPath:(MPRouteSegmentPath)segmentPath { + if ((0 <= segmentPath.legIndex) && (segmentPath.legIndex < self.legs.count)) { + MPRouteLeg *leg = [self.legs objectAtIndex:segmentPath.legIndex]; + if ((0 <= segmentPath.stepIndex) && (segmentPath.stepIndex < leg.steps.count)) { + return true; + } + } + return false; +} + +- (NSString*)displayDecription { + NSMutableString *displayDecription = [[NSMutableString alloc] init]; + + if (0 < self.distance.integerValue) { + // 1 foot = 0.3048 meters + // 1 mile = 1609.34 meters + + long totalMeters = labs(self.distance.integerValue); + double totalMiles = totalMeters / 1609.34; + + if (0 < displayDecription.length) + [displayDecription appendString:@", "]; + [displayDecription appendFormat:@"%.*f %@", (totalMiles < 10.0) ? 1 : 0, totalMiles, (totalMiles != 1.0) ? @"miles" : @"mile"]; + } + + if (0 < self.duration.integerValue) { + long totalSeconds = labs(self.duration.integerValue); + long totalMinutes = totalSeconds / 60; + long totalHours = totalMinutes / 60; + + long minutes = totalMinutes % 60; + + if (0 < displayDecription.length) + [displayDecription appendString:@", "]; + if (totalHours < 1) + [displayDecription appendFormat:@"%lu min", minutes]; + else if (totalHours < 24) + [displayDecription appendFormat:@"%lu h %02lu min", totalHours, minutes]; + else + [displayDecription appendFormat:@"%lu h", totalHours]; + } + + if ((0 < self.summary.length) && (displayDecription.length == 0)) { + [displayDecription appendString:self.summary]; + } + + return displayDecription; +} + +@end + +///////////////////////////////// +// Utility functions + +static MPRouteSegmentPath MPRouteSegmentPathMake(NSInteger legIndex, NSInteger stepIndex) { + MPRouteSegmentPath segmentPath = {legIndex, stepIndex}; + return segmentPath; +} + + + diff --git a/ios/Runner/MapLocationPickerController.h b/ios/Runner/MapLocationPickerController.h new file mode 100644 index 00000000..85518dd4 --- /dev/null +++ b/ios/Runner/MapLocationPickerController.h @@ -0,0 +1,29 @@ +// +// MapLocationPickerController.h +// Runner +// +// Created by Mihail Varbanov on 9/17/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import "FlutterCompletion.h" + +@interface MapLocationPickerController : UIViewController +@property (nonatomic) NSDictionary *parameters; +@property (nonatomic) FlutterCompletion completionHandler; + +- (instancetype)initWithParameters:(NSDictionary*)parameters completionHandler:(FlutterCompletion)completionHandler; +@end diff --git a/ios/Runner/MapLocationPickerController.m b/ios/Runner/MapLocationPickerController.m new file mode 100644 index 00000000..27cc6a2e --- /dev/null +++ b/ios/Runner/MapLocationPickerController.m @@ -0,0 +1,372 @@ +// +// MapLocationPickerController.m +// Runner +// +// Created by Mihail Varbanov on 9/17/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MapLocationPickerController.h" +#import "AppKeys.h" + +#import "NSDictionary+InaTypedValue.h" +#import "NSString+InaJson.h" +#import "UILabel+InaMeasure.h" + +#import +#import +#import + + +@interface MapLocationPickerController () + +@property (nonatomic, strong) NSDictionary* explore; + +@property (nonatomic, strong) GMSMapView* gmsMapView; +@property (nonatomic, strong) MPMapControl* mpMapControl; + +@property (nonatomic, strong) UILabel* locationLabel; + +@property (nonatomic, strong) GMSMarker* customLocationMarker; +@property (nonatomic, strong) GMSMarker* selectedMarker; + +@end + +@interface MPLocation(InaUtils) +@property (nonatomic, readonly) NSString* displayDescriptin; +@end + + +@implementation MapLocationPickerController + +- (instancetype)init { + if (self = [super init]) { + self.navigationItem.title = NSLocalizedString(@"Pick Location", nil); + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSave target:self action:@selector(didSave)]; + } + return self; +} + +- (instancetype)initWithParameters:(NSDictionary*)parameters completionHandler:(FlutterCompletion)completionHandler { + if (self = [self init]) { + _parameters = parameters; + + id exploreParam = [_parameters objectForKey:@"explore"]; + if ([exploreParam isKindOfClass:[NSDictionary class]]) { + _explore = exploreParam; + } + + _completionHandler = completionHandler; + } + return self; +} + +- (void)loadView { + + NSDictionary *initalLocation = [_explore inaDictForKey:@"location"]; + CLLocationCoordinate2D cameraPos = (initalLocation != nil) ? CLLocationCoordinate2DMake([initalLocation inaDoubleForKey:@"latitude"], [initalLocation inaDoubleForKey:@"longitude"]) : kInitialCameraLocation; + float cameraZoom = (initalLocation != nil) ? 20 : kInitialCameraZoom; + GMSCameraPosition *camera = [GMSCameraPosition cameraWithLatitude:cameraPos.latitude longitude:cameraPos.longitude zoom:cameraZoom]; + _gmsMapView = [GMSMapView mapWithFrame:CGRectZero camera:camera]; + _gmsMapView.delegate = self; + + _mpMapControl = [[MPMapControl alloc] initWithMap:_gmsMapView]; + _mpMapControl.delegate = self; + + _locationLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + _locationLabel.font = [UIFont systemFontOfSize:16]; + _locationLabel.numberOfLines = 0; + _locationLabel.textAlignment = NSTextAlignmentCenter; + _locationLabel.textColor = [UIColor blackColor]; + _locationLabel.shadowColor = [UIColor colorWithWhite:1 alpha:0.5]; + _locationLabel.shadowOffset = CGSizeMake(2, 2); + [_gmsMapView addSubview:_locationLabel]; + + self.view = _gmsMapView; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + [self layoutViews]; +} + +- (void)layoutViews { + CGSize contentSize = self.view.frame.size; + _gmsMapView.frame = CGRectMake(0, 0, contentSize.width, contentSize.height); + + CGSize descriptionGutter = CGSizeMake(24, 24); + CGFloat descriptionX = descriptionGutter.width; + CGFloat descriptionW = MAX(contentSize.width - 2 * descriptionGutter.width, 0); + CGFloat descriptionY = UIApplication.sharedApplication.statusBarFrame.size.height + self.navigationController.navigationBar.frame.size.height + descriptionGutter.height; + CGFloat descriptionH = [_locationLabel inaTextSizeForBoundWidth:descriptionW].height; + _locationLabel.frame = CGRectMake(descriptionX, descriptionY, descriptionW, descriptionH); +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + NSDictionary *initalLocation = [_explore inaDictForKey:@"location"]; + + if (initalLocation != nil) { + NSString *locationId = [initalLocation inaStringForKey:@"location_id"]; + MPLocation *location = (locationId != nil) ? [_mpMapControl getLocationById:locationId] : nil; +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + GMSMarker *marker = (location != nil) ? location.marker : nil; +#pragma clang diagnostic pop + if (marker == nil) { + marker = [self createCustomLocationMarkerFromExploreData:_explore]; + } + _mpMapControl.currentFloor = [initalLocation inaNumberForKey:@"floor"]; + _gmsMapView.selectedMarker = _selectedMarker = marker; + } + + [self updateLocationLabelFromMarker:_selectedMarker]; +} + +#pragma mark Location Handling + +- (void)setSelectedLocationMarker:(GMSMarker*)marker { + + if ((_customLocationMarker != nil) && (_customLocationMarker != marker)) { + [self clearCustomLocationMarker]; + } + + _selectedMarker = marker; + + [self updateLocationLabelFromMarker:marker]; +} + +- (void)updateLocationLabelFromMarker:(GMSMarker*)marker { + if (marker != nil) { + MPLocation *location = [_mpMapControl getLocation:marker]; + NSString *locationName = (location != nil) ? location.name : marker.title; + + NSString *html = [NSString stringWithFormat:@"\ + \ +
%@
\ + ", [NSString stringWithFormat:@"%@: %@", NSLocalizedString(@"Location", nil), [NSString stringWithFormat:@"%@", locationName]]]; + + _locationLabel.attributedText = [[NSAttributedString alloc] + initWithData:[html dataUsingEncoding:NSUTF8StringEncoding] + options:@{ + NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, + NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding) + } + documentAttributes:nil + error:nil + ]; + } + else { + _locationLabel.text = NSLocalizedString(@"Please select a location.", nil); + } +} + +- (GMSMarker*)createCustomLocationMarkerAtCoordinate:(CLLocationCoordinate2D)coordinate { + + NSMutableDictionary *explore = [NSMutableDictionary dictionaryWithDictionary:_explore]; + explore[@"location"] = @{ + @"location_id" : @"", + @"name" : @"", + @"description" : @"", + @"latitude" : @(coordinate.latitude), + @"longitude" : @(coordinate.longitude), + @"floor" : _mpMapControl.currentFloor ?: @(0) + }; + + return [self createCustomLocationMarkerFromExploreData:explore]; +} + +- (GMSMarker*)createCustomLocationMarkerFromExploreData:(NSDictionary*)explore { + + [self clearCustomLocationMarker]; + + NSDictionary *location = [explore inaDictForKey:@"location"]; + NSString *title = [location inaStringForKey:@"name"]; + if (title.length == 0) + title = [explore inaStringForKey:@"name"]; + if (title.length == 0) + title = NSLocalizedString(@"CUSTOM", nil); + NSString *description = [location inaStringForKey:@"description"]; + + _customLocationMarker = [[GMSMarker alloc] init]; + _customLocationMarker.position = CLLocationCoordinate2DMake([location inaDoubleForKey:@"latitude"], [location inaDoubleForKey:@"longitude"]); + _customLocationMarker.icon = [UIImage imageNamed:@"maps-icon-location-target"]; + _customLocationMarker.title = title; + _customLocationMarker.snippet = description; + _customLocationMarker.zIndex = 1; + _customLocationMarker.groundAnchor = CGPointMake(0.25, 1.0); + _customLocationMarker.userData = @{ @"location" : location ?: @{} }; + _customLocationMarker.map = _gmsMapView; + return _customLocationMarker; +} + +- (void)clearCustomLocationMarker { + if (_customLocationMarker != nil) { + if (_gmsMapView.selectedMarker == _customLocationMarker) { + _gmsMapView.selectedMarker = nil; + } + if (_selectedMarker == _customLocationMarker) { + _selectedMarker = nil; + } + + _customLocationMarker.map = nil; + _customLocationMarker = nil; + } +} + +- (void)updateCustomLocationMarker { + if (_customLocationMarker != nil) { + NSNumber *markerFloor = nil; + if ([_customLocationMarker.userData isKindOfClass:[NSDictionary class]]) { + NSDictionary *eventLocation = [_customLocationMarker.userData inaDictForKey:@"location"]; + markerFloor = [eventLocation inaNumberForKey:@"floor"]; + } + else if ([_customLocationMarker.userData isKindOfClass:[MPLocation class]]) { + markerFloor = [(MPLocation*)(_customLocationMarker.userData) floor]; + } + + bool markerVisible = (markerFloor == nil) || (markerFloor.intValue == _mpMapControl.currentFloor.intValue); + if (markerVisible && (_customLocationMarker.map == nil)) { + _customLocationMarker.map = _gmsMapView; + } + else if (!markerVisible && (_customLocationMarker.map != nil)) { + _customLocationMarker.map = nil; + } + } +} + +- (void)updateSelectedMarker { + if (_selectedMarker != nil) { + NSNumber *markerFloor = nil; + if (_selectedMarker == _customLocationMarker) { + NSDictionary *eventLocation = [_customLocationMarker.userData inaDictForKey:@"location"]; + markerFloor = [eventLocation inaNumberForKey:@"floor"]; + } + else { + MPLocation *location = [_mpMapControl getLocation:_selectedMarker]; + if (location != nil) { + markerFloor = location.floor; + } + else { + markerFloor = @(0); + } + } + + if (markerFloor.intValue == _mpMapControl.currentFloor.intValue) { + _gmsMapView.selectedMarker = _selectedMarker; + } + else { + _gmsMapView.selectedMarker = nil; + } + } +} + +- (void)didSave { + if (_selectedMarker == nil) { + NSString *title = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]; + if (title.length == 0) { + title = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"]; + } + UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:NSLocalizedString(@"Please select a location.", nil) preferredStyle:UIAlertControllerStyleAlert]; + [alertController addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]]; + [self presentViewController:alertController animated:YES completion:nil]; + } + else { + NSDictionary *locationData = nil; + if (_selectedMarker == _customLocationMarker) { + locationData = _selectedMarker.userData; + } + else { + MPLocation *location = [_mpMapControl getLocation:_selectedMarker]; + locationData = @{ @"location" : @{ + @"location_id" : location.locationId ?: @"", + @"name" : location.name ?: @"", + @"description" : location.displayDescriptin ?: @"", + @"latitude" : @(_selectedMarker.position.latitude), + @"longitude" : @(_selectedMarker.position.longitude), + @"floor" : location.floor ?: @(0) + }}; + } + + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:locationData options:0 error:NULL]; + NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + if (self.completionHandler != nil) { + self.completionHandler(jsonString); + self.completionHandler = nil; + } + + [self.navigationController popViewControllerAnimated:YES]; + } +} + +#pragma mark GMSMapViewDelegate + +- (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(nonnull GMSMarker *)marker { + [self setSelectedLocationMarker:marker]; + return FALSE; // perform default behavior +} + +- (void)mapView:(GMSMapView *)mapView didTapAtCoordinate:(CLLocationCoordinate2D)coordinate { + if ((_selectedMarker != nil) || (_customLocationMarker != nil)) { + [self clearCustomLocationMarker]; + [self setSelectedLocationMarker:nil]; + _gmsMapView.selectedMarker = nil; + } + else { + GMSMarker *customLocationMarker = [self createCustomLocationMarkerAtCoordinate:coordinate]; + [self setSelectedLocationMarker:customLocationMarker]; + _gmsMapView.selectedMarker = customLocationMarker; + } +} + +#pragma mark MPDirectionsRendererDelegate + +- (void)floorDidChange: (nonnull NSNumber*)floor { + [self updateCustomLocationMarker]; + [self updateSelectedMarker]; +} + + +@end + +@implementation MPLocation(InaUtils) + +- (NSString*)displayDescriptin { + if (0 < self.descr.length) { + return self.descr; + } + else { + NSMutableString *customDescr = [[NSMutableString alloc] init]; + if (0 < self.type.length) { + [customDescr appendString:self.type]; + } + if (0 < self.building.length) { + if (0 < customDescr.length) { + [customDescr appendString:@", "]; + } + [customDescr appendString:self.building]; + } + if ((0 < self.venue.length) && ![self.venue isEqualToString:self.building]) { + if (0 < customDescr.length) { + [customDescr appendString:@", "]; + } + [customDescr appendString:self.venue]; + } + return customDescr; + } + +} + +@end diff --git a/ios/Runner/MapMarkerView.h b/ios/Runner/MapMarkerView.h new file mode 100644 index 00000000..df422aea --- /dev/null +++ b/ios/Runner/MapMarkerView.h @@ -0,0 +1,48 @@ +// +// MapMarkerView.h +// Runner +// +// Created by Mihail Varbanov on 7/15/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +///////////////////////////////// +// MapMarkerDisplayMode + +typedef NS_ENUM(NSInteger, MapMarkerDisplayMode) { + MapMarkerDisplayMode_Plain, + MapMarkerDisplayMode_Title, + MapMarkerDisplayMode_Extended, +}; + +///////////////////////////////// +// MapMarkerView + +@interface MapMarkerView : UIView ++ (instancetype)createFromExplore:(NSDictionary*)explore; + +@property (nonatomic) MapMarkerDisplayMode displayMode; +@property (nonatomic) bool blurred; +@property (nonatomic, readonly) NSString *title; +@property (nonatomic, readonly) NSString *descr; +@property (nonatomic, readonly) CGPoint anchor; + ++ (UIImage*)markerImageWithHexColor:(NSString*)hexColor; +@end + + + diff --git a/ios/Runner/MapMarkerView.m b/ios/Runner/MapMarkerView.m new file mode 100644 index 00000000..5fb60492 --- /dev/null +++ b/ios/Runner/MapMarkerView.m @@ -0,0 +1,346 @@ +// +// MapMarkerView.m +// Runner +// +// Created by Mihail Varbanov on 7/15/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MapMarkerView.h" + +#import "NSDictionary+InaTypedValue.h" +#import "UIColor+InaParse.h" +#import "NSDictionary+UIUCExplore.h" +#import "InaSymbols.h" + +#import + +@interface MapExploreMarkerView : MapMarkerView +- (instancetype)initWithExplore:(NSDictionary*)explore; +@end + +@interface MapExploresMarkerView : MapMarkerView +- (instancetype)initWithExplore:(NSDictionary*)explore; +@end + +///////////////////////////////// +// MapMarkerView + +@interface MapMarkerView() {} +@property (nonatomic) NSDictionary *explore; +@end + +@implementation MapMarkerView + +- (id)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + } + return self; +} + ++ (instancetype)createFromExplore:(NSDictionary*)explore { + return (1 < explore.uiucExplores.count) ? + [[MapExploresMarkerView alloc] initWithExplore:explore] : + [[MapExploreMarkerView alloc] initWithExplore:explore]; +} + +- (void)setDisplayMode:(MapMarkerDisplayMode)displayMode { + if (_displayMode != displayMode) { + _displayMode = displayMode; + [self updateDisplayMode]; + } +} + +- (void)updateDisplayMode { +} + +- (void)setBlurred:(bool)blurred { + if (_blurred != blurred) { + _blurred = blurred; + [self updateBlurred]; + } +} + +- (void)updateBlurred { +} + ++ (UIImage*)markerImageWithHexColor:(NSString*)hexColor { + + static NSMutableDictionary *gMarkerImageMap = nil; + if (gMarkerImageMap == nil) { + gMarkerImageMap = [[NSMutableDictionary alloc] init]; + } + + UIImage *image = [gMarkerImageMap objectForKey:hexColor]; + if (image == nil) { + UIColor *color = [UIColor inaColorWithHex:hexColor]; + image = [GMSMarker markerImageWithColor:color]; + if (image != nil) { + [gMarkerImageMap setObject:image forKey:hexColor]; + } + } + return image; +} + +@end + + +///////////////////////////////// +// MapExploreMarkerView + +CGFloat const kExploreMarkerIconSize0 = 20; +CGFloat const kExploreMarkerIconSize1 = 30; +CGFloat const kExploreMarkerIconSize2 = 40; +CGFloat const kExploreMarkerIconSize[3] = { kExploreMarkerIconSize0, kExploreMarkerIconSize1, kExploreMarkerIconSize2 }; + +CGFloat const kExploreMarkerIconGutter = 3; +CGFloat const kExploreMarkerTitleFontSize = 12; +CGFloat const kExploreMarkerDescrFontSize = 12; +CGSize const kExploreMarkerViewSize = { 180, kExploreMarkerIconSize2 + kExploreMarkerIconGutter + kExploreMarkerTitleFontSize + kExploreMarkerDescrFontSize }; + +@interface MapExploreMarkerView() { + UIImageView *iconView; + UILabel *titleLabel; + UILabel *descrLabel; +} +@end + +@implementation MapExploreMarkerView + +- (id)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + + //self.backgroundColor = [UIColor colorWithWhite:0 alpha:0.1]; + + //UIImage *markerImage = [UIImage imageNamed:@"maps-icon-marker-circle-40"]; + iconView = [[UIImageView alloc] initWithFrame:CGRectZero]; + [self addSubview:iconView]; + + titleLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + titleLabel.font = [UIFont boldSystemFontOfSize:kExploreMarkerTitleFontSize]; + titleLabel.textAlignment = NSTextAlignmentCenter; + titleLabel.textColor = [UIColor inaColorWithHex:@"13294b"]; // darkSlateBlueTwo + titleLabel.shadowColor = [UIColor colorWithWhite:1.0 alpha:0.5]; + titleLabel.shadowOffset = CGSizeMake(1, 1); + [self addSubview:titleLabel]; + + descrLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + descrLabel.font = [UIFont boldSystemFontOfSize:kExploreMarkerDescrFontSize]; + descrLabel.textAlignment = NSTextAlignmentCenter; + descrLabel.textColor = [UIColor inaColorWithHex:@"244372"]; // darkSlateBlue + descrLabel.shadowColor = [UIColor colorWithWhite:1.0 alpha:0.5]; + descrLabel.shadowOffset = CGSizeMake(1, 1); + [self addSubview:descrLabel]; + + [self updateDisplayMode]; + } + return self; +} + +- (instancetype)initWithExplore:(NSDictionary*)explore { + if (self = [self initWithFrame:CGRectMake(0, 0, kExploreMarkerViewSize.width, kExploreMarkerViewSize.height)]) { + self.explore = explore; + iconView.image = [self.class markerImageWithHexColor:explore.uiucExploreMarkerHexColor]; + titleLabel.text = explore.uiucExploreTitle; + descrLabel.text = explore.uiucExploreDescription; + } + return self; +} + +- (void)layoutSubviews { + CGSize contentSize = self.frame.size; + + CGFloat y = 0; + NSInteger maxIconIndex = _countof(kExploreMarkerIconSize) - 1; + + CGSize iconSize = iconView.image.size; + + CGFloat iconH = kExploreMarkerIconSize[MIN(MAX(self.displayMode, 0), maxIconIndex)]; + CGFloat iconW = (0 < iconSize.height) ? (iconSize.width * iconH / iconSize.height) : 0; + + CGFloat iconMaxH = kExploreMarkerIconSize[maxIconIndex]; + + iconView.frame = CGRectMake((contentSize.width - iconW) / 2, iconMaxH - iconH, iconW, iconH); + y += iconMaxH + kExploreMarkerIconGutter; + + CGFloat titleH = titleLabel.font.pointSize; + titleLabel.frame = CGRectMake(0, y, contentSize.width, titleH); + y += titleH; + + CGFloat descrH = descrLabel.font.pointSize; + descrLabel.frame = CGRectMake(0, y, contentSize.width, descrH); + y += descrH; +} + +- (void)updateDisplayMode { + titleLabel.hidden = (self.displayMode < MapMarkerDisplayMode_Title); + descrLabel.hidden = (self.displayMode < MapMarkerDisplayMode_Extended); + [self setNeedsLayout]; +} + +- (void)updateBlurred { + iconView.image = [self.class markerImageWithHexColor:self.blurred ? @"#a0a0a0" : self.explore.uiucExploreMarkerHexColor]; + titleLabel.textColor = self.blurred ? [UIColor grayColor] : [UIColor inaColorWithHex:@"13294b"]; // darkSlateBlueTwo + descrLabel.textColor = self.blurred ? [UIColor grayColor] : [UIColor inaColorWithHex:@"244372"]; // darkSlateBlue +} + +- (NSString*)title { + return titleLabel.text; +} + +- (NSString*)descr { + return descrLabel.text; +} + +- (CGPoint)anchor { + return CGPointMake(0.5, kExploreMarkerIconSize[_countof(kExploreMarkerIconSize) - 1] / kExploreMarkerViewSize.height); +} + +@end + +///////////////////////////////// +// MapExploresMarkerView + +CGFloat const kExploresMarkerIconSize0 = 16; +CGFloat const kExploresMarkerIconSize1 = 20; +CGFloat const kExploresMarkerIconSize2 = 24; +CGFloat const kExploresMarkerIconSize[3] = { kExploresMarkerIconSize0, kExploresMarkerIconSize1, kExploresMarkerIconSize2 }; + +CGFloat const kExploresMarkerCountFontSize0 = 10; +CGFloat const kExploresMarkerCountFontSize1 = 12.5; +CGFloat const kExploresMarkerCountFontSize2 = 15; +CGFloat const kExploresMarkerCountFontSize[3] = { kExploresMarkerCountFontSize0, kExploresMarkerCountFontSize1, kExploresMarkerCountFontSize2 }; + +CGFloat const kExploresMarkerIconGutter = 3; +CGFloat const kExploresMarkerTitleFontSize = 12; +CGFloat const kExploresMarkerDescrFontSize = 12; +CGSize const kExploresMarkerViewSize = { 180, kExploresMarkerIconSize2 + kExploresMarkerIconGutter + kExploresMarkerTitleFontSize + kExploresMarkerDescrFontSize }; + +@interface MapExploresMarkerView() { + UIView *circleView; + UILabel *countLabel; + UILabel *titleLabel; + UILabel *descrLabel; +} +@end + +@implementation MapExploresMarkerView + +- (id)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + + //self.backgroundColor = [UIColor colorWithWhite:0 alpha:0.1]; + + circleView = [[UIView alloc] initWithFrame:CGRectZero]; + [self addSubview:circleView]; + + countLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + //countLabel.font = [UIFont boldSystemFontOfSize:kExploresMarkerCountFontSize0]; + countLabel.textAlignment = NSTextAlignmentCenter; + countLabel.textColor = [UIColor whiteColor]; + [self addSubview:countLabel]; + + titleLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + titleLabel.font = [UIFont boldSystemFontOfSize:kExploresMarkerTitleFontSize]; + titleLabel.textAlignment = NSTextAlignmentCenter; + titleLabel.textColor = [UIColor inaColorWithHex:@"13294b"]; // darkSlateBlueTwo + titleLabel.shadowColor = [UIColor colorWithWhite:1.0 alpha:0.5]; + titleLabel.shadowOffset = CGSizeMake(1, 1); + [self addSubview:titleLabel]; + + descrLabel = [[UILabel alloc] initWithFrame:CGRectZero]; + descrLabel.font = [UIFont boldSystemFontOfSize:kExploresMarkerDescrFontSize]; + descrLabel.textAlignment = NSTextAlignmentCenter; + descrLabel.textColor = [UIColor inaColorWithHex:@"244372"]; // darkSlateBlue + descrLabel.shadowColor = [UIColor colorWithWhite:1.0 alpha:0.5]; + descrLabel.shadowOffset = CGSizeMake(1, 1); + [self addSubview:descrLabel]; + + [self updateDisplayMode]; + } + return self; +} + +- (instancetype)initWithExplore:(NSDictionary*)explore { + if (self = [self initWithFrame:CGRectMake(0, 0, kExploresMarkerViewSize.width, kExploresMarkerViewSize.height)]) { + self.explore = explore; + + circleView.backgroundColor = [UIColor inaColorWithHex:explore.uiucExploreMarkerHexColor]; + circleView.layer.borderColor = [[UIColor blackColor] CGColor]; + circleView.layer.borderWidth = 0.5; + + countLabel.text = [NSString stringWithFormat:@"%d", (int)explore.uiucExplores.count]; + titleLabel.text = explore.uiucExploreTitle; + descrLabel.text = explore.uiucExploreDescription; + } + return self; +} + +- (void)layoutSubviews { + CGSize contentSize = self.frame.size; + + CGFloat y = 0; + NSInteger maxIconIndex = _countof(kExploresMarkerIconSize) - 1; + CGFloat iconSize = kExploresMarkerIconSize[MIN(MAX(self.displayMode, 0), maxIconIndex)]; + CGFloat iconMaxSize = kExploresMarkerIconSize[maxIconIndex]; + CGFloat iconY = (iconMaxSize - iconSize) / 2; + CGFloat iconX = (contentSize.width - iconSize) / 2; + + circleView.frame = CGRectMake(iconX, iconY, iconSize, iconSize); + if (circleView.layer.cornerRadius != iconSize/2) { + circleView.layer.cornerRadius = iconSize/2; + } + + CGFloat countH = countLabel.font.pointSize; + countLabel.frame = CGRectMake(iconX, iconY + (iconSize - countH) / 2 , iconSize, countH); + + y += iconMaxSize + kExploresMarkerIconGutter; + + CGFloat titleH = titleLabel.font.pointSize; + titleLabel.frame = CGRectMake(0, y, contentSize.width, titleH); + y += titleH; + + CGFloat descrH = descrLabel.font.pointSize; + descrLabel.frame = CGRectMake(0, y, contentSize.width, descrH); + y += descrH; +} + +- (void)updateDisplayMode { + countLabel.font = [UIFont boldSystemFontOfSize:kExploresMarkerCountFontSize[MIN(MAX(self.displayMode, 0), _countof(kExploresMarkerCountFontSize) - 1)]]; + titleLabel.hidden = (self.displayMode < MapMarkerDisplayMode_Title); + descrLabel.hidden = (self.displayMode < MapMarkerDisplayMode_Extended); + [self setNeedsLayout]; +} + +- (void)updateBlurred { + circleView.backgroundColor = [UIColor inaColorWithHex:self.blurred ? @"#a0a0a0" : self.explore.uiucExploreMarkerHexColor]; + titleLabel.textColor = self.blurred ? [UIColor grayColor] : [UIColor inaColorWithHex:@"13294b"]; // darkSlateBlueTwo + descrLabel.textColor = self.blurred ? [UIColor grayColor] : [UIColor inaColorWithHex:@"244372"]; // darkSlateBlue +} + +- (NSString*)title { + return titleLabel.text; +} + +- (NSString*)descr { + return descrLabel.text; +} + +- (CGPoint)anchor { + return CGPointMake(0.5, kExploresMarkerIconSize[_countof(kExploresMarkerIconSize) - 1] / 2 / kExploreMarkerViewSize.height); +} + + +@end diff --git a/ios/Runner/MapView.h b/ios/Runner/MapView.h new file mode 100644 index 00000000..a79f64e9 --- /dev/null +++ b/ios/Runner/MapView.h @@ -0,0 +1,44 @@ +// +// MapView.h +// Runner +// +// Created by Mihail Varbanov on 5/21/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import +#import + +///////////////////////////////// +// MapViewFactory + +@interface MapViewFactory : NSObject +- (instancetype)initWithMessenger:(NSObject*)messenger; +@end + +///////////////////////////////// +// MapViewController + +@interface MapViewController : NSObject +- (instancetype)initWithFrame:(CGRect)frame viewId:(int64_t) viewId args:(id)args binaryMessenger:(NSObject*)messenger; +@end + +///////////////////////////////// +// MapView + +@interface MapView : UIView +@end + diff --git a/ios/Runner/MapView.m b/ios/Runner/MapView.m new file mode 100644 index 00000000..1695d92a --- /dev/null +++ b/ios/Runner/MapView.m @@ -0,0 +1,387 @@ +// +// MapView.m +// Runner +// +// Created by Mihail Varbanov on 5/21/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "MapView.h" +#import "AppKeys.h" +#import "AppDelegate.h" +#import "MapMarkerView.h" + +#import "NSDictionary+InaTypedValue.h" +#import "CLLocationCoordinate2D+InaUtils.h" +#import "CGGeometry+InaUtils.h" +#import "NSString+InaJson.h" +#import "NSDate+UIUCUtils.h" +#import "NSDictionary+UIUCExplore.h" + +#import +#import + +///////////////////////////////// +// MapView + +@interface MapView() { + int64_t _mapId; + NSArray* _explores; + NSMutableSet* _markers; + float _currentZoom; + bool _didFirstLayout; + bool _enabled; +} +@property (nonatomic, readonly) GMSMapView* mapView; +@property (nonatomic, readonly) MPMapControl* mapControl; +@end + +@implementation MapView + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + GMSCameraPosition *camera = [GMSCameraPosition cameraWithLatitude:kInitialCameraLocation.latitude longitude:kInitialCameraLocation.longitude zoom:(_currentZoom = kInitialCameraZoom)]; + CGRect mapRect = CGRectMake(0, 0, self.frame.size.width, self.frame.size.height); + _mapView = [GMSMapView mapWithFrame:mapRect camera:camera]; + _mapView.delegate = self; + _mapView.settings.compassButton = YES; + _mapView.accessibilityElementsHidden = NO; + [self addSubview:_mapView]; + + _mapControl = [[MPMapControl alloc] initWithMap:_mapView]; + _mapControl.delegate = self; + + _markers = [[NSMutableSet alloc] init]; + _enabled = true; + } + return self; +} + +- (instancetype)initWithFrame:(CGRect)frame mapId:(int64_t)mapId parameters:(NSDictionary*)parameters { + if (self = [self initWithFrame:frame]) { + _mapId = mapId; + [self enableMyLocation:[parameters inaBoolForKey:@"myLocationEnabled"]]; + } + return self; +} + +- (void)layoutSubviews { + CGSize contentSize = self.frame.size; + _mapView.frame = CGRectMake(0, 0, contentSize.width, contentSize.height); + + if (!_didFirstLayout) { + _didFirstLayout = true; + [self applyMarkers]; + [self applyEnabled]; + } +} + +- (void)applyExplores:(NSArray*)explores options:(NSDictionary*)options { + [self buildExplores:explores options:(NSDictionary*)options]; + if (_didFirstLayout) { + [self applyMarkers]; + } +} + +- (void)buildExplores:(NSArray*)rawExplores options:(NSDictionary*)options { + NSMutableArray *mappedExploreGroups = [[NSMutableArray alloc] init]; + + double exploreLocationThresoldDistance = (options != nil) ? [options inaDoubleForKey:@"LocationThresoldDistance" defaults:kExploreLocationThresoldDistance] : kExploreLocationThresoldDistance; + + for (NSDictionary *explore in rawExplores) { + if ([explore isKindOfClass:[NSDictionary class]]) { + int exploreFloor = explore.uiucExploreLocationFloor; + CLLocationCoordinate2D exploreCoord = explore.uiucExploreLocationCoordinate; + if (CLLocationCoordinate2DIsValid(exploreCoord)) { + + bool exploreMapped = false; + for (NSMutableArray *mappedExpoloreGroup in mappedExploreGroups) { + for (NSDictionary *mappedExplore in mappedExpoloreGroup) { + + double distance = CLLocationCoordinate2DInaDistance(exploreCoord, mappedExplore.uiucExploreLocationCoordinate); + if ((distance < exploreLocationThresoldDistance) && (exploreFloor == mappedExplore.uiucExploreLocationFloor)) { + [mappedExpoloreGroup addObject:explore]; + exploreMapped = true; + break; + } + } + if (exploreMapped) { + break; + } + } + + if (!exploreMapped) { + NSMutableArray *mappedExpoloreGroup = [[NSMutableArray alloc] initWithObjects:explore, nil]; + [mappedExploreGroups addObject:mappedExpoloreGroup]; + } + } + } + } + + NSMutableArray *resultExplores = [[NSMutableArray alloc] init]; + for (NSMutableArray *mappedExpoloreGroup in mappedExploreGroups) { + NSDictionary *anExplore = mappedExpoloreGroup.firstObject; + if (mappedExpoloreGroup.count == 1) { + [resultExplores addObject:anExplore]; + } + else { + [resultExplores addObject:[NSDictionary uiucExploreFromGroup:mappedExpoloreGroup]]; + } + } + + _explores = resultExplores; +} + +- (void)enable:(bool)enable { + if (_enabled != enable) { + _enabled = enable; + + if (_didFirstLayout) { + [self applyEnabled]; + } + } +} + +- (void)applyEnabled { + if (_enabled) { + if (_mapView.superview == nil) { + [self addSubview:_mapView]; + } + } + else { + if (_mapView.superview == self) { + [_mapView removeFromSuperview]; + } + } +} + +- (void)enableMyLocation:(bool)enableMyLocation { + if (_mapView.myLocationEnabled != enableMyLocation) { + _mapView.myLocationEnabled = enableMyLocation; + _mapView.settings.myLocationButton = enableMyLocation; + } +} + +#pragma mark Display + +- (void)applyMarkers { + + for (GMSMarker *marker in _markers) { + marker.map = nil; + } + [_markers removeAllObjects]; + + GMSCoordinateBounds *bounds = nil; + + for (NSDictionary *explore in _explores) { + if ([explore isKindOfClass:[NSDictionary class]]) { + NSDictionary *exploreLocation = [explore inaDictForKey:@"location"]; + if (exploreLocation != nil) { + CLLocationDegrees latitude = [exploreLocation inaDoubleForKey:@"latitude"]; + CLLocationDegrees longitude = [exploreLocation inaDoubleForKey:@"longitude"]; + + GMSMarker *marker = [[GMSMarker alloc] init]; + marker.position = CLLocationCoordinate2DMake(latitude, longitude); + + MapMarkerView *iconView = [MapMarkerView createFromExplore:explore]; + marker.iconView = iconView; + marker.title = iconView.title; + marker.snippet = iconView.descr; + marker.groundAnchor = iconView.anchor; + +// marker.icon = [UIImage imageNamed:(1 < explore.uiucExplores.count) ? @"maps-icon-marker-circle-20" : @"maps-icon-marker-pin-30" ]; +// marker.title = explore.uiucExploreTitle; +// marker.snippet = explore.uiucExploreDescription; +// marker.groundAnchor = CGPointMake(0.5, 1); + + marker.zIndex = 1; + marker.userData = @{ @"explore" : explore }; + [_markers addObject:marker]; + + if (bounds == nil) { + bounds = [[GMSCoordinateBounds alloc] initWithCoordinate:marker.position coordinate:marker.position]; + } + else { + bounds = [bounds includingCoordinate:marker.position]; + } + } + } + } + + if ((bounds != nil) && _didFirstLayout) { + _currentZoom = 0; + GMSCameraUpdate *update = [GMSCameraUpdate fitBounds:bounds withPadding:50.0f]; + [_mapView moveCamera:update]; + // idleAtCameraPosition -> updateMarkers + } + else { + [self updateMarkers]; + } +} + +- (void)updateMarkers { + + int currentFloor = _mapControl.currentFloor.intValue; + + for (GMSMarker *marker in _markers) { + NSDictionary *explore = nil, *exploreLocation = nil; + if ([marker.userData isKindOfClass:[NSDictionary class]]) { + explore = [marker.userData inaDictForKey:@"explore"]; + exploreLocation = [explore inaDictForKey:@"location"]; + } + + MapMarkerView *iconView = [marker.iconView isKindOfClass:[MapMarkerView class]] ? ((MapMarkerView*)marker.iconView) : nil; + if ((iconView != nil) && (exploreLocation != nil)) { + iconView.displayMode = (_mapView.camera.zoom < kMarkerThresold1Zoom) ? MapMarkerDisplayMode_Plain : ((_mapView.camera.zoom < kMarkerThresold2Zoom) ? MapMarkerDisplayMode_Title : MapMarkerDisplayMode_Extended); + } + + NSNumber *markerFloor = nil; + if (exploreLocation != nil) { + markerFloor = [exploreLocation inaNumberForKey:@"floor"]; + } + + bool markerVisible = ((markerFloor == nil) || (markerFloor.intValue == currentFloor)); + + if (markerVisible && (marker.map == nil)) { + marker.map = _mapView; + } + else if (!markerVisible && (marker.map != nil)) { + marker.map = nil; + } + } + +} + +#pragma mark GMSMapViewDelegate + +- (void)mapView:(GMSMapView *)mapView didTapAtCoordinate:(CLLocationCoordinate2D)coordinate { + NSDictionary *arguments = @{ + @"mapId" : @(_mapId) + }; + [AppDelegate.sharedInstance.flutterMethodChannel invokeMethod:@"map.explore.clear" arguments:arguments.inaJsonString]; +} + +- (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(nonnull GMSMarker *)marker { + NSDictionary *explore = [marker.userData isKindOfClass:[NSDictionary class]] ? [marker.userData inaDictForKey:@"explore"] : nil; + id exploreParam = explore.uiucExplores ?: explore; + if (exploreParam != nil) { + NSDictionary *arguments = @{ + @"mapId" : @(_mapId), + @"explore" : exploreParam + }; + [AppDelegate.sharedInstance.flutterMethodChannel invokeMethod:@"map.explore.select" arguments:arguments.inaJsonString]; + return TRUE; // do nothing + } + else { + return FALSE; // do default behavior + } +} + +- (void)mapView:(GMSMapView *)mapView idleAtCameraPosition:(GMSCameraPosition *)position { + if (_currentZoom != position.zoom) { + _currentZoom = position.zoom; + [self updateMarkers]; + } +} + +#pragma mark MPDirectionsRendererDelegate + +- (void)floorDidChange:(NSNumber*)floor { + [self updateMarkers]; +} + + +@end + +///////////////////////////////// +// MapViewFactory + +@implementation MapViewFactory { + NSObject* _messenger; +} + +- (instancetype)initWithMessenger:(NSObject*)messenger { + if (self = [super init]) { + _messenger = messenger; + } + return self; +} + +- (NSObject*)createArgsCodec { + return [FlutterStandardMessageCodec sharedInstance]; +} + +- (NSObject*)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args { + return [[MapViewController alloc] initWithFrame:frame viewId:viewId args:args binaryMessenger:_messenger]; +} + +@end + +///////////////////////////////// +// MapViewController + +@interface MapViewController() { + int64_t _viewId; + FlutterMethodChannel* _channel; + MapView *_mapView; + +} +@end + +@implementation MapViewController + +- (instancetype)initWithFrame:(CGRect)frame viewId:(int64_t)viewId args:(id)args binaryMessenger:(NSObject*)messenger { + if (self = [super init]) { + _viewId = viewId; + + NSDictionary *parameters = [args isKindOfClass:[NSDictionary class]] ? args : nil; + _mapView = [[MapView alloc] initWithFrame:frame mapId:viewId parameters:parameters]; + + NSString* channelName = [NSString stringWithFormat:@"edu.illinois.covid/mapview_%lld", (long long)viewId]; + _channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:messenger]; + __weak __typeof__(self) weakSelf = self; + [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { + [weakSelf onMethodCall:call result:result]; + }]; + + } + return self; +} + +- (UIView*)view { + return _mapView; +} + +- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if ([[call method] isEqualToString:@"placePOIs"]) { + NSDictionary *parameters = [call.arguments isKindOfClass:[NSDictionary class]] ? call.arguments : nil; + NSArray *exploresJsonList = [parameters inaArrayForKey:@"explores"]; + NSDictionary *optionsJsonMap = [parameters inaDictForKey:@"options"]; + [_mapView applyExplores:exploresJsonList options:optionsJsonMap]; + result(@(true)); + } else if ([[call method] isEqualToString:@"enable"]) { + bool enable = [call.arguments isKindOfClass:[NSNumber class]] ? [(NSNumber*)(call.arguments) boolValue] : false; + [_mapView enable:enable]; + } else if ([[call method] isEqualToString:@"enableMyLocation"]) { + bool enableMyLocation = [call.arguments isKindOfClass:[NSNumber class]] ? [(NSNumber*)(call.arguments) boolValue] : false; + [_mapView enableMyLocation:enableMyLocation]; + } else { + result(FlutterMethodNotImplemented); + } +} + +@end + + diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d80a1167 --- /dev/null +++ b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "filename" : "Icon-App-20@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-60@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "Icon-App-20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-20@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-App-29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-40@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-App-76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "Icon-App-83.5@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "Icon-App-1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-1024.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-1024.png new file mode 100644 index 00000000..48533bb9 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-1024.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20.png new file mode 100644 index 00000000..37ad5426 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20@2x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20@2x.png new file mode 100644 index 00000000..bb7c90b4 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20@2x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20@3x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20@3x.png new file mode 100644 index 00000000..6a0bc7a1 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20@3x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29.png new file mode 100644 index 00000000..c3a212c5 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29@2x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29@2x.png new file mode 100644 index 00000000..2e233fd8 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29@2x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29@3x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29@3x.png new file mode 100644 index 00000000..1d51decc Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29@3x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40.png new file mode 100644 index 00000000..bb7c90b4 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40@2x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40@2x.png new file mode 100644 index 00000000..3119e150 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40@2x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40@3x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40@3x.png new file mode 100644 index 00000000..7da10b7a Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40@3x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60@2x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60@2x.png new file mode 100644 index 00000000..7da10b7a Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60@2x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60@3x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60@3x.png new file mode 100644 index 00000000..219a99e7 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60@3x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76.png new file mode 100644 index 00000000..9f0931ec Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76@2x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76@2x.png new file mode 100644 index 00000000..e1c7c2fe Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76@2x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5@2x.png b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5@2x.png new file mode 100644 index 00000000..5abcf85a Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5@2x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/Contents.json b/ios/Runner/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/Runner/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9b4fdc7d Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..2c579637 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..485d6689 Binary files /dev/null and b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/ios/Runner/Resources/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Resources/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..3f6d0bc8 --- /dev/null +++ b/ios/Runner/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Resources/Base.lproj/Main.storyboard b/ios/Runner/Resources/Base.lproj/Main.storyboard new file mode 100644 index 00000000..62b2c04b --- /dev/null +++ b/ios/Runner/Resources/Base.lproj/Main.storyboard @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Resources/Images/button-icon-nav-clear.png b/ios/Runner/Resources/Images/button-icon-nav-clear.png new file mode 100644 index 00000000..e1955794 Binary files /dev/null and b/ios/Runner/Resources/Images/button-icon-nav-clear.png differ diff --git a/ios/Runner/Resources/Images/button-icon-nav-location.png b/ios/Runner/Resources/Images/button-icon-nav-location.png new file mode 100644 index 00000000..0b1e58e7 Binary files /dev/null and b/ios/Runner/Resources/Images/button-icon-nav-location.png differ diff --git a/ios/Runner/Resources/Images/button-icon-nav-next.png b/ios/Runner/Resources/Images/button-icon-nav-next.png new file mode 100644 index 00000000..6499528e Binary files /dev/null and b/ios/Runner/Resources/Images/button-icon-nav-next.png differ diff --git a/ios/Runner/Resources/Images/button-icon-nav-prev.png b/ios/Runner/Resources/Images/button-icon-nav-prev.png new file mode 100644 index 00000000..d4b59a8f Binary files /dev/null and b/ios/Runner/Resources/Images/button-icon-nav-prev.png differ diff --git a/ios/Runner/Resources/Images/button-icon-nav-refresh.png b/ios/Runner/Resources/Images/button-icon-nav-refresh.png new file mode 100644 index 00000000..2ffd0eff Binary files /dev/null and b/ios/Runner/Resources/Images/button-icon-nav-refresh.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-circle-10@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-circle-10@2x.png new file mode 100644 index 00000000..81cdda11 Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-circle-10@2x.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-circle-20@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-circle-20@2x.png new file mode 100644 index 00000000..f3551507 Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-circle-20@2x.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-circle-30@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-circle-30@2x.png new file mode 100644 index 00000000..26c17f83 Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-circle-30@2x.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-circle-40@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-circle-40@2x.png new file mode 100644 index 00000000..d8b46483 Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-circle-40@2x.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-pin-10@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-pin-10@2x.png new file mode 100644 index 00000000..541c6705 Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-pin-10@2x.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-pin-20@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-pin-20@2x.png new file mode 100644 index 00000000..a8b3d281 Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-pin-20@2x.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-pin-30@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-pin-30@2x.png new file mode 100644 index 00000000..c6dd2eef Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-pin-30@2x.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-pin-40@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-pin-40@2x.png new file mode 100644 index 00000000..7257b65c Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-pin-40@2x.png differ diff --git a/ios/Runner/Resources/Images/maps-icon-marker-pin-50@2x.png b/ios/Runner/Resources/Images/maps-icon-marker-pin-50@2x.png new file mode 100644 index 00000000..0308912c Binary files /dev/null and b/ios/Runner/Resources/Images/maps-icon-marker-pin-50@2x.png differ diff --git a/ios/Runner/Resources/Images/travel-mode-bicycle@2x.png b/ios/Runner/Resources/Images/travel-mode-bicycle@2x.png new file mode 100644 index 00000000..e3d7dcad Binary files /dev/null and b/ios/Runner/Resources/Images/travel-mode-bicycle@2x.png differ diff --git a/ios/Runner/Resources/Images/travel-mode-drive@2x.png b/ios/Runner/Resources/Images/travel-mode-drive@2x.png new file mode 100644 index 00000000..db3fa67d Binary files /dev/null and b/ios/Runner/Resources/Images/travel-mode-drive@2x.png differ diff --git a/ios/Runner/Resources/Images/travel-mode-transit@2x.png b/ios/Runner/Resources/Images/travel-mode-transit@2x.png new file mode 100644 index 00000000..60d26d2d Binary files /dev/null and b/ios/Runner/Resources/Images/travel-mode-transit@2x.png differ diff --git a/ios/Runner/Resources/Images/travel-mode-unknown@2x.png b/ios/Runner/Resources/Images/travel-mode-unknown@2x.png new file mode 100644 index 00000000..a39d5231 Binary files /dev/null and b/ios/Runner/Resources/Images/travel-mode-unknown@2x.png differ diff --git a/ios/Runner/Resources/Images/travel-mode-walk@2x.png b/ios/Runner/Resources/Images/travel-mode-walk@2x.png new file mode 100644 index 00000000..79bd533b Binary files /dev/null and b/ios/Runner/Resources/Images/travel-mode-walk@2x.png differ diff --git a/ios/Runner/Resources/en.lproj/Localizable.strings b/ios/Runner/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..bbd1e429 --- /dev/null +++ b/ios/Runner/Resources/en.lproj/Localizable.strings @@ -0,0 +1,38 @@ +// +// File.strings +// Runner + +// Created by Mladen Dryankov on 26.03.20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// English + +"Directions" = "Directions"; +"Detecting current location..." = "Detecting current location..."; +"Resolving target address ..." = "Resolving target address ..."; +"Failed to resolve target address." = "Failed to resolve target address."; +"Detecting current location..." = "Detecting current location..."; +"Failed to detect current location." = "Failed to detect current location."; +"Looking for route..." = "Looking for route..."; +"Failed to find route." = "Failed to find route."; +"START" = "START"; +"FINISH" = "FINISH"; +"Pick Location" = "Pick Location"; +"Location" = "Location"; +"Please select a location." = "Please select a location."; +"CUSTOM" = "CUSTOM"; +"Today" = "Today"; +"Tomorrow" = "Tomorrow"; diff --git a/ios/Runner/Resources/es.lproj/Localizable.strings b/ios/Runner/Resources/es.lproj/Localizable.strings new file mode 100644 index 00000000..71784443 --- /dev/null +++ b/ios/Runner/Resources/es.lproj/Localizable.strings @@ -0,0 +1,38 @@ +// +//File.strings +//Runner + +//Created by Mladen Dryankov on 26.03.20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Spanish + +"Directions" = "Directions"; +"Detecting current location..." = "Detecting current location..."; +"Resolving target address ..." = "Resolving target address ..."; +"Failed to resolve target address." = "Failed to resolve target address."; +"Detecting current location..." = "Detecting current location..."; +"Failed to detect current location." = "Failed to detect current location."; +"Looking for route..." = "Looking for route..."; +"Failed to find route." = "Failed to find route."; +"START" = "START"; +"FINISH" = "FINISH"; +"Pick Location" = "Pick Location"; +"Location" = "Location"; +"Please select a location." = "Please select a location."; +"CUSTOM" = "CUSTOM"; +"Today" = "Today"; +"Tomorrow" = "Tomorrow"; diff --git a/ios/Runner/Resources/zh.lproj/Localizable.strings b/ios/Runner/Resources/zh.lproj/Localizable.strings new file mode 100644 index 00000000..08cd685e --- /dev/null +++ b/ios/Runner/Resources/zh.lproj/Localizable.strings @@ -0,0 +1,39 @@ + +// File.strings +// Runner +// +// Created by Mladen Dryankov on 26.03.20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +// Chinese + +"Directions" = "Directions"; +"Detecting current location..." = "Detecting current location..."; +"Resolving target address ..." = "Resolving target address ..."; +"Failed to resolve target address." = "Failed to resolve target address."; +"Detecting current location..." = "Detecting current location..."; +"Failed to detect current location." = "Failed to detect current location."; +"Looking for route..." = "Looking for route..."; +"Failed to find route." = "Failed to find route."; +"START" = "START"; +"FINISH" = "FINISH"; +"Pick Location" = "Pick Location"; +"Location" = "Location"; +"Please select a location." = "Please select a location."; +"CUSTOM" = "CUSTOM"; +"Today" = "Today"; +"Tomorrow" = "Tomorrow"; diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..7335fdf9 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" \ No newline at end of file diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..cc868fb8 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.developer.associated-domains + + applinks:osf.rokwire.illinois.edu + + + diff --git a/ios/Runner/UIUC/CommonCrypto+UIUCUtils.h b/ios/Runner/UIUC/CommonCrypto+UIUCUtils.h new file mode 100644 index 00000000..580809b3 --- /dev/null +++ b/ios/Runner/UIUC/CommonCrypto+UIUCUtils.h @@ -0,0 +1,32 @@ +// +// CommonCrypto+UIUCUtils.h +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import + +NSData * uiuc_aes_operation(NSData * dataIn, + CCOperation operation, // kCC Encrypt, Decrypt + CCMode mode, // kCCMode ECB, CBC, CFB, CTR, OFB, RC4, CFB8 + CCAlgorithm algorithm, // CCAlgorithm AES DES, 3DES, CAST, RC4, RC2, Blowfish + CCPadding padding, // cc NoPadding, PKCS7Padding + size_t keyLength, // kCCKeySizeAES 128, 192, 256 + NSData * iv, // CBC, CFB, CFB8, OFB, CTR + NSData * key, + NSError ** error); diff --git a/ios/Runner/UIUC/CommonCrypto+UIUCUtils.m b/ios/Runner/UIUC/CommonCrypto+UIUCUtils.m new file mode 100644 index 00000000..12f1355c --- /dev/null +++ b/ios/Runner/UIUC/CommonCrypto+UIUCUtils.m @@ -0,0 +1,99 @@ +// +// CommonCrypto+UIUCUtils.m +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "CommonCrypto+UIUCUtils.h" + +NSData * uiuc_aes_operation(NSData * dataIn, CCOperation operation, // kCC Encrypt, Decrypt + CCMode mode, // kCCMode ECB, CBC, CFB, CTR, OFB, RC4, CFB8 + CCAlgorithm algorithm, // CCAlgorithm AES DES, 3DES, CAST, RC4, RC2, Blowfish + CCPadding padding, // cc NoPadding, PKCS7Padding + size_t keyLength, // kCCKeySizeAES 128, 192, 256 + NSData * iv, // CBC, CFB, CFB8, OFB, CTR + NSData * key, + NSError ** error) +{ + if (key.length != keyLength) { + NSLog(@"CCCryptorArgument key.length: %lu != keyLength: %zu", (unsigned long)key.length, keyLength); + if (error) { + *error = [NSError errorWithDomain:@"kArgumentError key length" code:key.length userInfo:nil]; + } + return nil; + } + + size_t dataOutMoved = 0; + size_t dataOutMovedTotal = 0; + CCCryptorStatus ccStatus = 0; + CCCryptorRef cryptor = NULL; + + ccStatus = CCCryptorCreateWithMode(operation, mode, algorithm, + padding, + iv.bytes, key.bytes, + keyLength, + NULL, 0, 0, // tweak XTS mode, numRounds + kCCModeOptionCTR_BE, // CCModeOptions + &cryptor); + + if (cryptor == 0 || ccStatus != kCCSuccess) { + NSLog(@"CCCryptorCreate status: %d", ccStatus); + if (error) { + *error = [NSError errorWithDomain:@"kCreateError" code:ccStatus userInfo:nil]; + } + CCCryptorRelease(cryptor); + return nil; + } + + size_t dataOutLength = CCCryptorGetOutputLength(cryptor, dataIn.length, true); + NSMutableData *dataOut = [NSMutableData dataWithLength:dataOutLength]; + char *dataOutPointer = (char *)dataOut.mutableBytes; + + ccStatus = CCCryptorUpdate(cryptor, + dataIn.bytes, dataIn.length, + dataOutPointer, dataOutLength, + &dataOutMoved); + dataOutMovedTotal += dataOutMoved; + + if (ccStatus != kCCSuccess) { + NSLog(@"CCCryptorUpdate status: %d", ccStatus); + if (error) { + *error = [NSError errorWithDomain:@"kUpdateError" code:ccStatus userInfo:nil]; + } + CCCryptorRelease(cryptor); + return nil; + } + + ccStatus = CCCryptorFinal(cryptor, + dataOutPointer + dataOutMoved, dataOutLength - dataOutMoved, + &dataOutMoved); + if (ccStatus != kCCSuccess) { + NSLog(@"CCCryptorFinal status: %d", ccStatus); + if (error) { + *error = [NSError errorWithDomain:@"kFinalError" code:ccStatus userInfo:nil]; + } + CCCryptorRelease(cryptor); + return nil; + } + + CCCryptorRelease(cryptor); + + dataOutMovedTotal += dataOutMoved; + dataOut.length = dataOutMovedTotal; + + return dataOut; +} diff --git a/ios/Runner/UIUC/NSDate+UIUCUtils.h b/ios/Runner/UIUC/NSDate+UIUCUtils.h new file mode 100644 index 00000000..bfb74d70 --- /dev/null +++ b/ios/Runner/UIUC/NSDate+UIUCUtils.h @@ -0,0 +1,26 @@ +// +// NSDate+UIUCUtils.h +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSDate(UIUCUtils) +- (NSString*)formatUUICTime; +@end + diff --git a/ios/Runner/UIUC/NSDate+UIUCUtils.m b/ios/Runner/UIUC/NSDate+UIUCUtils.m new file mode 100644 index 00000000..b363e1ab --- /dev/null +++ b/ios/Runner/UIUC/NSDate+UIUCUtils.m @@ -0,0 +1,41 @@ +// +// NSDate+UIUCUtils.m +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSDate+UIUCUtils.h" +#import "NSDate+InaUtils.h" + +@implementation NSDate(UIUCUtils) + +- (NSString*)formatUUICTime { + + NSDateComponents *components = [[NSCalendar currentCalendar] components:NSCalendarUnitDay fromDate:[NSDate date] toDate:self options:0]; + NSInteger days = [components day]; + + switch (days) { + case 0: return [NSString stringWithFormat:@"%@ @ %@", NSLocalizedString(@"Today", nil), [self inaStringWithFormat:@"h:mma"]]; + case 1: return [NSString stringWithFormat:@"%@ @ %@", NSLocalizedString(@"Tomorrow", nil), [self inaStringWithFormat:@"h:mma"]]; + case 2: case 3: case 4: case 5: case 6: + return [self inaStringWithFormat:@"EEEE @ h:mma"]; + default: + return [self inaStringWithFormat:@"EEE, MMM d @ h:mma"]; + } +} + +@end diff --git a/ios/Runner/UIUC/NSDictionary+UIUCConfig.h b/ios/Runner/UIUC/NSDictionary+UIUCConfig.h new file mode 100644 index 00000000..703f4b33 --- /dev/null +++ b/ios/Runner/UIUC/NSDictionary+UIUCConfig.h @@ -0,0 +1,51 @@ +// +// NSDictionary+UIUCConfig.h +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSDictionary(UIUCConfig) + + - (int)uiucConfigIntForPathKey:(NSString*)key; + - (int)uiucConfigIntForPathKey:(NSString*)key defaults:(int)defaultValue; + + - (long)uiucConfigLongForPathKey:(NSString*)key; + - (long)uiucConfigLongForPathKey:(NSString*)key defaults:(long)defaultValue; + + - (NSInteger)uiucConfigIntegerForPathKey:(NSString*)key; + - (NSInteger)uiucConfigIntegerForPathKey:(NSString*)key defaults:(NSInteger)defaultValue; + + - (int64_t)uiucConfigInt64ForPathKey:(NSString*)key; + - (int64_t)uiucConfigInt64ForPathKey:(NSString*)key defaults:(int64_t)defaultValue; + + - (bool)uiucConfigBoolForPathKey:(NSString*)key; + - (bool)uiucConfigBoolForPathKey:(NSString*)key defaults:(bool)defaultValue; + + - (float)uiucConfigFloatForPathKey:(NSString*)key; + - (float)uiucConfigFloatForPathKey:(NSString*)key defaults:(float)defaultValue; + + - (double)uiucConfigDoubleForPathKey:(NSString*)key; + - (double)uiucConfigDoubleForPathKey:(NSString*)key defaults:(double)defaultValue; + + - (NSNumber*)uiucConfigNumberForPathKey:(NSString*)key; + - (NSNumber*)uiucConfigNumberForPathKey:(NSString*)key defaults:(NSNumber*)defaultValue; + + - (NSString*)uiucConfigStringForPathKey:(NSString*)key; + - (NSString*)uiucConfigStringForPathKey:(NSString*)key defaults:(NSString*)defaultValue; +@end diff --git a/ios/Runner/UIUC/NSDictionary+UIUCConfig.m b/ios/Runner/UIUC/NSDictionary+UIUCConfig.m new file mode 100644 index 00000000..4fba4442 --- /dev/null +++ b/ios/Runner/UIUC/NSDictionary+UIUCConfig.m @@ -0,0 +1,152 @@ +// +// NSDictionary+UIUCConfig +// UIUCUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSDictionary+UIUCConfig.h" +#import "NSDictionary+InaPathKey.h" + +@implementation NSDictionary(UIUCConfig) + ++ (id)_uiucConfigProcessedValue:(id)value { + return ([value isKindOfClass:[NSDictionary class]]) ? [value objectForKey:@"ios"] : nil; +} + +- (int)uiucConfigIntForPathKey:(NSString*)key { + return [self uiucConfigIntForPathKey:key defaults:0]; +} + +- (int)uiucConfigIntForPathKey:(NSString*)key defaults:(int)defaultValue { + id value = [self inaObjectForPathKey:key]; + if ([value respondsToSelector:@selector(intValue)]) + return [value intValue]; + id value1 = [self.class _uiucConfigProcessedValue:value]; + return [value1 respondsToSelector:@selector(intValue)] ? [value1 intValue] : defaultValue; + +} + +- (long)uiucConfigLongForPathKey:(NSString*)key { + return [self uiucConfigLongForPathKey:key defaults:0L]; +} + +- (long)uiucConfigLongForPathKey:(NSString*)key defaults:(long)defaultValue { + id value = [self inaObjectForPathKey:key]; + if ([value respondsToSelector:@selector(longValue)]) + return [value longValue]; + id value1 = [self.class _uiucConfigProcessedValue:value]; + return [value1 respondsToSelector:@selector(longValue)] ? [value1 longValue] : defaultValue; +} + +- (int64_t)uiucConfigInt64ForPathKey:(NSString*)key { + return [self uiucConfigInt64ForPathKey:key defaults:0LL]; +} + +- (int64_t)uiucConfigInt64ForPathKey:(NSString*)key defaults:(int64_t)defaultValue { + id value = [self inaObjectForPathKey:key]; + if ([value respondsToSelector:@selector(longLongValue)]) + return [value longLongValue]; + id value1 = [self.class _uiucConfigProcessedValue:value]; + return [value1 respondsToSelector:@selector(longLongValue)] ? [value1 longLongValue] : defaultValue; +} + +- (NSInteger)uiucConfigIntegerForPathKey:(NSString*)key { + return [self uiucConfigIntegerForPathKey:key defaults:0LL]; +} + +- (NSInteger)uiucConfigIntegerForPathKey:(NSString*)key defaults:(NSInteger)defaultValue { + id value = [self inaObjectForPathKey:key]; + if ([value respondsToSelector:@selector(integerValue)]) + return [value integerValue]; + id value1 = [self.class _uiucConfigProcessedValue:value]; + return [value1 respondsToSelector:@selector(integerValue)] ? [value1 integerValue] : defaultValue; +} + +- (bool)uiucConfigBoolForPathKey:(NSString*)key { + return [self uiucConfigBoolForPathKey:key defaults:NO]; +} + +- (bool)uiucConfigBoolForPathKey:(NSString*)key defaults:(bool)defaultValue { + id value = [self inaObjectForPathKey:key]; + if ([value respondsToSelector:@selector(boolValue)]) + return [value boolValue]; + id value1 = [self.class _uiucConfigProcessedValue:value]; + return [value1 respondsToSelector:@selector(boolValue)] ? [value1 boolValue] : defaultValue; +} + +- (float)uiucConfigFloatForPathKey:(NSString*)key { + return [self uiucConfigFloatForPathKey:key defaults:0.0f]; +} + +- (float)uiucConfigFloatForPathKey:(NSString*)key defaults:(float)defaultValue { + id value = [self inaObjectForPathKey:key]; + if ([value respondsToSelector:@selector(floatValue)]) + return [value floatValue]; + id value1 = [self.class _uiucConfigProcessedValue:value]; + return [value1 respondsToSelector:@selector(floatValue)] ? [value1 floatValue] : defaultValue; +} + + +- (double)uiucConfigDoubleForPathKey:(NSString*)key { + return [self uiucConfigDoubleForPathKey:key defaults:0.0]; +} + +- (double)uiucConfigDoubleForPathKey:(NSString*)key defaults:(double)defaultValue { + id value = [self inaObjectForPathKey:key]; + if ([value respondsToSelector:@selector(doubleValue)]) + return [value doubleValue]; + id value1 = [self.class _uiucConfigProcessedValue:value]; + return [value1 respondsToSelector:@selector(doubleValue)] ? [value1 doubleValue] : defaultValue; +} + +- (NSNumber*)uiucConfigNumberForPathKey:(NSString*)key { + return [self uiucConfigNumberForPathKey:key defaults:nil]; +} + +- (NSNumber*)uiucConfigNumberForPathKey:(NSString*)key defaults:(NSNumber*)defaultValue { + id value = [self inaObjectForPathKey:key]; + if([value isKindOfClass:[NSNumber class]]) + return ((NSNumber*)value); + id value1 = [self.class _uiucConfigProcessedValue:value]; + return [value1 isKindOfClass:[NSNumber class]] ? value1 : defaultValue; +} + +- (NSString*)uiucConfigStringForPathKey:(NSString*)key { + return [self uiucConfigStringForPathKey:key defaults:nil]; +} + +- (NSString*)uiucConfigStringForPathKey:(NSString*)key defaults:(NSString*)defaultValue { + id value = [self inaObjectForPathKey:key]; + if(value == nil) + return defaultValue; + else if([value isKindOfClass:[NSString class]]) + return value; + else if([value respondsToSelector:@selector(stringValue)]) + return [value stringValue]; + + id value1 = [self.class _uiucConfigProcessedValue:value]; + if([value1 isKindOfClass:[NSString class]]) + return value1; + else if([value1 respondsToSelector:@selector(stringValue)]) + return [value1 stringValue]; + else + return defaultValue; +} + +@end + + diff --git a/ios/Runner/UIUC/NSDictionary+UIUCExplore.h b/ios/Runner/UIUC/NSDictionary+UIUCExplore.h new file mode 100644 index 00000000..b500543b --- /dev/null +++ b/ios/Runner/UIUC/NSDictionary+UIUCExplore.h @@ -0,0 +1,52 @@ +// +// NSDictionary+UIUCExplore.h +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import + +typedef NS_ENUM(NSInteger, UIUCExploreType) { + UIUCExploreType_Unknown, + UIUCExploreType_Event, + UIUCExploreType_Dining, + UIUCExploreType_Laundry, + UIUCExploreType_Parking, + UIUCExploreType_Explores, +}; + +NSString* UIUCExploreTypeToString(UIUCExploreType exploreType); +UIUCExploreType UIUCExploreTypeFromString(NSString* value); + +@interface NSDictionary(UIUCExplore) +@property (nonatomic, readonly) UIUCExploreType uiucExploreType; +@property (nonatomic, readonly) UIUCExploreType uiucExploreContentType; +@property (nonatomic, readonly) NSString* uiucExploreTitle; +@property (nonatomic, readonly) NSString* uiucExploreMarkerHexColor; +@property (nonatomic, readonly) NSString* uiucExploreDescription; +@property (nonatomic, readonly) NSArray* uiucExplores; +@property (nonatomic, readonly) NSString* uiucExploreAddress; +@property (nonatomic, readonly) NSDictionary* uiucExploreLocation; +@property (nonatomic, readonly) CLLocationCoordinate2D uiucExploreLocationCoordinate; +@property (nonatomic, readonly) NSArray* uiucExplorePolygon; +@property (nonatomic, readonly) int uiucExploreLocationFloor; + ++ (NSDictionary*)uiucExploreFromGroup:(NSArray*)explores; + +@end + diff --git a/ios/Runner/UIUC/NSDictionary+UIUCExplore.m b/ios/Runner/UIUC/NSDictionary+UIUCExplore.m new file mode 100644 index 00000000..a33593f2 --- /dev/null +++ b/ios/Runner/UIUC/NSDictionary+UIUCExplore.m @@ -0,0 +1,203 @@ +// +// NSDictionary+UIUCExplore.h +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSDictionary+UIUCExplore.h" + +#import "NSDictionary+InaTypedValue.h" +#import "NSDate+InaUtils.h" +#import "NSDate+UIUCUtils.h" + +@implementation NSDictionary(UIUCExplore) + +- (UIUCExploreType)uiucExploreType { + if ([self objectForKey:@"eventId"] != nil) { + return UIUCExploreType_Event; + } + else if ([self objectForKey:@"DiningOptionID"] != nil) { + return UIUCExploreType_Dining; + } + else if ([self objectForKey:@"campus_name"] != nil) { + return UIUCExploreType_Laundry; + } + else if ([self objectForKey:@"lot_id"] != nil) { + return UIUCExploreType_Parking; + } + else if ([self objectForKey:@"explores"] != nil) { + return UIUCExploreType_Explores; + } + else { + return UIUCExploreType_Unknown; + } +} + +- (UIUCExploreType)uiucExploreContentType { + return [self inaIntegerForKey:@"exploresContentType" defaults:UIUCExploreType_Unknown]; +} + +- (NSString*)uiucExploreMarkerHexColor { + UIUCExploreType exploreType = self.uiucExploreType; + return (exploreType == UIUCExploreType_Explores) ? + [self inaStringForKey:@"color" defaults:@"#13294b"] : + [self.class uiucExploreMarkerHexColorFromType:exploreType]; +} + ++ (NSString*)uiucExploreMarkerHexColorFromType:(UIUCExploreType)type { + switch (type) { + case UIUCExploreType_Event: return @"#e84a27"; // illinoisOrange + case UIUCExploreType_Dining: return @"#f29835"; // mangо + default: return @"#5fa7a3"; // teal + } +} + +- (NSString*)uiucExploreTitle { + switch (self.uiucExploreType) { + case UIUCExploreType_Parking: return [self inaStringForKey:@"lot_name"]; + default: return [self inaStringForKey:@"title"]; + } +} + +- (NSString*)uiucExploreDescription { + UIUCExploreType exploreType = self.uiucExploreType; + if (exploreType == UIUCExploreType_Event) { + NSString *eventTime = [self inaStringForKey:@"startDateLocal"]; + if (0 < eventTime.length) { + NSDate *eventDate = [NSDate inaDateFromString:eventTime format:@"yyyy-MM-dd'T'HH:mm:ss"]; + return [eventDate formatUUICTime] ?: eventTime; + } + } + else if (exploreType == UIUCExploreType_Laundry) { + NSString *status = [self inaStringForKey:@"status"]; + if (0 < status.length) { + return status; + } + } + + return [self.uiucExploreLocation inaStringForKey:@"description"]; +} + +- (NSArray*)uiucExplores { + return [self inaArrayForKey:@"explores"]; +} + +- (NSDictionary*)uiucExploreLocation { + switch (self.uiucExploreType) { + case UIUCExploreType_Parking: return [self inaDictForKey:@"entrance"]; + default: return [self inaDictForKey:@"location"]; + } +} + +- (NSString*)uiucExploreAddress { + switch (self.uiucExploreType) { + case UIUCExploreType_Parking: return [self inaStringForKey:@"lot_address1"]; + case UIUCExploreType_Explores: return [self inaStringForKey:@"address"]; + default: return nil; + } +} + +- (NSArray*)uiucExplorePolygon { + return [self inaArrayForKey:@"polygon"]; + +} + +- (CLLocationCoordinate2D)uiucExploreLocationCoordinate { + NSDictionary *location = self.uiucExploreLocation; + return (location != nil) ? CLLocationCoordinate2DMake([location inaDoubleForKey:@"latitude"], [location inaDoubleForKey:@"longitude"]) : kCLLocationCoordinate2DInvalid; +} + +- (int)uiucExploreLocationFloor { + NSDictionary *location = self.uiucExploreLocation; + return [location inaIntForKey:@"floor"]; +} + ++ (NSDictionary*)uiucExploreFromGroup:(NSArray*)explores { + if (explores != nil) { + + UIUCExploreType exploresType = UIUCExploreType_Unknown; + for (NSDictionary *explore in explores) { + UIUCExploreType exploreType = explore.uiucExploreType; + if (exploresType == UIUCExploreType_Unknown) { + exploresType = exploreType; + } + else if (exploresType != exploreType) { + exploresType = UIUCExploreType_Unknown; + break; + } + } + + NSString *exploresName = nil; + switch (exploresType) { + case UIUCExploreType_Event: exploresName = @"Events"; break; + case UIUCExploreType_Dining: exploresName = @"Dinings"; break; + case UIUCExploreType_Laundry: exploresName = @"Laundries"; break; + case UIUCExploreType_Parking: exploresName = @"Parkings"; break; + default: exploresName = @"Explores"; break; + } + + NSString *exploresColor = (exploresType != UIUCExploreType_Unknown) ? [self.class uiucExploreMarkerHexColorFromType:exploresType] : @"#13294b"; + + return @{ + @"type" : @"explores", + @"title" : [NSString stringWithFormat:@"%d %@", (int)explores.count, exploresName], + @"location" : [explores.firstObject inaDictForKey:@"location"] ?: [NSNull null], + @"address": [explores.firstObject inaStringForKey:@"lot_address1"] ?: [NSNull null], + @"color": exploresColor, + @"exploresContentType": @(exploresType), + @"explores" : explores, + }; + } + return nil; +} + + +@end + +// UIUCExploreType + +NSString* UIUCExploreTypeToString(UIUCExploreType exploreType) { + switch (exploreType) { + case UIUCExploreType_Event: return @"event"; + case UIUCExploreType_Dining: return @"dining"; + case UIUCExploreType_Laundry: return @"laundry"; + case UIUCExploreType_Parking: return @"parking"; + case UIUCExploreType_Explores: return @"explores"; + default: return nil; + } +} + +UIUCExploreType UIUCExploreTypeFromString(NSString* value) { + if (value != nil) { + if ([value isEqualToString:@"event"]) { + return UIUCExploreType_Event; + } + else if ([value isEqualToString:@"dining"]) { + return UIUCExploreType_Dining; + } + else if ([value isEqualToString:@"laundry"]) { + return UIUCExploreType_Laundry; + } + else if ([value isEqualToString:@"parking"]) { + return UIUCExploreType_Parking; + } + else if ([value isEqualToString:@"explores"]) { + return UIUCExploreType_Explores; + } + } + return UIUCExploreType_Unknown; +} diff --git a/ios/Runner/UIUC/Security+UIUCUtils.h b/ios/Runner/UIUC/Security+UIUCUtils.h new file mode 100644 index 00000000..06e24ad1 --- /dev/null +++ b/ios/Runner/UIUC/Security+UIUCUtils.h @@ -0,0 +1,24 @@ +// +// Security+UIUCUtils.h +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +NSData* uiucSecStorageData(NSString *account, NSString *generic, id valueToWrite); + diff --git a/ios/Runner/UIUC/Security+UIUCUtils.m b/ios/Runner/UIUC/Security+UIUCUtils.m new file mode 100644 index 00000000..6970b854 --- /dev/null +++ b/ios/Runner/UIUC/Security+UIUCUtils.m @@ -0,0 +1,95 @@ +// +// Security+UIUCUtils.m +// UIUCUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "Security+UIUCUtils.h" + +NSData* uiucSecStorageData(NSString *account, NSString *generic, id valueToWrite) { + NSDictionary *spec = @{ + (id)kSecClass: (id)kSecClassGenericPassword, + (id)kSecAttrAccount: account, + (id)kSecAttrGeneric: generic, + (id)kSecAttrService: NSBundle.mainBundle.bundleIdentifier, + }; + + NSMutableDictionary *searchRequest = [NSMutableDictionary dictionaryWithDictionary:spec]; + [searchRequest setObject:(id)kSecMatchLimitOne forKey:(id)kSecMatchLimit]; + [searchRequest setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnData]; + [searchRequest setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnAttributes]; + + CFDictionaryRef response = NULL; + OSStatus status = SecItemCopyMatching((CFDictionaryRef)searchRequest, (CFTypeRef*)&response); + NSData *existingData = nil; + + if (status == errSecInteractionNotAllowed) { + // Could not access data. Error: errSecInteractionNotAllowed + return nil; + } + else if (status == 0) { + NSDictionary *attribs = CFBridgingRelease(response); + NSData *data = [attribs objectForKey:(id)kSecValueData]; + NSString *security = [attribs objectForKey:(id)kSecAttrAccessible]; + + // If not always accessible then update it to be so + if (![security isEqualToString:(id)kSecAttrAccessibleAlways]) { + NSDictionary *update = @{ + (id)kSecAttrAccessible:(id)kSecAttrAccessibleAlways, + (id)kSecValueData:data ?: [[NSData alloc] init], + }; + + SecItemUpdate((CFDictionaryRef)spec, (CFDictionaryRef)update); + } + + existingData = data; + } + + if (valueToWrite == nil) { // getter + return existingData; + } + else if ([valueToWrite isKindOfClass:[NSData class]]) { // setter + + if (status == 0) { + // update existing entry + NSDictionary *update = @{ + (id)kSecAttrAccessible:(id)kSecAttrAccessibleAlways, + (id)kSecValueData:valueToWrite + }; + status = SecItemUpdate((CFDictionaryRef)spec, (CFDictionaryRef)update); + } + else { + // create new entry + NSMutableDictionary *createRequest = [NSMutableDictionary dictionaryWithDictionary:spec]; + [createRequest setObject:valueToWrite forKey:(id)kSecValueData]; + [createRequest setObject:(id)kSecAttrAccessibleAlways forKey:(id)kSecAttrAccessible]; + status = SecItemAdd((CFDictionaryRef)createRequest, NULL); + } + + return (status == 0) ? valueToWrite : nil; + } + else { // delete existing entry + if (status == 0) { + status = SecItemDelete((CFDictionaryRef)spec); + return (status == 0) ? valueToWrite : nil; + } + else { + // nothing to do + return valueToWrite; + } + } +} diff --git a/ios/Runner/Utils/Bluetooth+InaUtils.h b/ios/Runner/Utils/Bluetooth+InaUtils.h new file mode 100644 index 00000000..f3593d24 --- /dev/null +++ b/ios/Runner/Utils/Bluetooth+InaUtils.h @@ -0,0 +1,50 @@ +// +// CBPeripheral+InaUtils.h +// Runner +// +// Created by Mladen Dryankov on 16.12.19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import + +typedef NS_ENUM(NSInteger, InaBluetoothAuthorizationStatus) { + InaBluetoothAuthorizationStatusNotDetermined = 0, + InaBluetoothAuthorizationStatusRestricted, + InaBluetoothAuthorizationStatusDenied, + InaBluetoothAuthorizationStatusAuthorized, +}; + +NSString* InaBluetoothAuthorizationStatusToString(InaBluetoothAuthorizationStatus value); +InaBluetoothAuthorizationStatus InaBluetoothAuthorizationStatusFromString(NSString *value); + +@interface InaBluetooth : NSObject +@property(nonatomic, class, readonly) InaBluetoothAuthorizationStatus peripheralAuthorizationStatus; +@property(nonatomic, class, readonly) InaBluetoothAuthorizationStatus centralAuthorizationStatus; +@end + +@interface CBPeripheral(InaUtils) +- (CBService*)inaServiceWithUUID:(CBUUID*)uuid; +@end + +@interface CBService(InaUtils) +- (CBCharacteristic*)inaCharacteristicWithUUID:(CBUUID*)uuid; +- (CBMutableCharacteristic*)inaMutableCharacteristicWithUUID:(CBUUID*)uuid; +@end + +@interface NSDictionary(InaBluetoothUtils) +- (bool)inaAdvertisementDataContainsServiceWithUuid:(CBUUID*)serviceUuid; +@end diff --git a/ios/Runner/Utils/Bluetooth+InaUtils.m b/ios/Runner/Utils/Bluetooth+InaUtils.m new file mode 100644 index 00000000..53944168 --- /dev/null +++ b/ios/Runner/Utils/Bluetooth+InaUtils.m @@ -0,0 +1,152 @@ +// +// CBPeripheral+InaUtils.m +// Runner +// +// Created by Mladen Dryankov on 16.12.19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "Bluetooth+InaUtils.h" +#import "NSDictionary+InaTypedValue.h" + +////////////////////////////////////// +// InaBluetooth + +@implementation InaBluetooth + ++ (InaBluetoothAuthorizationStatus)peripheralAuthorizationStatus { + if (@available(iOS 13.1, *)) { + switch(CBPeripheralManager.authorization) { + case CBManagerAuthorizationNotDetermined: return InaBluetoothAuthorizationStatusNotDetermined; + case CBManagerAuthorizationRestricted: return InaBluetoothAuthorizationStatusRestricted; + case CBManagerAuthorizationDenied: return InaBluetoothAuthorizationStatusDenied; + case CBManagerAuthorizationAllowedAlways: return InaBluetoothAuthorizationStatusAuthorized; + } + } else { + switch (CBPeripheralManager.authorizationStatus) { + case CBPeripheralManagerAuthorizationStatusNotDetermined: return InaBluetoothAuthorizationStatusNotDetermined; + case CBPeripheralManagerAuthorizationStatusRestricted: return InaBluetoothAuthorizationStatusRestricted; + case CBPeripheralManagerAuthorizationStatusDenied: return InaBluetoothAuthorizationStatusDenied; + case CBPeripheralManagerAuthorizationStatusAuthorized: return InaBluetoothAuthorizationStatusAuthorized; + } + } +} + ++ (InaBluetoothAuthorizationStatus)centralAuthorizationStatus { + if (@available(iOS 13.1, *)) { + switch(CBCentralManager.authorization) { + case CBManagerAuthorizationNotDetermined: return InaBluetoothAuthorizationStatusNotDetermined; + case CBManagerAuthorizationRestricted: return InaBluetoothAuthorizationStatusRestricted; + case CBManagerAuthorizationDenied: return InaBluetoothAuthorizationStatusDenied; + case CBManagerAuthorizationAllowedAlways: return InaBluetoothAuthorizationStatusAuthorized; + } + } else { + return InaBluetoothAuthorizationStatusAuthorized; + } +} + +@end + +////////////////////////////////////// +// InaBluetoothAuthorizationStatus + +NSString* InaBluetoothAuthorizationStatusToString(InaBluetoothAuthorizationStatus value) { + switch (value) { + case InaBluetoothAuthorizationStatusNotDetermined: return @"not_determined"; + case InaBluetoothAuthorizationStatusRestricted: return @"not_supported"; + case InaBluetoothAuthorizationStatusDenied: return @"denied"; + case InaBluetoothAuthorizationStatusAuthorized: return @"allowed"; + } +} + +InaBluetoothAuthorizationStatus InaBluetoothAuthorizationStatusFromString(NSString *value) { + if ([value isEqualToString:@"not_determined"]) { + return InaBluetoothAuthorizationStatusNotDetermined; + } + else if ([value isEqualToString:@"not_supported"]) { + return InaBluetoothAuthorizationStatusRestricted; + } + else if ([value isEqualToString:@"denied"]) { + return InaBluetoothAuthorizationStatusDenied; + } + else if ([value isEqualToString:@"allowed"]) { + return InaBluetoothAuthorizationStatusAuthorized; + } + else { + return InaBluetoothAuthorizationStatusNotDetermined; + } +} + +////////////////////////////////////// +// CBPeripheral+InaUtils + +@implementation CBPeripheral(InaUtils) + +- (CBService*)inaServiceWithUUID:(CBUUID*)uuid { + for (CBService *service in self.services) { + if([uuid isEqual: service.UUID]){ + return service; + } + } + return nil; +} + +@end + +////////////////////////////////////// +// CBService+InaUtils + +@implementation CBService(InaUtils) + +- (CBCharacteristic*)inaCharacteristicWithUUID:(CBUUID *)uuid { + for(CBCharacteristic *characteristic in self.characteristics){ + if([characteristic.UUID isEqual:uuid]) { + return characteristic; + } + } + return nil; +} + +- (CBMutableCharacteristic*)inaMutableCharacteristicWithUUID:(CBUUID*)uuid; { + CBCharacteristic *characteristic = [self inaCharacteristicWithUUID:uuid]; + return [characteristic isKindOfClass:[CBMutableCharacteristic class]] ? ((CBMutableCharacteristic*)characteristic) : nil; +} + +@end + +////////////////////////////////////// +// NSDictionary+InaBluetoothUtils + +@implementation NSDictionary(InaBluetoothUtils) + +- (bool)inaAdvertisementDataContainsServiceWithUuid:(CBUUID*)serviceUuid { + NSArray *serviceUuids = [self inaArrayForKey:CBAdvertisementDataServiceUUIDsKey]; + for (CBUUID *peripheralServiceUuid in serviceUuids) { + if ([peripheralServiceUuid isEqual:serviceUuid]) { + return true; + } + } + + serviceUuids = [self inaArrayForKey: CBAdvertisementDataOverflowServiceUUIDsKey]; + for (CBUUID *peripheralServiceUuid in serviceUuids) { + if ([peripheralServiceUuid isEqual:serviceUuid]) { + return true; + } + } + + return false; +} + +@end diff --git a/ios/Runner/Utils/CGGeometry+InaUtils.h b/ios/Runner/Utils/CGGeometry+InaUtils.h new file mode 100644 index 00000000..f7b251cc --- /dev/null +++ b/ios/Runner/Utils/CGGeometry+InaUtils.h @@ -0,0 +1,26 @@ +// +// CGGeometry+InaUtils.h +// InaUtils +// +// Created by mac mini on 2/17/10. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +CGSize InaSizeScaleToFit(CGSize size, CGSize boundsSize); +CGSize InaSizeScaleToFill(CGSize size, CGSize boundsSize); +CGSize InaSizeShrinkToFit(CGSize size, CGSize boundsSize); + diff --git a/ios/Runner/Utils/CGGeometry+InaUtils.m b/ios/Runner/Utils/CGGeometry+InaUtils.m new file mode 100644 index 00000000..d9076971 --- /dev/null +++ b/ios/Runner/Utils/CGGeometry+InaUtils.m @@ -0,0 +1,49 @@ +// +// CGGeometry+InaUtils.m +// InaUtils +// +// Created by mac mini on 2/17/10. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "CGGeometry+InaUtils.h" + +CGSize InaSizeScaleToFit(CGSize size, CGSize boundsSize) { + CGSize sizeFit = boundsSize; + float fltW = (0.0f < boundsSize.width) ? (size.width / boundsSize.width) : FLT_MAX; + float fltH = (0.0f < boundsSize.height) ? (size.height / boundsSize.height) : FLT_MAX; + if(fltW < fltH) + sizeFit.width = (0.0f < size.height) ? (size.width * boundsSize.height / size.height) : boundsSize.width; + else if(fltH < fltW) + sizeFit.height = (0.0f < size.width) ? (size.height * boundsSize.width / size.width) : boundsSize.height; + return sizeFit; +} + +CGSize InaSizeScaleToFill(CGSize size, CGSize boundsSize) { + CGSize sizeFit = boundsSize; + float fltW = (0.0f < boundsSize.width) ? (size.width / boundsSize.width) : FLT_MAX; + float fltH = (0.0f < boundsSize.height) ? (size.height / boundsSize.height) : FLT_MAX; + if(fltW < fltH) + sizeFit.height = (0.0f < size.width) ? (size.height * boundsSize.width / size.width) : boundsSize.height; + else if(fltH < fltW) + sizeFit.width = (0.0f < size.height) ? (size.width * boundsSize.height / size.height) : boundsSize.width; + return sizeFit; +} + +CGSize InaSizeShrinkToFit(CGSize size, CGSize boundsSize) { + // scale size only if exceeds bound size + return ((boundsSize.width < size.width) || (boundsSize.height < size.height)) ? InaSizeScaleToFit(size, boundsSize) : size; +} + diff --git a/ios/Runner/Utils/CLLocationCoordinate2D+InaUtils.h b/ios/Runner/Utils/CLLocationCoordinate2D+InaUtils.h new file mode 100644 index 00000000..721aa844 --- /dev/null +++ b/ios/Runner/Utils/CLLocationCoordinate2D+InaUtils.h @@ -0,0 +1,25 @@ +// +// CLLocationCoordinate2D+InaUtils.h +// InaUtils +// +// Created by Mihail Varbanov on 7/17/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +// Returns the distance between two LatLngs, in meters. +double CLLocationCoordinate2DInaDistance(CLLocationCoordinate2D from, CLLocationCoordinate2D to); + diff --git a/ios/Runner/Utils/CLLocationCoordinate2D+InaUtils.m b/ios/Runner/Utils/CLLocationCoordinate2D+InaUtils.m new file mode 100644 index 00000000..8f9a54da --- /dev/null +++ b/ios/Runner/Utils/CLLocationCoordinate2D+InaUtils.m @@ -0,0 +1,84 @@ +// +// CLLocationCoordinate2D+InaUtils.h +// InaUtils +// +// Created by Mihail Varbanov on 7/17/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "CLLocationCoordinate2D+InaUtils.h" + +/** + * The earth's radius, in meters. + * Mean radius as defined by IUGG. + */ +static double EARTH_RADIUS = 6371009; + +/** + * Returns haversine(angle-in-radians). + * hav(x) == (1 - cos(x)) / 2 == sin(x / 2)^2. + */ +static double hav(double x) { + double sinHalf = sin(x * 0.5); + return sinHalf * sinHalf; +} + +/** + * Computes inverse haversine. Has good numerical stability around 0. + * arcHav(x) == acos(1 - 2 * x) == 2 * asin(sqrt(x)). + * The argument must be in [0, 1], and the result is positive. + */ +static double arcHav(double x) { + return 2 * asin(sqrt(x)); +} + +/** + * Returns hav() of distance from (lat1, lng1) to (lat2, lng2) on the unit sphere. + */ +static double havDistance(double lat1, double lat2, double dLng) { + return hav(lat1 - lat2) + hav(dLng) * cos(lat1) * cos(lat2); +} + +/** + * Returns the measure in radians of the supplied degree angle. + */ +static double toRadians(double angdeg) { + return angdeg / 180.0 * M_PI; +} + +// Spherical Utils + +/** + * Returns distance on the unit sphere; the arguments are in radians. + */ +static double distanceRadians(double lat1, double lng1, double lat2, double lng2) { + return arcHav(havDistance(lat1, lat2, lng1 - lng2)); +} + +/** + * Returns the angle between two LatLngs, in radians. This is the same as the distance + * on the unit sphere. + */ +static double computeAngleBetween(CLLocationCoordinate2D from, CLLocationCoordinate2D to) { + return distanceRadians(toRadians(from.latitude), toRadians(from.longitude), + toRadians(to.latitude), toRadians(to.longitude)); +} + +/** + * Returns the distance between two LatLngs, in meters. + */ +double CLLocationCoordinate2DInaDistance(CLLocationCoordinate2D from, CLLocationCoordinate2D to) { + return computeAngleBetween(from, to) * EARTH_RADIUS; +} diff --git a/ios/Runner/Utils/InaSymbols.h b/ios/Runner/Utils/InaSymbols.h new file mode 100644 index 00000000..342660f9 --- /dev/null +++ b/ios/Runner/Utils/InaSymbols.h @@ -0,0 +1,22 @@ +// +// Symbols.h +// Runner +// +// Created by Mihail Varbanov on 8/16/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#define _countof(a) (sizeof(a)/sizeof((a)[0])) +#define RGB(r,g,b) ((unsigned long)(((unsigned long)((r) & 0xFF) | ((unsigned long)((g) & 0xFF))<<8) | ((unsigned long)((b) & 0xFF))<<16)) diff --git a/ios/Runner/Utils/NSArray+InaTypedValue.h b/ios/Runner/Utils/NSArray+InaTypedValue.h new file mode 100644 index 00000000..8c365c65 --- /dev/null +++ b/ios/Runner/Utils/NSArray+InaTypedValue.h @@ -0,0 +1,70 @@ +// +// NSArray+InaTypedValue.h +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSArray(InaTypedValue) + + - (int)inaIntAtIndex:(NSUInteger)index; + - (int)inaIntAtIndex:(NSUInteger)index defaults:(int)defaultValue; + + - (long)inaLongAtIndex:(NSUInteger)index; + - (long)inaLongAtIndex:(NSUInteger)index defaults:(long)defaultValue; + + - (NSInteger)inaIntegerAtIndex:(NSUInteger)index; + - (NSInteger)inaIntegerAtIndex:(NSUInteger)index defaults:(NSInteger)defaultValue; + + - (int64_t)inaInt64AtIndex:(NSUInteger)index; + - (int64_t)inaInt64AtIndex:(NSUInteger)index defaults:(int64_t)defaultValue; + + - (bool)inaBoolAtIndex:(NSUInteger)index; + - (bool)inaBoolAtIndex:(NSUInteger)index defaults:(bool)defaultValue; + + - (float)inaFloatAtIndex:(NSUInteger)index; + - (float)inaFloatAtIndex:(NSUInteger)index defaults:(float)defaultValue; + + - (double)inaDoubleAtIndex:(NSUInteger)index; + - (double)inaDoubleAtIndex:(NSUInteger)index defaults:(double)defaultValue; + + - (NSString*)inaStringAtIndex:(NSUInteger)index; + - (NSString*)inaStringAtIndex:(NSUInteger)index defaults:(NSString*)defaultValue; + + - (NSNumber*)inaNumberAtIndex:(NSUInteger)index; + - (NSNumber*)inaNumberAtIndex:(NSUInteger)index defaults:(NSNumber*)defaultValue; + + - (NSDictionary*)inaDictAtIndex:(NSUInteger)index; + - (NSDictionary*)inaDictAtIndex:(NSUInteger)index defaults:(NSDictionary*)defaultValue; + + - (NSArray*)inaArrayAtIndex:(NSUInteger)index; + - (NSArray*)inaArrayAtIndex:(NSUInteger)index defaults:(NSArray*)defaultValue; + + - (NSValue*)inaValueAtIndex:(NSUInteger)index; + - (NSValue*)inaValueAtIndex:(NSUInteger)index defaults:(NSValue*)defaultValue; + + - (NSData*)inaDataAtIndex:(NSUInteger)index; + - (NSData*)inaDataAtIndex:(NSUInteger)index defaults:(NSData*)defaultValue; + + - (SEL)inaSelectorAtIndex:(NSUInteger)index; + - (SEL)inaSelectorAtIndex:(NSUInteger)index defaults:(SEL)defaultValue; + + - (id)inaObjectAtIndex:(NSUInteger)index; + - (id)inaObjectAtIndex:(NSUInteger)index class:(Class)class; + - (id)inaObjectAtIndex:(NSUInteger)index class:(Class)class defaults:(id)defaultValue; +@end diff --git a/ios/Runner/Utils/NSArray+InaTypedValue.m b/ios/Runner/Utils/NSArray+InaTypedValue.m new file mode 100644 index 00000000..61a9f82a --- /dev/null +++ b/ios/Runner/Utils/NSArray+InaTypedValue.m @@ -0,0 +1,188 @@ +// +// NSArray+InaTypedValue.m +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSArray+InaTypedValue.h" + +@implementation NSArray(InaTypedValue) + +- (int)inaIntAtIndex:(NSUInteger)index { + return [self inaIntAtIndex:index defaults:0]; +} + +- (int)inaIntAtIndex:(NSUInteger)index defaults:(int)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value respondsToSelector:@selector(intValue)] ? [value intValue] : defaultValue; +} + +- (long)inaLongAtIndex:(NSUInteger)index { + return [self inaLongAtIndex:index defaults:0L]; +} + +- (long)inaLongAtIndex:(NSUInteger)index defaults:(long)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value respondsToSelector:@selector(longValue)] ? [value longValue] : defaultValue; +} + +- (int64_t)inaInt64AtIndex:(NSUInteger)index { + return [self inaInt64AtIndex:index defaults:0LL]; +} + +- (int64_t)inaInt64AtIndex:(NSUInteger)index defaults:(int64_t)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value respondsToSelector:@selector(longLongValue)] ? [value longLongValue] : defaultValue; +} + +- (NSInteger)inaIntegerAtIndex:(NSUInteger)index { + return [self inaIntegerAtIndex:index defaults:0LL]; +} + +- (NSInteger)inaIntegerAtIndex:(NSUInteger)index defaults:(NSInteger)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value respondsToSelector:@selector(integerValue)] ? [value integerValue] : defaultValue; +} + +- (bool)inaBoolAtIndex:(NSUInteger)index { + return [self inaBoolAtIndex:index defaults:NO]; +} + +- (bool)inaBoolAtIndex:(NSUInteger)index defaults:(bool)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value respondsToSelector:@selector(boolValue)] ? [value boolValue] : defaultValue; +} + + +- (float)inaFloatAtIndex:(NSUInteger)index { + return [self inaFloatAtIndex:index defaults:0.0f]; +} + +- (float)inaFloatAtIndex:(NSUInteger)index defaults:(float)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value respondsToSelector:@selector(floatValue)] ? [value floatValue] : defaultValue; +} + + +- (double)inaDoubleAtIndex:(NSUInteger)index { + return [self inaDoubleAtIndex:index defaults:0.0]; +} + +- (double)inaDoubleAtIndex:(NSUInteger)index defaults:(double)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value respondsToSelector:@selector(doubleValue)] ? [value doubleValue] : defaultValue; +} + +- (NSString*)inaStringAtIndex:(NSUInteger)index { + return [self inaStringAtIndex:index defaults:nil]; +} + +- (NSString*)inaStringAtIndex:(NSUInteger)index defaults:(NSString*)defaultValue { + id value = [self inaObjectAtIndex:index]; + if(value == nil) + return defaultValue; + else if([value isKindOfClass:[NSString class]]) + return ((NSString*)value); + else if([value respondsToSelector:@selector(stringValue)]) + return [value stringValue]; + else + return defaultValue; +} + +- (NSNumber*)inaNumberAtIndex:(NSUInteger)index { + return [self inaNumberAtIndex:index defaults:nil]; +} + +- (NSNumber*)inaNumberAtIndex:(NSUInteger)index defaults:(NSNumber*)defaultValue { + id value = [self inaObjectAtIndex:index]; + if(value == nil) + return defaultValue; + else if([value isKindOfClass:[NSNumber class]]) + return ((NSNumber*)value); + else + return defaultValue; +} + + +- (NSArray*)inaArrayAtIndex:(NSUInteger)index { + return [self inaArrayAtIndex:index defaults:nil]; +} + +- (NSArray*)inaArrayAtIndex:(NSUInteger)index defaults:(NSArray*)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value isKindOfClass:[NSArray class]] ? value : defaultValue; +} + +- (NSDictionary*)inaDictAtIndex:(NSUInteger)index { + return [self inaDictAtIndex:index defaults:nil]; +} + +- (NSDictionary*)inaDictAtIndex:(NSUInteger)index defaults:(NSDictionary*)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value isKindOfClass:[NSDictionary class]] ? value : defaultValue; +} + +- (NSValue*)inaValueAtIndex:(NSUInteger)index { + return [self inaValueAtIndex:index defaults:nil]; +} + +- (NSValue*)inaValueAtIndex:(NSUInteger)index defaults:(NSValue*)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value isKindOfClass:[NSValue class]] ? value : defaultValue; +} + +- (NSData*)inaDataAtIndex:(NSUInteger)index { + return [self inaDataAtIndex:index defaults:nil]; +} + +- (NSData*)inaDataAtIndex:(NSUInteger)index defaults:(NSData*)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value isKindOfClass:[NSData class]] ? value : defaultValue; +} + +- (SEL)inaSelectorAtIndex:(NSUInteger)index { + return [self inaSelectorAtIndex:index defaults:NULL]; +} + +- (SEL)inaSelectorAtIndex:(NSUInteger)index defaults:(SEL)defaultValue { + id value = [self inaObjectAtIndex:index]; + if ([value isKindOfClass:[NSValue class]]) { + return ((NSValue*)value).pointerValue; + } + else if ([value isKindOfClass:[NSString class]]) { + SEL selector = NSSelectorFromString(value); + return (selector != nil) ? selector : defaultValue; + } + return defaultValue; +} + +- (id)inaObjectAtIndex:(NSUInteger)index { + return ((0 <= index) && (index < self.count)) ? [self objectAtIndex:index] : nil; +} + +- (id)inaObjectAtIndex:(NSUInteger)index class:(Class)class { + return [self inaObjectAtIndex:index class:class defaults:nil]; +} + +- (id)inaObjectAtIndex:(NSUInteger)index class:(Class)class defaults:(id)defaultValue { + id value = [self inaObjectAtIndex:index]; + return [value isKindOfClass:class] ? value : defaultValue; +} + +@end + + diff --git a/ios/Runner/Utils/NSData+InaHex.h b/ios/Runner/Utils/NSData+InaHex.h new file mode 100644 index 00000000..b1f857c1 --- /dev/null +++ b/ios/Runner/Utils/NSData+InaHex.h @@ -0,0 +1,30 @@ +// +// NSData+InaHex.h +// Runner +// +// Created by Mladen Dryankov on 27.01.20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSData(InaHex) +- (NSString*)inaHexString; +@end + +@interface NSString(InaHex) +- (NSData*)inaDataFromHex; +@end; + diff --git a/ios/Runner/Utils/NSData+InaHex.m b/ios/Runner/Utils/NSData+InaHex.m new file mode 100644 index 00000000..de36f54b --- /dev/null +++ b/ios/Runner/Utils/NSData+InaHex.m @@ -0,0 +1,78 @@ +// +// NSData+InaHex.m +// Runner +// +// Created by Mladen Dryankov on 27.01.20. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSData+InaHex.h" + +unsigned char strToChar (char a, char b); + +@implementation NSData(InaHex) + +- (NSString*)inaHexString { + const unsigned char *dataBuffer = (const unsigned char *)[self bytes]; + + if (!dataBuffer) + { + return [NSString string]; + } + + NSUInteger dataLength = [self length]; + NSMutableString *hexString = [NSMutableString stringWithCapacity:(dataLength * 2)]; + + for (int i = 0; i < dataLength; ++i) + { + [hexString appendFormat:@"%02x", (unsigned int)dataBuffer[i]]; + } + + return [NSString stringWithString:hexString]; +} + +@end + +@implementation NSString(InaHex) + +- (NSData*)inaDataFromHex { + const char * bytes = [self cStringUsingEncoding: NSUTF8StringEncoding]; + NSUInteger length = strlen(bytes); + unsigned char * r = (unsigned char *) malloc(length / 2 + 1); + unsigned char * index = r; + + while ((*bytes) && (*(bytes +1))) { + *index = strToChar(*bytes, *(bytes +1)); + index++; + bytes+=2; + } + *index = '\0'; + + NSData * result = [NSData dataWithBytes: r length: length / 2]; + free(r); + + return result; +} + +@end + +unsigned char strToChar (char a, char b) +{ + char encoder[3] = {'\0','\0','\0'}; + encoder[0] = a; + encoder[1] = b; + return (char) strtol(encoder,NULL,16); +} + diff --git a/ios/Runner/Utils/NSDate+InaUtils.h b/ios/Runner/Utils/NSDate+InaUtils.h new file mode 100644 index 00000000..6d80b96b --- /dev/null +++ b/ios/Runner/Utils/NSDate+InaUtils.h @@ -0,0 +1,28 @@ +// +// NSDate+InaUtils.h +// InaUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSDate(InaUtils) ++ (NSDate*)inaDateFromString:(NSString*)value format:(NSString*)format; +- (NSString*)inaStringWithFormat:(NSString*)format; +@end + + diff --git a/ios/Runner/Utils/NSDate+InaUtils.m b/ios/Runner/Utils/NSDate+InaUtils.m new file mode 100644 index 00000000..f1f87990 --- /dev/null +++ b/ios/Runner/Utils/NSDate+InaUtils.m @@ -0,0 +1,38 @@ +// +// NSDate+InaUtils.m +// InaUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSDate+InaUtils.h" + +@implementation NSDate(InaUtils) + ++ (NSDate*)inaDateFromString:(NSString*)value format:(NSString*)format { + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + [dateFormatter setDateFormat:format]; + return [dateFormatter dateFromString:value]; +} + +- (NSString*)inaStringWithFormat:(NSString*)format { + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + [dateFormatter setDateFormat:format]; + return [dateFormatter stringFromDate:self]; +} + +@end + diff --git a/ios/Runner/Utils/NSDictionary+InaPathKey.h b/ios/Runner/Utils/NSDictionary+InaPathKey.h new file mode 100644 index 00000000..6a90c6ca --- /dev/null +++ b/ios/Runner/Utils/NSDictionary+InaPathKey.h @@ -0,0 +1,60 @@ +// +// NSDictionary+InaPathKey.h +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSDictionary(InaPathKey) + + - (id)inaObjectForPathKey:(NSString*)key; + - (id)inaObjectForPathKey:(NSString*)key defaults:(id)defaultValue; + + - (int)inaIntForPathKey:(NSString*)key; + - (int)inaIntForPathKey:(NSString*)key defaults:(int)defaultValue; + + - (long)inaLongForPathKey:(NSString*)key; + - (long)inaLongForPathKey:(NSString*)key defaults:(long)defaultValue; + + - (NSInteger)inaIntegerForPathKey:(NSString*)key; + - (NSInteger)inaIntegerForPathKey:(NSString*)key defaults:(NSInteger)defaultValue; + + - (int64_t)inaInt64ForPathKey:(NSString*)key; + - (int64_t)inaInt64ForPathKey:(NSString*)key defaults:(int64_t)defaultValue; + + - (bool)inaBoolForPathKey:(NSString*)key; + - (bool)inaBoolForPathKey:(NSString*)key defaults:(bool)defaultValue; + + - (float)inaFloatForPathKey:(NSString*)key; + - (float)inaFloatForPathKey:(NSString*)key defaults:(float)defaultValue; + + - (double)inaDoubleForPathKey:(NSString*)key; + - (double)inaDoubleForPathKey:(NSString*)key defaults:(double)defaultValue; + + - (NSString*)inaStringForPathKey:(NSString*)key; + - (NSString*)inaStringForPathKey:(NSString*)key defaults:(NSString*)defaultValue; + + - (NSNumber*)inaNumberForPathKey:(NSString*)key; + - (NSNumber*)inaNumberForPathKey:(NSString*)key defaults:(NSNumber*)defaultValue; + + - (NSDictionary*)inaDictForPathKey:(NSString*)key; + - (NSDictionary*)inaDictForPathKey:(NSString*)key defaults:(NSDictionary*)defaultValue; + + - (NSArray*)inaArrayForPathKey:(NSString*)key; + - (NSArray*)inaArrayForPathKey:(NSString*)key defaults:(NSArray*)defaultValue; +@end diff --git a/ios/Runner/Utils/NSDictionary+InaPathKey.m b/ios/Runner/Utils/NSDictionary+InaPathKey.m new file mode 100644 index 00000000..89da16f5 --- /dev/null +++ b/ios/Runner/Utils/NSDictionary+InaPathKey.m @@ -0,0 +1,174 @@ +// +// NSDictionary+InaPathKey.m +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSDictionary+InaPathKey.h" + +@implementation NSDictionary(InaPathKey) + +- (id)inaObjectForPathKey:(NSString*)key { + id entry = nil; + NSString *field = nil; + NSDictionary *source = self; + NSRange scope = NSMakeRange(0, key.length), lookup; + + while (0 < (lookup = [key rangeOfString:@"." options:0 range:scope]).length) { + field = [key substringWithRange:NSMakeRange(scope.location, lookup.location - scope.location)]; + entry = [source objectForKey:field]; + if ([entry isKindOfClass:[NSDictionary class]] || [entry isKindOfClass:[NSArray class]]) { + source = entry; + scope.location = lookup.location + lookup.length; + scope.length = key.length - scope.location; + } + else { + break; + } + } + + if (0 < scope.length) { + field = (0 < scope.location) ? [key substringWithRange:scope] : key; + return [source objectForKey:field]; + } + else { + return nil; + } +} + +- (id)inaObjectForPathKey:(NSString*)key defaults:(id)defaultValue { + id value = [self inaObjectForPathKey:key]; + return (value != nil) ? value : defaultValue; +} + +- (int)inaIntForPathKey:(NSString*)key { + return [self inaIntForPathKey:key defaults:0]; +} + +- (int)inaIntForPathKey:(NSString*)key defaults:(int)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value respondsToSelector:@selector(intValue)] ? [value intValue] : defaultValue; +} + +- (long)inaLongForPathKey:(NSString*)key { + return [self inaLongForPathKey:key defaults:0L]; +} + +- (long)inaLongForPathKey:(NSString*)key defaults:(long)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value respondsToSelector:@selector(longValue)] ? [value longValue] : defaultValue; +} + +- (int64_t)inaInt64ForPathKey:(NSString*)key { + return [self inaInt64ForPathKey:key defaults:0LL]; +} + +- (int64_t)inaInt64ForPathKey:(NSString*)key defaults:(int64_t)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value respondsToSelector:@selector(longLongValue)] ? [value longLongValue] : defaultValue; +} + +- (NSInteger)inaIntegerForPathKey:(NSString*)key { + return [self inaIntegerForPathKey:key defaults:0LL]; +} + +- (NSInteger)inaIntegerForPathKey:(NSString*)key defaults:(NSInteger)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value respondsToSelector:@selector(integerValue)] ? [value integerValue] : defaultValue; +} + +- (bool)inaBoolForPathKey:(NSString*)key { + return [self inaBoolForPathKey:key defaults:NO]; +} + +- (bool)inaBoolForPathKey:(NSString*)key defaults:(bool)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value respondsToSelector:@selector(boolValue)] ? [value boolValue] : defaultValue; +} + + +- (float)inaFloatForPathKey:(NSString*)key { + return [self inaFloatForPathKey:key defaults:0.0f]; +} + +- (float)inaFloatForPathKey:(NSString*)key defaults:(float)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value respondsToSelector:@selector(floatValue)] ? [value floatValue] : defaultValue; +} + + +- (double)inaDoubleForPathKey:(NSString*)key { + return [self inaDoubleForPathKey:key defaults:0.0]; +} + +- (double)inaDoubleForPathKey:(NSString*)key defaults:(double)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value respondsToSelector:@selector(doubleValue)] ? [value doubleValue] : defaultValue; +} + +- (NSString*)inaStringForPathKey:(NSString*)key { + return [self inaStringForPathKey:key defaults:nil]; +} + +- (NSString*)inaStringForPathKey:(NSString*)key defaults:(NSString*)defaultValue { + id value = [self inaObjectForPathKey:key]; + if(value == nil) + return defaultValue; + else if([value isKindOfClass:[NSString class]]) + return ((NSString*)value); + else if([value respondsToSelector:@selector(stringValue)]) + return [value stringValue]; + else + return defaultValue; +} + +- (NSNumber*)inaNumberForPathKey:(NSString*)key { + return [self inaNumberForPathKey:key defaults:nil]; +} + +- (NSNumber*)inaNumberForPathKey:(NSString*)key defaults:(NSNumber*)defaultValue { + id value = [self inaObjectForPathKey:key]; + if(value == nil) + return defaultValue; + else if([value isKindOfClass:[NSNumber class]]) + return ((NSNumber*)value); + else + return defaultValue; +} + + +- (NSArray*)inaArrayForPathKey:(NSString*)key { + return [self inaArrayForPathKey:key defaults:nil]; +} + +- (NSArray*)inaArrayForPathKey:(NSString*)key defaults:(NSArray*)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value isKindOfClass:[NSArray class]] ? value : defaultValue; +} + +- (NSDictionary*)inaDictForPathKey:(NSString*)key { + return [self inaDictForPathKey:key defaults:nil]; +} + +- (NSDictionary*)inaDictForPathKey:(NSString*)key defaults:(NSDictionary*)defaultValue { + id value = [self inaObjectForPathKey:key]; + return [value isKindOfClass:[NSDictionary class]] ? value : defaultValue; +} + +@end + + diff --git a/ios/Runner/Utils/NSDictionary+InaTypedValue.h b/ios/Runner/Utils/NSDictionary+InaTypedValue.h new file mode 100644 index 00000000..1af8785d --- /dev/null +++ b/ios/Runner/Utils/NSDictionary+InaTypedValue.h @@ -0,0 +1,69 @@ +// +// NSDictionary+InaTypedValue.h +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSDictionary(InaTypedValue) + + - (int)inaIntForKey:(id)key; + - (int)inaIntForKey:(id)key defaults:(int)defaultValue; + + - (long)inaLongForKey:(id)key; + - (long)inaLongForKey:(id)key defaults:(long)defaultValue; + + - (NSInteger)inaIntegerForKey:(id)key; + - (NSInteger)inaIntegerForKey:(id)key defaults:(NSInteger)defaultValue; + + - (int64_t)inaInt64ForKey:(id)key; + - (int64_t)inaInt64ForKey:(id)key defaults:(int64_t)defaultValue; + + - (bool)inaBoolForKey:(id)key; + - (bool)inaBoolForKey:(id)key defaults:(bool)defaultValue; + + - (float)inaFloatForKey:(id)key; + - (float)inaFloatForKey:(id)key defaults:(float)defaultValue; + + - (double)inaDoubleForKey:(id)key; + - (double)inaDoubleForKey:(id)key defaults:(double)defaultValue; + + - (NSString*)inaStringForKey:(id)key; + - (NSString*)inaStringForKey:(id)key defaults:(NSString*)defaultValue; + + - (NSNumber*)inaNumberForKey:(id)key; + - (NSNumber*)inaNumberForKey:(id)key defaults:(NSNumber*)defaultValue; + + - (NSDictionary*)inaDictForKey:(id)key; + - (NSDictionary*)inaDictForKey:(id)key defaults:(NSDictionary*)defaultValue; + + - (NSArray*)inaArrayForKey:(id)key; + - (NSArray*)inaArrayForKey:(id)key defaults:(NSArray*)defaultValue; + + - (NSValue*)inaValueForKey:(id)key; + - (NSValue*)inaValueForKey:(id)key defaults:(NSValue*)defaultValue; + + - (NSData*)inaDataForKey:(id)key; + - (NSData*)inaDataForKey:(id)key defaults:(NSData*)defaultValue; + + - (SEL)inaSelectorForKey:(id)key; + - (SEL)inaSelectorForKey:(id)key defaults:(SEL)defaultValue; + + - (id)inaObjectForKey:(id)key class:(Class)class; + - (id)inaObjectForKey:(id)key class:(Class)class defaults:(id)defaultValue; +@end diff --git a/ios/Runner/Utils/NSDictionary+InaTypedValue.m b/ios/Runner/Utils/NSDictionary+InaTypedValue.m new file mode 100644 index 00000000..eda38ad7 --- /dev/null +++ b/ios/Runner/Utils/NSDictionary+InaTypedValue.m @@ -0,0 +1,184 @@ +// +// NSDictionary+InaTypedValue.m +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSDictionary+InaTypedValue.h" + +@implementation NSDictionary(InaTypedValue) + +- (int)inaIntForKey:(id)key { + return [self inaIntForKey:(id)key defaults:0]; +} + +- (int)inaIntForKey:(id)key defaults:(int)defaultValue { + id value = [self objectForKey:key]; + return [value respondsToSelector:@selector(intValue)] ? [value intValue] : defaultValue; +} + +- (long)inaLongForKey:(id)key { + return [self inaLongForKey:(id)key defaults:0L]; +} + +- (long)inaLongForKey:(id)key defaults:(long)defaultValue { + id value = [self objectForKey:key]; + return [value respondsToSelector:@selector(longValue)] ? [value longValue] : defaultValue; +} + +- (int64_t)inaInt64ForKey:(id)key { + return [self inaInt64ForKey:key defaults:0LL]; +} + +- (int64_t)inaInt64ForKey:(id)key defaults:(int64_t)defaultValue { + id value = [self objectForKey:key]; + return [value respondsToSelector:@selector(longLongValue)] ? [value longLongValue] : defaultValue; +} + +- (NSInteger)inaIntegerForKey:(id)key { + return [self inaIntegerForKey:key defaults:0LL]; +} + +- (NSInteger)inaIntegerForKey:(id)key defaults:(NSInteger)defaultValue { + id value = [self objectForKey:key]; + return [value respondsToSelector:@selector(integerValue)] ? [value integerValue] : defaultValue; +} + +- (bool)inaBoolForKey:(id)key { + return [self inaBoolForKey:key defaults:NO]; +} + +- (bool)inaBoolForKey:(id)key defaults:(bool)defaultValue { + id value = [self objectForKey:key]; + return [value respondsToSelector:@selector(boolValue)] ? [value boolValue] : defaultValue; +} + + +- (float)inaFloatForKey:(id)key { + return [self inaFloatForKey:key defaults:0.0f]; +} + +- (float)inaFloatForKey:(id)key defaults:(float)defaultValue { + id value = [self objectForKey:key]; + return [value respondsToSelector:@selector(floatValue)] ? [value floatValue] : defaultValue; +} + + +- (double)inaDoubleForKey:(id)key { + return [self inaDoubleForKey:key defaults:0.0]; +} + +- (double)inaDoubleForKey:(id)key defaults:(double)defaultValue { + id value = [self objectForKey:key]; + return [value respondsToSelector:@selector(doubleValue)] ? [value doubleValue] : defaultValue; +} + +- (NSString*)inaStringForKey:(id)key { + return [self inaStringForKey:key defaults:nil]; +} + +- (NSString*)inaStringForKey:(id)key defaults:(NSString*)defaultValue { + id value = [self objectForKey:key]; + if(value == nil) + return defaultValue; + else if([value isKindOfClass:[NSString class]]) + return ((NSString*)value); + else if([value respondsToSelector:@selector(stringValue)]) + return [value stringValue]; + else + return defaultValue; +} + +- (NSNumber*)inaNumberForKey:(id)key { + return [self inaNumberForKey:key defaults:nil]; +} + +- (NSNumber*)inaNumberForKey:(id)key defaults:(NSNumber*)defaultValue { + id value = [self objectForKey:key]; + if(value == nil) + return defaultValue; + else if([value isKindOfClass:[NSNumber class]]) + return ((NSNumber*)value); + else + return defaultValue; +} + + +- (NSArray*)inaArrayForKey:(id)key { + return [self inaArrayForKey:key defaults:nil]; +} + +- (NSArray*)inaArrayForKey:(id)key defaults:(NSArray*)defaultValue { + id value = [self objectForKey:key]; + return [value isKindOfClass:[NSArray class]] ? value : defaultValue; +} + +- (NSDictionary*)inaDictForKey:(id)key { + return [self inaDictForKey:key defaults:nil]; +} + +- (NSDictionary*)inaDictForKey:(id)key defaults:(NSDictionary*)defaultValue { + id value = [self objectForKey:key]; + return [value isKindOfClass:[NSDictionary class]] ? value : defaultValue; +} + +- (NSValue*)inaValueForKey:(id)key { + return [self inaValueForKey:key defaults:nil]; +} + +- (NSValue*)inaValueForKey:(id)key defaults:(NSValue*)defaultValue { + id value = [self objectForKey:key]; + return [value isKindOfClass:[NSValue class]] ? value : defaultValue; +} + +- (NSData*)inaDataForKey:(id)key { + return [self inaDataForKey:key defaults:nil]; +} + +- (NSData*)inaDataForKey:(id)key defaults:(NSData*)defaultValue { + id value = [self objectForKey:key]; + return [value isKindOfClass:[NSData class]] ? value : defaultValue; +} + +- (SEL)inaSelectorForKey:(id)key { + return [self inaSelectorForKey:key defaults:NULL]; +} + +- (SEL)inaSelectorForKey:(id)key defaults:(SEL)defaultValue { + id value = [self objectForKey:key]; + if ([value isKindOfClass:[NSValue class]]) { + return ((NSValue*)value).pointerValue; + } + else if ([value isKindOfClass:[NSString class]]) { + SEL selector = NSSelectorFromString(value); + return (selector != nil) ? selector : defaultValue; + } + return defaultValue; +} + +- (id)inaObjectForKey:(id)key class:(Class)class { + return [self inaObjectForKey:key class:class defaults:nil]; +} + +- (id)inaObjectForKey:(id)key class:(Class)class defaults:(id)defaultValue { + id value = [self objectForKey:key]; + return [value isKindOfClass:class] ? value : defaultValue; +} + +@end + + diff --git a/ios/Runner/Utils/NSString+InaJson.h b/ios/Runner/Utils/NSString+InaJson.h new file mode 100644 index 00000000..19b7fb7f --- /dev/null +++ b/ios/Runner/Utils/NSString+InaJson.h @@ -0,0 +1,33 @@ +// +// NSString+InaJson.h +// InaUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSString(InaJson) +- (id)inaObjectFromJsonString; +- (NSDictionary*)inaDictFromJsonString; +- (NSArray*)inaArrayFromJsonString; +@end + +@interface NSObject(InaJson) +- (NSString*)inaJsonString; +@end + + diff --git a/ios/Runner/Utils/NSString+InaJson.m b/ios/Runner/Utils/NSString+InaJson.m new file mode 100644 index 00000000..5261dc21 --- /dev/null +++ b/ios/Runner/Utils/NSString+InaJson.m @@ -0,0 +1,50 @@ +// +// NSString+InaJson.m +// InaUtils +// +// Created by Mihail Varbanov on 5/9/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSString+InaJson.h" + +@implementation NSString(InaJson) + +- (id)inaObjectFromJsonString { + NSData *jsonData = [self dataUsingEncoding:NSUTF8StringEncoding]; + return (jsonData != nil) ? [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:NULL] : nil; +} + +- (NSDictionary*)inaDictFromJsonString { + NSDictionary* dict = [self inaObjectFromJsonString]; + return [dict isKindOfClass:[NSDictionary class]] ? dict : nil; +} + +- (NSArray*)inaArrayFromJsonString { + NSArray* array = [self inaObjectFromJsonString]; + return [array isKindOfClass:[NSArray class]] ? array : nil; +} + +@end + +@implementation NSObject(InaJson) + +- (NSString*)inaJsonString { + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:self options:0 error:NULL]; + return (jsonData != nil) ? [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding] : nil; +} + +@end + diff --git a/ios/Runner/Utils/NSUserDefaults+InaUtils.h b/ios/Runner/Utils/NSUserDefaults+InaUtils.h new file mode 100644 index 00000000..7345c692 --- /dev/null +++ b/ios/Runner/Utils/NSUserDefaults+InaUtils.h @@ -0,0 +1,29 @@ +// +// NSUserDefaults+InaTypedValue.h +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface NSUserDefaults(InaUtils) +- (NSString*)inaStringForKey:(NSString *)defaultName defaults:(NSString*)defaultValue; +- (NSString*)inaNumberForKey:(NSString *)defaultName defaults:(NSNumber*)defaultValue; +- (NSInteger)inaIntegerForKey:(NSString *)defaultName defaults:(NSInteger)defaultValue; +- (bool)inaBoolForKey:(NSString *)defaultName defaults:(bool)defaultValue; +- (double)inaDoubleForKey:(NSString *)defaultName defaults:(double)defaultValue; +@end diff --git a/ios/Runner/Utils/NSUserDefaults+InaUtils.m b/ios/Runner/Utils/NSUserDefaults+InaUtils.m new file mode 100644 index 00000000..cb94f698 --- /dev/null +++ b/ios/Runner/Utils/NSUserDefaults+InaUtils.m @@ -0,0 +1,55 @@ +// +// NSUserDefaults+InaTypedValue.m +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "NSUserDefaults+InaUtils.h" + +@implementation NSUserDefaults(InaUtils) + +- (NSString*)inaStringForKey:(NSString *)defaultName defaults:(NSString*)defaultValue { + NSString *value = [self stringForKey:defaultName]; + return (value != nil) ? value : defaultValue; +} + +- (NSNumber*)inaNumberForKey:(NSString *)defaultName defaults:(NSNumber*)defaultValue { + NSNumber *value = [self objectForKey:defaultName]; + return [value isKindOfClass:[NSNumber class]] ? value : defaultValue; +} + +- (NSInteger)inaIntegerForKey:(NSString *)defaultName defaults:(NSInteger)defaultValue { + id value = [self objectForKey:defaultName]; + return [value respondsToSelector:@selector(integerValue)] ? [value integerValue] : defaultValue; +} + +- (bool)inaBoolForKey:(NSString *)defaultName defaults:(bool)defaultValue { + id value = [self objectForKey:defaultName]; + return [value respondsToSelector:@selector(boolValue)] ? [value boolValue] : defaultValue; +} + +- (double)inaDoubleForKey:(NSString *)defaultName defaults:(double)defaultValue { + id value = [self objectForKey:defaultName]; + return [value respondsToSelector:@selector(doubleValue)] ? [value doubleValue] : defaultValue; +} + +- (float)inaFloatForKey:(NSString *)defaultName defaults:(float)defaultValue { + id value = [self objectForKey:defaultName]; + return [value respondsToSelector:@selector(floatValue)] ? [value doubleValue] : defaultValue; +} + +@end diff --git a/ios/Runner/Utils/UIColor+InaParse.h b/ios/Runner/Utils/UIColor+InaParse.h new file mode 100644 index 00000000..1226a16c --- /dev/null +++ b/ios/Runner/Utils/UIColor+InaParse.h @@ -0,0 +1,41 @@ +// +// UIColor+InaParse.h +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +////////////////////////////////// +// UIColor+InaParse + +@interface UIColor(InaParse) + ++ (UIColor*)inaColorWithHex:(NSString*)hexString; ++ (UIColor*)inaColorWithHex:(NSString*)hexString defaults:(UIColor*)defaultColor; +- (NSString*)inaHexString; + +@end + +////////////////////////////////// +// NSDictionary+InaTypedColor + +@interface NSDictionary(InaTypedColor) +- (UIColor*)inaColorForKey:(id)key; +- (UIColor*)inaColorForKey:(id)key defaults:(UIColor*)defaultValue; +@end + diff --git a/ios/Runner/Utils/UIColor+InaParse.m b/ios/Runner/Utils/UIColor+InaParse.m new file mode 100644 index 00000000..2e250b58 --- /dev/null +++ b/ios/Runner/Utils/UIColor+InaParse.m @@ -0,0 +1,81 @@ +// +// UIColor+InaParse.m +// InaUtils +// +// Created by Mihail Varbanov on 2/12/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "UIColor+InaParse.h" + +#import "NSDictionary+InaTypedValue.h" + +////////////////////////////////// +// UIColor+InaParse + +@implementation UIColor(InaParse) + ++ (UIColor*)inaColorWithHex:(NSString*)hexString { + return [self inaColorWithHex:hexString defaults:nil]; +} + ++ (UIColor*)inaColorWithHex:(NSString*)hexString defaults:(UIColor*)defaultColor { + + NSUInteger scanPos = ((0 < hexString.length) && ([hexString characterAtIndex:0] == '#')) ? 1 : 0; // bypass '#' character + NSUInteger scanLen = hexString.length - scanPos; + if ((scanLen != 8) && (scanLen != 6)) + return defaultColor; + + NSScanner *scanner = [NSScanner scannerWithString:hexString]; + [scanner setScanLocation:scanPos]; + + unsigned int argb = 0x00000000; + if (![scanner scanHexInt:&argb]) + return defaultColor; + + float alpha = (scanLen == 8) ? + ((argb & 0xFF000000) >> 24) / 255.0f : 1.0f; + float red = ((argb & 0x00FF0000) >> 16) / 255.0f; + float green = ((argb & 0x0000FF00) >> 8) / 255.0f; + float blue = ((argb & 0x000000FF)) / 255.0f; + return [UIColor colorWithRed:red green:green blue:blue alpha:alpha]; +} + +- (NSString*)inaHexString { + CGFloat red = 0.0, green = 0.0, blue = 0.0, alpha = 0.0; + [self getRed:&red green:&green blue:&blue alpha:&alpha]; + + return (alpha == 1.0) ? + [NSString stringWithFormat:@"#%02x%02x%02x", (int)(red*255), (int)(green*255), (int)(blue*255)] : + [NSString stringWithFormat:@"#%02x%02x%02x%02x", (int)(alpha*255), (int)(red*255), (int)(green*255), (int)(blue*255)]; +} + +@end + +////////////////////////////////// +// NSDictionary+InaTypedColor + +@implementation NSDictionary(InaTypedColor) + +- (UIColor*)inaColorForKey:(id)key { + return [self inaColorForKey:(id)key defaults:nil]; +} + +- (UIColor*)inaColorForKey:(id)key defaults:(UIColor*)defaultValue { + return [UIColor inaColorWithHex:[self inaStringForKey:key]] ?: defaultValue; +} + +@end + diff --git a/ios/Runner/Utils/UILabel+InaMeasure.h b/ios/Runner/Utils/UILabel+InaMeasure.h new file mode 100644 index 00000000..208a4842 --- /dev/null +++ b/ios/Runner/Utils/UILabel+InaMeasure.h @@ -0,0 +1,32 @@ +// +// UILabel_InaMeasure.h +// NJII +// +// Created by Mihail Varbanov on 2/14/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import + +@interface UILabel(InaMeasure) +- (CGSize)inaTextSize; +- (CGSize)inaTextSizeForBoundWidth:(CGFloat)baundWidth; +- (CGSize)inaTextSizeForBoundSize:(CGSize)boundSize; + +- (CGSize)inaAttributedTextSize; +- (CGSize)inaAttributedTextSizeForBoundWidth:(CGFloat)baundWidth; +- (CGSize)inaAttributedTextSizeForBoundSize:(CGSize)boundSize; +@end + diff --git a/ios/Runner/Utils/UILabel+InaMeasure.m b/ios/Runner/Utils/UILabel+InaMeasure.m new file mode 100644 index 00000000..0eb6cb44 --- /dev/null +++ b/ios/Runner/Utils/UILabel+InaMeasure.m @@ -0,0 +1,74 @@ +// +// UILabel+InaMeasure.m +// NJII +// +// Created by Mihail Varbanov on 2/14/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import "UILabel+InaMeasure.h" + +@implementation UILabel(InaMeasure) + +- (CGSize)inaTextSize { + CGSize textSize = [self.text sizeWithAttributes:@{ + NSFontAttributeName:self.font + }]; + return CGSizeMake(ceil(textSize.width), ceil(textSize.height)); +} + +- (CGSize)inaTextSizeForBoundWidth:(CGFloat)baundWidth { + CGFloat boundHeight = (self.numberOfLines == 0) ? CGFLOAT_MAX : ((self.font.lineHeight * self.numberOfLines) + (self.font.leading * MAX(self.numberOfLines - 1, 0)) + 0.5); + return [self inaTextSizeForBoundSize:CGSizeMake(baundWidth, boundHeight)]; +} + + +- (CGSize)inaTextSizeForBoundSize:(CGSize)boundSize { + + NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; + paragraphStyle.lineBreakMode = self.lineBreakMode; + + NSDictionary *attributes = @{ + NSFontAttributeName:self.font, + NSParagraphStyleAttributeName:paragraphStyle + }; + + NSStringDrawingOptions options = NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading; + + CGSize textSize = [self.text boundingRectWithSize:boundSize options:options attributes:attributes context:nil].size; + + // Apple: + // This method returns fractional sizes (in the size component of the returned CGRect); + // to use a returned size to size views, you must raise its value to the nearest higher integer using the ceil function. + return CGSizeMake(ceil(textSize.width), ceil(textSize.height)); +} + +- (CGSize)inaAttributedTextSize { + CGSize textSize = self.attributedText.size; + return CGSizeMake(ceil(textSize.width), ceil(textSize.height)); +} + +- (CGSize)inaAttributedTextSizeForBoundWidth:(CGFloat)baundWidth { + return [self inaAttributedTextSizeForBoundSize:CGSizeMake(baundWidth, CGFLOAT_MAX)]; +} + + +- (CGSize)inaAttributedTextSizeForBoundSize:(CGSize)boundSize { + NSStringDrawingOptions options = NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading; + CGSize textSize = [self.attributedText boundingRectWithSize:boundSize options:options context:nil].size; + return CGSizeMake(ceil(textSize.width), ceil(textSize.height)); +} + +@end diff --git a/ios/Runner/main.m b/ios/Runner/main.m new file mode 100644 index 00000000..65ec67c8 --- /dev/null +++ b/ios/Runner/main.m @@ -0,0 +1,28 @@ +// +// main.m +// MapsIndoors +// +// Created by Mihail Varbanov on 2/19/19. +// Copyright 2020 Board of Trustees of the University of Illinois. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#import +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/ios/ServiceDefinitions.json b/ios/ServiceDefinitions.json new file mode 100644 index 00000000..3c470ca0 --- /dev/null +++ b/ios/ServiceDefinitions.json @@ -0,0 +1 @@ +{"services":[]} \ No newline at end of file diff --git a/ios/build.sh b/ios/build.sh new file mode 100755 index 00000000..1b466c2e --- /dev/null +++ b/ios/build.sh @@ -0,0 +1,18 @@ +#! /bin/bash + +# Copy GoogleService-Info.plist in output bundle +if [ "${CONFIGURATION}" = "Debug" ]; then +GOOGLE_SERVICE_SRC="${PROJECT_DIR}/Runner/GoogleService-Info-Debug.plist" +else +GOOGLE_SERVICE_SRC="${PROJECT_DIR}/Runner/GoogleService-Info-Release.plist" +fi +GOOGLE_SERVICE_DEST="${CODESIGNING_FOLDER_PATH}/GoogleService-Info.plist" +cp "${GOOGLE_SERVICE_SRC}" "${GOOGLE_SERVICE_DEST}" + +# Upload app DSYM for Crashlytics +if [ "${CONFIGURATION}" = "Release" ]; then + echo "Upload app DSYM for Crashlytics" +# "${PODS_ROOT}/Fabric/run" +# "${PODS_ROOT}/Fabric/upload-symbols" -gsp "${GOOGLE_SERVICE_SRC}" -p ios "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}" + "${PODS_ROOT}/Fabric/upload-symbols" -gsp "${GOOGLE_SERVICE_SRC}" -p ios "${DWARF_DSYM_FOLDER_PATH}" +fi diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..87e8a762 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,228 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/foundation.dart'; +import 'package:illinois/service/AppNavigation.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/ui/onboarding/OnboardingUpgradePanel.dart'; + +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Crashlytics.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/ui/RootPanel.dart'; +import 'package:illinois/service/Styles.dart'; + +final AppExitListener appExitListener = AppExitListener(); + +void main() async { + + // https://stackoverflow.com/questions/57689492/flutter-unhandled-exception-servicesbinding-defaultbinarymessenger-was-accesse + WidgetsFlutterBinding.ensureInitialized(); + + await _init(); + + // do not show the red error widget when release mode + if (kReleaseMode) { + ErrorWidget.builder = (FlutterErrorDetails details) => Container(); + } + + // Log app create analytics event + Analytics().logLivecycle(name: Analytics.LogLivecycleEventCreate); + + runZonedGuarded(() async { + runApp(App()); + }, Crashlytics().handleZoneError); +} + +Future _init() async { + NotificationService().subscribe(appExitListener, AppLivecycle.notifyStateChanged); + + Services().create(); + await Services().init(); +} + +Future _destroy() async { + + NotificationService().unsubscribe(appExitListener); + + Services().destroy(); +} + +class AppExitListener implements NotificationsListener { + + // NotificationsListener + @override + void onNotification(String name, param) { + if ((name == AppLivecycle.notifyStateChanged) && (param == AppLifecycleState.detached)) { + Future.delayed(Duration(), () { + _destroy(); + }); + } + } +} + +class _AppData { + _AppState _panelState; +} + +class App extends StatefulWidget { + + final _AppData _data = _AppData(); + static App _instance; + + App() { + _instance = this; + } + + static get instance { + return _instance; + } + + get panelState { + return _data._panelState; + } + + _AppState createState() { + return _data._panelState = _AppState(); + } +} + +class _AppState extends State implements NotificationsListener { + + RootPanel rootPanel; + String _upgradeRequiredVersion; + String _upgradeAvailableVersion; + Key key = UniqueKey(); + + @override + void initState() { + Log.d("App init"); + + NotificationService().subscribe(this, [ + Onboarding.notifyFinished, + Config.notifyUpgradeAvailable, + Config.notifyUpgradeRequired, + User.notifyUserDeleted, + ]); + + AppLivecycle.instance.ensureBinding(); + + rootPanel = RootPanel(); + _upgradeRequiredVersion = Config().upgradeRequiredVersion; + _upgradeAvailableVersion = Config().upgradeAvailableVersion; + + // This is just a placeholder to take some action on app upgrade. + String lastRunVersion = Storage().lastRunVersion; + if ((lastRunVersion == null) || (lastRunVersion != Config().appVersion)) { + Storage().lastRunVersion = Config().appVersion; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + NativeCommunicator().dismissLaunchScreen(); + }); + + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + key: key, + localizationsDelegates: [ + AppLocalizationsDelegate(), + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: Localization().supportedLocales(), + navigatorObservers:[AppNavigation()], + //onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, + title: Localization().getStringEx('app.title', 'Safer Illinois'), + theme: ThemeData( + primaryColor: Styles().colors.fillColorPrimaryVariant, + fontFamily: Styles().fontFamilies.extraBold), + home: _homePanel, + ); + } + + Widget get _homePanel { + if (_upgradeRequiredVersion != null) { + return OnboardingUpgradePanel(requiredVersion:_upgradeRequiredVersion); + } + else if (_upgradeAvailableVersion != null) { + return OnboardingUpgradePanel(availableVersion:_upgradeAvailableVersion); + } + else if (!Storage().onBoardingPassed) { + return Onboarding().startPanel; + } + else { + return rootPanel; + } + } + + void _resetUI() async { + this.setState(() { + rootPanel = RootPanel(); + key = new UniqueKey(); + }); + } + + void _finishOnboarding(BuildContext context) { + Storage().onBoardingPassed = true; + Route routeToHome = CupertinoPageRoute(builder: (context) => rootPanel); + Navigator.pushAndRemoveUntil(context, routeToHome, (_) => false); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == Onboarding.notifyFinished) { + _finishOnboarding(param); + } + else if (name == Config.notifyUpgradeRequired) { + setState(() { + _upgradeRequiredVersion = param; + }); + } + else if (name == Config.notifyUpgradeAvailable) { + setState(() { + _upgradeAvailableVersion = param; + }); + } + else if (name == User.notifyUserDeleted) { + _resetUI(); + } + } +} diff --git a/lib/model/Auth.dart b/lib/model/Auth.dart new file mode 100644 index 00000000..85c93147 --- /dev/null +++ b/lib/model/Auth.dart @@ -0,0 +1,254 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:illinois/utils/Utils.dart'; + +abstract class AuthToken { + String get idToken => null; + String get accessToken => null; + String get refreshToken => null; + String get tokenType => null; + int get expiresIn => null; + + factory AuthToken.fromJson(Map json) { + if(json != null){ + if(json.containsKey("phone")){ + return PhoneToken.fromJson(json); + } + else{ + return ShibbolethToken.fromJson(json); + } + } + return null; + } + + toJson() => {}; +} + +class ShibbolethToken with AuthToken { + + final String idToken; + final String accessToken; + final String refreshToken; + final String tokenType; + final int expiresIn; + + ShibbolethToken({this.idToken, this.accessToken, this.refreshToken, this.tokenType, this.expiresIn}); + + factory ShibbolethToken.fromJson(Map json) { + return ShibbolethToken( + idToken: json['id_token'], + accessToken: json['access_token'], + refreshToken: json['refresh_token'], + tokenType: json['token_type'], + expiresIn: json['expires_in'], + ); + } + + toJson() { + return { + 'id_token': idToken, + 'access_token': accessToken, + 'refresh_token': refreshToken, + 'token_type': tokenType, + 'expires_in': expiresIn + }; + } + + bool operator ==(o) => + o is ShibbolethToken && + o.idToken == idToken && + o.accessToken == accessToken && + o.refreshToken == refreshToken && + o.tokenType == tokenType && + o.expiresIn == expiresIn; + + int get hashCode => + idToken.hashCode ^ + accessToken.hashCode ^ + refreshToken.hashCode ^ + tokenType.hashCode ^ + expiresIn.hashCode; +} + +class PhoneToken with AuthToken{ + final String phone; + final String idToken; + final String tokenType = "Bearer"; // missing data from the phone validation + + PhoneToken({this.phone, this.idToken}); + + factory PhoneToken.fromJson(Map json) { + return PhoneToken( + phone: json['phone'], + idToken: json['id_token'], + ); + } + + toJson() { + return { + 'phone': phone, + 'id_token': idToken, + }; + } + + bool operator ==(o) => + o is PhoneToken && + o.phone == phone && + o.accessToken == accessToken; + + int get hashCode => + phone.hashCode ^ + accessToken.hashCode; +} + +class AuthInfo { + + String fullName; + String firstName; + String middleName; + String lastName; + String username; + String uin; + String sub; + String email; + Set userGroupMembership; + + static const analyticsUin = 'UINxxxxxx'; + static const analyticsFirstName = 'FirstNameXXXXXX'; + static const analyticsLastName = 'LastNameXXXXXX'; + + AuthInfo({this.fullName, this.firstName, this.middleName, this.lastName, + this.username, this.uin, this.sub, this.email, this.userGroupMembership}); + + factory AuthInfo.fromJson(Map json) { + dynamic groupMembershipJson = json != null + ? json['uiucedu_is_member_of'] + : null; + Set userGroupMembership = groupMembershipJson != null ? Set.from( + groupMembershipJson) : null; + + return (json != null) ? AuthInfo( + fullName: AppString.isStringNotEmpty(json["name"]) ? json["name"] : "", + firstName: AppString.isStringNotEmpty(json["given_name"]) ? json["given_name"] : "", + middleName: AppString.isStringNotEmpty(json["middle_name"]) ? json["middle_name"] : "", + lastName: AppString.isStringNotEmpty(json["family_name"]) ? json["family_name"] : "", + username: AppString.isStringNotEmpty(json["preferred_username"]) ? json["preferred_username"] : "", + uin: AppString.isStringNotEmpty(json["uiucedu_uin"]) ? json["uiucedu_uin"] : "", + sub: AppString.isStringNotEmpty(json["sub"]) ? json["sub"] : "", + email: AppString.isStringNotEmpty(json["email"]) ? json["email"] : "", + userGroupMembership: userGroupMembership + ) : null; + } + + toJson() { + List userGroupsToJson = (userGroupMembership != null) ? + userGroupMembership.toList() : null; + + return { + "name": fullName, + "given_name": firstName, + "middle_name": middleName, + "family_name": lastName, + "preferred_username": username, + "uiucedu_uin": uin, + "sub": sub, + "email": email, + "uiucedu_is_member_of": userGroupsToJson + }; + } +} + +class AuthCard { + + final String uin; + final String fullName; + final String role; + final String cardNumber; + final String expirationDate; + final String libraryNumber; + final String magTrack2; + final String photoBase64; + + Future get photoBytes async{ + return (photoBase64 != null) ? await compute(AppBytes.decodeBase64Bytes, photoBase64) : null; + } + + AuthCard({this.uin, this.cardNumber, this.libraryNumber, this.expirationDate, this.fullName, this.role, this.magTrack2, this.photoBase64}); + + factory AuthCard.fromJson(Map json) { + return AuthCard( + uin: json['UIN'], + fullName: json['full_name'], + role: json['role'], + cardNumber: json['card_number'], + expirationDate: json['expiration_date'], + libraryNumber: json['library_number'], + magTrack2: json['mag_track2'], + photoBase64: json['photo_base64'], + ); + } + + toJson() { + return { + 'UIN': uin, + 'full_name': fullName, + 'role': role, + 'card_number': cardNumber, + 'expiration_date': expirationDate, + 'library_number': libraryNumber, + 'mag_track2': magTrack2, + 'photo_base64': photoBase64, + }; + } + + toShortJson() { + return { + 'UIN': uin, + 'full_name': fullName, + 'role': role, + 'card_number': cardNumber, + 'expiration_date': expirationDate, + 'library_number': libraryNumber, + 'mag_track2': magTrack2, + 'photo_base64_len': photoBase64?.length, + }; + } + + bool operator ==(o) => + o is AuthCard && + o.uin == uin && + o.fullName == fullName && + o.role == role && + o.cardNumber == cardNumber && + o.expirationDate == expirationDate && + o.libraryNumber == libraryNumber && + o.magTrack2 == magTrack2 && + o.photoBase64 == photoBase64; + + int get hashCode => + uin.hashCode ^ + fullName.hashCode ^ + role.hashCode ^ + cardNumber.hashCode ^ + expirationDate.hashCode ^ + libraryNumber.hashCode ^ + magTrack2.hashCode ^ + photoBase64.hashCode; +} + diff --git a/lib/model/Exposure.dart b/lib/model/Exposure.dart new file mode 100644 index 00000000..d3857c38 --- /dev/null +++ b/lib/model/Exposure.dart @@ -0,0 +1,147 @@ + + + +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; + +/////////////////////////////// +// ExposureTEK + +class ExposureTEK { + final String tek; + final int timestamp; + final int expirestamp; + + ExposureTEK({this.tek, this.timestamp, this.expirestamp}); + + factory ExposureTEK.fromJson(Map json) { + return (json != null) ? ExposureTEK( + tek: json['tek'], + timestamp: json['timestamp'], + expirestamp: json['expirestamp'], + ) : null; + } + + Map toJson() { + return { + 'tek': tek, + 'timestamp': timestamp, + 'expirestamp': expirestamp, + }; + } + + DateTime get dateUtc { + return (timestamp != null) ? DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true) : null; + } + + DateTime get expireUtc { + return (expirestamp != null) ? DateTime.fromMillisecondsSinceEpoch(expirestamp, isUtc: true) : null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + ExposureTEK value; + try { value = ExposureTEK.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (ExposureTEK value in values) { + json.add(value?.toJson()); + } + } + return json; + } + + static Map mapFromJson(List json) { + Map result; + if (json != null) { + result = Map(); + for (dynamic entry in json) { + ExposureTEK value; + try { value = ExposureTEK.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + if (value.tek != null) { + result[value.tek] = value; + } + } + } + return result; + } + + static List mapToJson(Map entries) { + List json; + if (entries != null) { + json = []; + for (ExposureTEK value in entries.values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// ExposureRecord + +class ExposureRecord { + final int id; + final String rpi; + final int timestamp; + final int duration; + + ExposureRecord({this.id, this.rpi, this.timestamp, this.duration}); + + DateTime get dateUtc { + return (timestamp != null) ? DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true) : null; + } + + int get durationInMinutes { + return (duration ~/ 60000); // milliseconds -> minutes + } + + String get durationDisplayString { + int durationInSeconds = (duration != null) ? duration ~/ 1000 : null; + if (durationInSeconds != null) { + if (durationInSeconds < 60) { + return "$durationInSeconds sec" + (1 != durationInSeconds ? "s" : ""); + } + else { + int durationInMinutes = durationInSeconds ~/ 60; + if (durationInMinutes < TimeOfDay.minutesPerHour) { + return "$durationInMinutes min" + (1 != durationInMinutes ? "s" : ""); + } else { + int exposureHours = durationInMinutes ~/ TimeOfDay.minutesPerHour; + return "$exposureHours hr" + (1 != exposureHours ? "s" : ""); + } + + } + } + return null; + } +} diff --git a/lib/model/Health.dart b/lib/model/Health.dart new file mode 100644 index 00000000..bfc9aeb7 --- /dev/null +++ b/lib/model/Health.dart @@ -0,0 +1,2301 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/utils/Crypt.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:intl/intl.dart'; +import "package:pointycastle/export.dart"; + +/////////////////////////////// +// Covid19News + +class Covid19News implements Favorite { + final String id; + final DateTime date; + final String title; + final String description; + final String htmlContent; + final String link; + + Covid19News({this.id, this.date, this.title, this.description, this.htmlContent, this.link}); + + factory Covid19News.fromJson(Map json) { + return Covid19News( + id: json['id'], + date: healthDateTimeFromString(json['date']), + title: json['title'], + description: json['description'], + htmlContent: json['htmlContent'], + link: json['link'], + ); + } + + String get displayDate { + return AppDateTime().formatDateTime(date, format: AppDateTime.covid19NewsCardDateFormat); + } + + dynamic toJson() { + return { + 'id': id, + 'date': healthDateTimeToString(date), + 'title': title, + 'description': description, + 'htmlContent': htmlContent, + 'link': link, + }; + } + + // Favorite implementation + + static String favoriteKeyName = "covid19NewsIds"; + + @override + String get favoriteId => id; + + @override + String get favoriteKey => favoriteKeyName; +} + +/////////////////////////////// +// Covid19FAQEntry + +class Covid19FAQEntry { + final String title; + final String description; + final String link; + + Covid19FAQEntry({this.title, this.description, this.link}); + + factory Covid19FAQEntry.fromJson(Map json) { + return Covid19FAQEntry( + title: json['title'], + description: json['description'], + link: json['link'], + ); + } + + dynamic toJson() { + return { + 'title': title, + 'description': description, + 'link': link, + }; + } + + static List fromJsonList(List jsonList) { + List faqs; + if (jsonList != null) { + faqs = List(); + for (dynamic jsonEntry in jsonList) { + faqs.add(Covid19FAQEntry.fromJson(jsonEntry)); + } + } + return faqs; + } + + static List toJsonList(List faqs) { + List jsonList; + if (faqs != null) { + jsonList = List(); + for (Covid19FAQEntry faq in faqs) { + jsonList.add(faq.toJson()); + } + } + return jsonList; + } +} + +/////////////////////////////// +// Covid19FAQSection + +class Covid19FAQSection { + final String title; + final List questions; + + Covid19FAQSection({this.title, this.questions}); + + factory Covid19FAQSection.fromJson(Map json) { + return Covid19FAQSection( + title: json['title'], + questions: Covid19FAQEntry.fromJsonList(json['questions']), + ); + } + + dynamic toJson() { + return { + 'title': title, + 'questions': Covid19FAQEntry.toJsonList(questions), + }; + } + + static List fromJsonList(List jsonList) { + List sections; + if (jsonList != null) { + sections = List(); + for (dynamic jsonEntry in jsonList) { + sections.add(Covid19FAQSection.fromJson(jsonEntry)); + } + } + return sections; + } + + static List toJsonList(List sections) { + List jsonList; + if (sections != null) { + jsonList = List(); + for (Covid19FAQSection section in sections) { + jsonList.add(section.toJson()); + } + } + return jsonList; + } +} + +class Covid19FAQ { + DateTime dateUpdated; + List sections; + List general; + + Covid19FAQ({this.dateUpdated, this.sections, this.general}); + + factory Covid19FAQ.fromJson(Map json) { + return Covid19FAQ( + dateUpdated: healthDateTimeFromString(json['dateUpdated']), + sections: Covid19FAQSection.fromJsonList(json['sections']), + general: Covid19FAQEntry.fromJsonList(json['general']), + ); + } + + dynamic toJson() { + return { + 'dateUpdated': healthDateTimeToString(dateUpdated), + 'sections': Covid19FAQSection.fromJsonList(sections), + 'general': Covid19FAQEntry.toJsonList(general), + }; + } +} + +/////////////////////////////// +// Covid19Resource + +class Covid19Resource { + final String title; + final String icon; + final String link; + + Covid19Resource({this.title, this.icon, this.link}); + + factory Covid19Resource.fromJson(Map json) { + return Covid19Resource( + title: json['title'], + icon: json['icon'], + link: json['link'], + ); + } + + dynamic toJson() { + return { + 'title': title, + 'icon': icon, + 'link': link, + }; + } +} + +//////////////////////////////// +// Covid19Status + +class Covid19Status { + final String id; + final String userId; + final DateTime dateUtc; + final String encryptedKey; + final String encryptedBlob; + Covid19StatusBlob blob; + + Covid19Status({this.id, this.userId, this.dateUtc, this.encryptedKey, this.encryptedBlob, this.blob}); + + factory Covid19Status.fromJson(Map json) { + return (json != null) ? Covid19Status( + id: json['id'], + userId: json['user_id'], + dateUtc: healthDateTimeFromString(json['date']), + encryptedKey: json['encrypted_key'], + encryptedBlob: json['encrypted_blob'], + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'date': healthDateTimeToString(dateUtc), + 'encrypted_key': encryptedKey, + 'encrypted_blob': encryptedBlob, + }; + } + + static Future decryptedFromJson(Map json, PrivateKey privateKey) async { + try { + Covid19Status value = Covid19Status.fromJson(json); + if ((value != null) && (value.encryptedKey != null) && (value.encryptedBlob != null) && (privateKey != null)) { + String blobString = await compute(_decryptBlob, { + 'encryptedKey': value.encryptedKey, + 'encryptedBlob': value.encryptedBlob, + 'privateKey': privateKey + }); + value.blob = Covid19StatusBlob.fromJson(AppJson.decodeMap(blobString)); + } + return value; + } + catch(e) { print(e?.toString()); } + return null; + } + + Future encrypted(PublicKey publicKey) async { + Map encrypted = await compute(_encryptBlob, { + 'blob': AppJson.encode(blob?.toJson()), + 'publicKey': publicKey + }); + return Covid19Status( + id: id, + userId: userId, + dateUtc: dateUtc, + encryptedKey: encrypted['encryptedKey'], + encryptedBlob: encrypted['encryptedBlob'], + ); + } +} + +/////////////////////////////// +// Covid19StatusBlob + +class Covid19StatusBlob { + final String healthStatus; + final int priority; + final String nextStep; + final DateTime nextStepDateUtc; + final String reason; + final Covid19HistoryBlob historyBlob; + + static const String _nextStepDateMacro = '{next_step_date}'; + static const String _nextStepDateFormat = 'MMMM d'; + + Covid19StatusBlob({this.healthStatus, this.priority, this.nextStep, this.nextStepDateUtc, this.reason, this.historyBlob}); + + + factory Covid19StatusBlob.fromJson(Map json) { + return (json != null) ? Covid19StatusBlob( + healthStatus: json['health_status'], + priority: json['priority'], + nextStep: json['next_step'], + nextStepDateUtc: healthDateTimeFromString(json['next_step_date']), + reason: json['reason'], + historyBlob: Covid19HistoryBlob.fromJson(json['history_blob']), + ) : null; + } + + Map toJson() { + return { + 'health_status': healthStatus, + 'priority': priority, + 'next_step': nextStep, + 'next_step_date': healthDateTimeToString(nextStepDateUtc), + 'reason': reason, + 'history_blob': historyBlob?.toJson(), + }; + } + + String get displayNextStep { + if (nextStep != null) { + if (nextStep.contains(_nextStepDateMacro)) { + if (nextStepDateUtc != null) { + String nextStepDateString = AppDateTime().formatDateTime(nextStepDateUtc.toLocal(), format: _nextStepDateFormat); + return nextStep.replaceAll(_nextStepDateMacro, nextStepDateString); + } + } + } + return nextStep; + } + + String get localizedHealthStatus { + return localizedHealthStatusFromKey(healthStatus); + } + + String get localizedHealthStatusType { + return localizedHealthStatusTypeFromKey(healthStatus); + } + + String get localizedHealthStatusDescription { + return localizedHealthStatusDescriptionFromKey(healthStatus); + } + + static String localizedHealthStatusFromKey(String key) { + return _localizedHealthStatusFromKey("com.illinois.covid19.status.long.${key.toLowerCase()}", AppString.capitalize(key)); + } + + static String localizedHealthStatusTypeFromKey(String key) { + return _localizedHealthStatusFromKey("com.illinois.covid19.status.type.${key.toLowerCase()}", AppString.capitalize(key)); + } + + static String localizedHealthStatusDescriptionFromKey(String key) { + return _localizedHealthStatusFromKey("com.illinois.covid19.status.description.${key.toLowerCase()}", AppString.capitalize(key)); + } + + static String _localizedHealthStatusFromKey(String key, String defaultValue) { + if(key != null){ + return Localization().getStringEx(key, defaultValue); + } + return defaultValue; + } +} + +/////////////////////////////// +// Covid19HealthStatus + +const String kCovid19HealthStatusRed = 'red'; +const String kCovid19HealthStatusOrange = 'orange'; +const String kCovid19HealthStatusYellow = 'yellow'; +const String kCovid19HealthStatusGreen = 'green'; +const String kCovid19HealthStatusUnchanged = 'no change'; + +Color covid19HealthStatusColor(String status) { + switch (status) { + case kCovid19HealthStatusRed: return Styles().colors.healthStatusRed; + case kCovid19HealthStatusOrange: return Styles().colors.healthStatusOrange; + case kCovid19HealthStatusYellow: return Styles().colors.healthStatusYellow; + case kCovid19HealthStatusGreen: return Styles().colors.healthStatusGreen; + default: return null; + } +} + +bool covid19HealthStatusIsValid(String status) { + return (status != null) && (status != kCovid19HealthStatusUnchanged); +} + +int covid19HealthStatusWeight(String status) { + switch (status) { + case kCovid19HealthStatusRed: return 4; + case kCovid19HealthStatusOrange: return 3; + case kCovid19HealthStatusYellow: return 2; + case kCovid19HealthStatusGreen: return 1; + default: return 0; + } +} + +//////////////////////////////// +// Covid19Access + +const String kCovid19AccessGranted = 'granted'; +const String kCovid19AccessDenied = 'denied'; + + +/////////////////////////////// +// Covid19History + +class Covid19History { + final String id; + final String userId; + final DateTime dateUtc; + final Covid19HistoryType type; + + final String encryptedKey; + final String encryptedBlob; + + final String locationId; + final String countyId; + final String encryptedImageKey; + final String encryptedImageBlob; + + Covid19HistoryBlob blob; + + Covid19History({this.id, this.userId, this.dateUtc, this.type, this.encryptedKey, this.encryptedBlob, this.locationId, this.countyId, this.encryptedImageKey, this.encryptedImageBlob }); + + factory Covid19History.fromJson(Map json) { + return (json != null) ? Covid19History( + id: json['id'], + userId: json['user_id'], + dateUtc: healthDateTimeFromString(json['date']), + type: covid19HistoryTypeFromString(json['type']), + + encryptedKey: json['encrypted_key'], + encryptedBlob: json['encrypted_blob'], + + locationId: json['location_id'], + countyId: json['county_id'], + encryptedImageKey: json['encrypted_image_key'], + encryptedImageBlob: json['encrypted_image_blob'], + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'date': healthDateTimeToString(dateUtc), + 'type': covid19HistoryTypeToString(type), + + 'encrypted_key': encryptedKey, + 'encrypted_blob': encryptedBlob, + + 'location_id': locationId, + 'county_id': countyId, + 'encrypted_image_key': encryptedImageKey, + 'encrypted_image_blob': encryptedImageBlob, + }; + } + + static Future decryptedFromJson(Map json, Map privateKeys ) async { + try { + Covid19History value = Covid19History.fromJson(json); + PrivateKey privateKey = privateKeys[value.type]; + if ((value != null) && (value.encryptedKey != null) && (value.encryptedBlob != null) && (privateKey != null)) { + String blobString = await compute(_decryptBlob, { + 'encryptedKey': value.encryptedKey, + 'encryptedBlob': value.encryptedBlob, + 'privateKey': privateKey + }); + value.blob = Covid19HistoryBlob.fromJson(AppJson.decodeMap(blobString)); + } + return value; + } + catch(e) { print(e?.toString()); } + return null; + } + + static Future encryptedFromBlob({String id, String userId, DateTime dateUtc, Covid19HistoryType type, Covid19HistoryBlob blob, String locationId, String countyId, String image, PublicKey publicKey}) async { + Map encrypted = await compute(_encryptBlob, { + 'blob': AppJson.encode(blob?.toJson()), + 'publicKey': publicKey + }); + Map encryptedImage = (image != null) ? await compute(_encryptBlob, { + 'blob': image, + 'publicKey': publicKey + }) : null; + return Covid19History( + id: id, + userId: userId, + dateUtc: dateUtc, + type: type, + encryptedKey: encrypted['encryptedKey'], + encryptedBlob: encrypted['encryptedBlob'], + locationId: locationId, + countyId: countyId, + encryptedImageKey: (encryptedImage != null) ? encryptedImage['encryptedKey'] : null, + encryptedImageBlob: (encryptedImage != null) ? encryptedImage['encryptedBlob'] : null, + ); + } + + bool get isTest { + return (type == Covid19HistoryType.test) || (type == Covid19HistoryType.manualTestNotVerified) || (type == Covid19HistoryType.manualTestVerified); + } + + bool get isManualTest { + return (type == Covid19HistoryType.manualTestNotVerified) || (type == Covid19HistoryType.manualTestVerified); + } + + bool get isTestVerified { + return (type == Covid19HistoryType.test) || (type == Covid19HistoryType.manualTestVerified); + } + + bool get canTestUpdateStatus { + return (type == Covid19HistoryType.test) || (type == Covid19HistoryType.manualTestVerified); + } + + bool get isSymptoms { + return (type == Covid19HistoryType.symptoms); + } + + bool get isContactTrace { + return (type == Covid19HistoryType.contactTrace); + } + + bool get isAction { + return (type == Covid19HistoryType.action); + } + + DateTime get dateMidnightLocal { + if (dateUtc != null) { + DateTime dateLocal = dateUtc.toLocal(); + return DateTime(dateLocal.year, dateLocal.month, dateLocal.day); + } + else { + return null; + } + } + + bool matchEvent(Covid19Event event) { + if (event.isTest) { + return this.isTest && + (this.dateUtc == event?.blob?.dateUtc) && + (this.blob?.provider == event?.provider) && + (this.blob?.providerId == event?.providerId) && + (this.blob?.testType == event?.blob?.testType) && + (this.blob?.testResult == event?.blob?.testResult); + } + else if (event.isAction) { + return this.isAction && + (this.dateUtc == event?.blob?.dateUtc) && + (this.blob?.actionType == event?.blob?.actionType) && + (this.blob?.actionText == event?.blob?.actionText); + } + else { + return false; + } + } + + static Future> listFromJson(List json, Map privateKeys) async { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + Covid19History value = await Covid19History.decryptedFromJson((entry as Map)?.cast(), privateKeys); + values.add(value); + } + } + return values; + } + + /*static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (Covid19History value in values) { + json.add(value?.toJson()); + } + } + return json; + }*/ + + static Covid19History traceInList(List values, { String tek }) { + if ((values != null) && (tek != null)) { + for (Covid19History history in values) { + if ((history.type == Covid19HistoryType.contactTrace) && (history.blob?.traceTEK == tek)) { + return history; + } + } + } + return null; + } + + static bool listContainsEvent(List histories, Covid19Event event) { + if ((histories != null) && (event != null)) { + for (Covid19History history in histories) { + if (history.matchEvent(event)) { + return true; + } + } + } + return false; + } + + static Covid19History mostRecent(List histories) { + if (histories != null) { + DateTime nowUtc = DateTime.now().toUtc(); + for (int index = 0; index < histories.length; index++) { + Covid19History history = histories[index]; + if ((history.dateUtc != null) && (history.dateUtc.isBefore(nowUtc))) { + return history; + } + } + } + return null; + } + + static Covid19History mostRecentTest(List histories) { + if (histories != null) { + DateTime nowUtc = DateTime.now().toUtc(); + for (int index = 0; index < histories.length; index++) { + Covid19History history = histories[index]; + if (history.isTestVerified && (history.dateUtc != null) && (history.dateUtc.isBefore(nowUtc))) { + return history; + } + } + } + return null; + } + + static List pastList(List histories) { + List result; + if (histories != null) { + result = List(); + DateTime nowUtc = DateTime.now().toUtc(); + for (int index = 0; index < histories.length; index++) { + Covid19History history = histories[index]; + if ((history.dateUtc != null) && (history.dateUtc.isBefore(nowUtc))) { + result.add(history); + } + } + } + return result; + } +} + +//////////////////////////////// +// Covid19HistoryBlob + +class Covid19HistoryBlob { + final String provider; + final String providerId; + final String location; + final String locationId; + final String countyId; + final String testType; + final String testResult; + + final List symptoms; + + final int traceDuration; + final String traceTEK; + + final String actionType; + final String actionText; + + Covid19HistoryBlob({ + this.provider, this.providerId, this.location, this.locationId, this.countyId, this.testType, this.testResult, + this.symptoms, + this.traceDuration, this.traceTEK, + this.actionType, this.actionText, + }); + + factory Covid19HistoryBlob.fromJson(Map json) { + return (json != null) ? Covid19HistoryBlob( + provider: json['provider'], + providerId: json['provider_id'], + location: json['location'], + locationId: json['location_id'], + countyId: json['county_id'], + testType: json['test_type'], + testResult: json['result'], + + symptoms: HealthSymptom.listFromJson(json['symptoms']), + + traceDuration: json['trace_duration'], + traceTEK: json['trace_tek'], + + actionType: json['action_type'], + actionText: json['action_text'], + ) : null; + } + + Map toJson() { + return { + 'provider': provider, + 'provider_id': providerId, + 'location': location, + 'location_id': locationId, + 'county_id': countyId, + 'test_type': testType, + 'result': testResult, + + 'symptoms': HealthSymptom.listToJson(symptoms), + + 'trace_duration': traceDuration, + 'trace_tek': traceTEK, + + 'action_type': actionType, + 'action_text': actionText, + }; + } + + bool get isTest { + return (providerId != null) || (locationId != null) || (testType != null) || (testResult != null); + } + + bool get isSymptoms { + return (symptoms != null); + } + + bool get isContactTrace { + return ((traceDuration != null) /*&& (traceTEK != null)*/); + } + + bool get isAction { + return (actionType != null); + } + + Set get symptomsIds { + Set symptomsIds; + if (symptoms != null) { + symptomsIds = Set(); + for (HealthSymptom symptom in symptoms) { + symptomsIds.add(symptom.id); + } + } + return symptomsIds; + } + + String get symptomsDisplayString { + String result = ""; + if (symptoms != null) { + for (HealthSymptom symptom in symptoms) { + if (0 < result.length) { + result += ", "; + } + result += symptom.name; + } + } + return result; + } + + int get traceDurationInMinutes { + return (traceDuration != null) ? (traceDuration / 60000).round() : null; + } + + String get traceDurationDisplayString { + int durationInSeconds = (traceDuration != null) ? traceDuration ~/ 1000 : null; + if (durationInSeconds != null) { + if (durationInSeconds < 60) { + return "$durationInSeconds second" + (1 != durationInSeconds ? "s" : ""); + } + else { + int durationInMinutes = durationInSeconds ~/ 60; + if (durationInMinutes < TimeOfDay.minutesPerHour) { + return "$durationInMinutes minute" + (1 != durationInMinutes ? "s" : ""); + } else { + int exposureHours = durationInMinutes ~/ TimeOfDay.minutesPerHour; + return "$exposureHours hour" + (1 != exposureHours ? "s" : ""); + } + + } + } + return null; + } + + String get actionDisplayString { + return actionText ?? actionType; + } +} + +//////////////////////////////// +// Covid19HistoryType + +enum Covid19HistoryType { test, manualTestVerified, manualTestNotVerified, symptoms, contactTrace, action } + +Covid19HistoryType covid19HistoryTypeFromString(String value) { + if (value == 'received_test') { + return Covid19HistoryType.test; + } + else if (value == 'verified_manual_test') { + return Covid19HistoryType.manualTestVerified; + } + else if (value == 'unverified_manual_test') { + return Covid19HistoryType.manualTestNotVerified; + } + else if (value == 'symptoms') { + return Covid19HistoryType.symptoms; + } + else if (value == 'trace') { + return Covid19HistoryType.contactTrace; + } + else if (value == 'action') { + return Covid19HistoryType.action; + } + else { + return null; + } +} + +String covid19HistoryTypeToString(Covid19HistoryType value) { + switch (value) { + case Covid19HistoryType.test: return 'received_test'; + case Covid19HistoryType.manualTestVerified: return 'verified_manual_test'; + case Covid19HistoryType.manualTestNotVerified: return 'unverified_manual_test'; + case Covid19HistoryType.symptoms: return 'symptoms'; + case Covid19HistoryType.contactTrace: return 'trace'; + case Covid19HistoryType.action: return 'action'; + } + return null; +} + +/////////////////////////////// +// Covid19Event + +class Covid19Event { + final String id; + final String provider; + final String providerId; + final String userId; + final String encryptedKey; + final String encryptedBlob; + final bool processed; + final DateTime dateCreated; + final DateTime dateUpdated; + + Covid19EventBlob blob; + + Covid19Event({this.id, this.provider, this.providerId, this.userId, this.encryptedKey, this.encryptedBlob, this.processed, this.dateCreated, this.dateUpdated}); + + factory Covid19Event.fromJson(Map json) { + return (json != null) ? Covid19Event( + id: AppJson.stringValue(json['id']), + provider: AppJson.stringValue(json['provider']), + providerId: AppJson.stringValue(json['provider_id']), + userId: AppJson.stringValue(json['user_id']), + encryptedKey: AppJson.stringValue(json['encrypted_key']), + encryptedBlob: AppJson.stringValue(json['encrypted_blob']), + processed: AppJson.boolValue(json['processed']), + dateCreated: healthDateTimeFromString(AppJson.stringValue(json['date_created'])), + dateUpdated: healthDateTimeFromString(AppJson.stringValue(json['date_updated'])), + ) : null; + } + + Map toJson() { + Map json = {}; + json['id'] = id; + json['provider'] = provider; + json['provider_id'] = providerId; + json['user_id'] = userId; + json['encrypted_key'] = encryptedKey; + json['encrypted_blob'] = encryptedBlob; + json['processed'] = processed; + json['date_created'] = healthDateTimeToString(dateCreated); + json['date_updated'] = healthDateTimeToString(dateUpdated); + return json; + } + + static Future decryptedFromJson(Map json, PrivateKey privateKey) async { + try { + Covid19Event value = Covid19Event.fromJson(json); + if ((value != null) && (value.encryptedKey != null) && (value.encryptedBlob != null) && (privateKey != null)) { + String blobString = await compute(_decryptBlob, { + 'encryptedKey': value.encryptedKey, + 'encryptedBlob': value.encryptedBlob, + 'privateKey': privateKey + }); + value.blob = Covid19EventBlob.fromJson(AppJson.decodeMap(blobString)); + } + return value; + } + catch(e) { print(e?.toString()); } + return null; + } + + static Future> listFromJson(List json, PrivateKey privateKey) async { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + Covid19Event value = await Covid19Event.decryptedFromJson(entry, privateKey); + values.add(value); + } + } + return values; + } + + bool get isTest { + return (blob != null) && blob.isTest && (providerId != null); + } + + bool get isAction { + return (blob != null) && blob.isAction; + } +} + +/////////////////////////////// +// Covid19EventBlob + +class Covid19EventBlob { + final DateTime dateUtc; + + final String testType; + final String testResult; + + final String actionType; + final String actionText; + + Covid19EventBlob({this.dateUtc, this.testType, this.testResult, this.actionType, this.actionText}); + + factory Covid19EventBlob.fromJson(Map json) { + return (json != null) ? Covid19EventBlob( + dateUtc: healthDateTimeFromString(AppJson.stringValue(json['Date'])), + testType: AppJson.stringValue(json['TestName']), + testResult: AppJson.stringValue(json['Result']), + actionType: AppJson.stringValue(json['ActionType']), + actionText: AppJson.stringValue(json['ActionText']), + ) : null; + } + + Map toJson() { + if ((testType != null) || (testResult != null)) { + return { + 'Date': healthDateTimeToString(dateUtc), + 'TestName': testType, + 'Result': testResult, + }; + } + else if ((actionType != null) || (actionText != null)) { + return { + 'Date': healthDateTimeToString(dateUtc), + 'ActionType': actionType, + 'ActionText': actionText, + }; + } + else { + return { + 'Date': healthDateTimeToString(dateUtc), + }; + } + } + + bool get isTest { + return AppString.isStringNotEmpty(testType) && AppString.isStringNotEmpty(testResult); + } + + bool get isAction { + return AppString.isStringNotEmpty(actionType); + } +} + + +/////////////////////////////// +// Covid19OSFTest + +class Covid19OSFTest { + final String provider; + final String providerId; + final String testType; + final String testResult; + final DateTime dateUtc; + + Covid19OSFTest({this.provider, this.providerId, this.testType, this.testResult, this.dateUtc,}); +} + + +/////////////////////////////// +// Covid19ManualTest + +class Covid19ManualTest { + final String provider; + final String providerId; + final String location; + final String locationId; + final String countyId; + final String testType; + final String testResult; + final DateTime dateUtc; + final String image; + + Covid19ManualTest({this.provider, this.providerId, this.location, this.locationId, this.countyId, this.testType, this.testResult, this.dateUtc, this.image}); +} + +/////////////////////////////// +// HealthUser + +class HealthUser { + String uuid; + String publicKeyString; + PublicKey _publicKey; + bool consent; + bool exposureNotification; + bool repost; + String encryptedKey; + String encryptedBlob; + + HealthUser({this.uuid, this.publicKeyString, PublicKey publicKey, this.consent, this.exposureNotification, this.repost, this.encryptedKey, this.encryptedBlob}) { + _publicKey = publicKey; + } + + factory HealthUser.fromJson(Map json) { + return (json != null) ? HealthUser( + uuid: json['uuid'], + publicKeyString: json['public_key'], + consent: json['consent'], + exposureNotification: json['exposure_notification'], + repost: json['re_post'], + encryptedKey: json['encrypted_key'], + encryptedBlob: json['encrypted_blob'], + ) : null; + } + + Map toJson() { + return { + 'uuid': uuid, + 'public_key': publicKeyString, + 'consent': consent, + 'exposure_notification': exposureNotification, + 're_post': repost, + 'encrypted_key': encryptedKey, + 'encrypted_blob': encryptedBlob, + }; + } + + Future encryptBlob(HealthUserBlob blob, PublicKey publicKey) async { + Map encrypted = await compute(_encryptBlob, { + 'blob': AppJson.encode(blob?.toJson()), + 'publicKey': publicKey + }); + encryptedKey = encrypted['encryptedKey']; + encryptedBlob = encrypted['encryptedBlob']; + } + + + factory HealthUser.fromUser(HealthUser user) { + return (user != null) ? HealthUser( + uuid: user.uuid, + publicKeyString: user.publicKeyString, + publicKey: user.publicKey, + consent: user.consent, + exposureNotification: user.exposureNotification, + repost: user.repost, + encryptedKey: user.encryptedKey, + encryptedBlob: user.encryptedBlob, + ) : null; + } + + PublicKey get publicKey { + if ((_publicKey == null) && (publicKeyString != null)) { + _publicKey = RsaKeyHelper.parsePublicKeyFromPem(publicKeyString); + } + return _publicKey; + } + + set publicKey(PublicKey value) { + _publicKey = value; + publicKeyString = (value != null) ? RsaKeyHelper.encodePublicKeyToPemPKCS1(value) : null; + } + + bool operator ==(o) => + o is HealthUser && + o.uuid == uuid && + o.publicKeyString == publicKeyString && + o.consent == consent && + o.exposureNotification == exposureNotification && + o.repost == repost && + o.encryptedKey == encryptedKey && + o.encryptedBlob == encryptedBlob; + + int get hashCode => + (uuid?.hashCode ?? 0) ^ + (publicKeyString?.hashCode ?? 0) ^ + (consent?.hashCode ?? 0) ^ + (exposureNotification?.hashCode ?? 0) ^ + (repost?.hashCode ?? 0) ^ + (encryptedKey?.hashCode ?? 0) ^ + (encryptedBlob?.hashCode ?? 0); +} + +/////////////////////////////// +// HealthUserBlob + +class HealthUserBlob { + String info; + + HealthUserBlob({this.info}); + + factory HealthUserBlob.fromJson(Map json) { + return (json != null) ? HealthUserBlob( + info: json['info'], + ) : null; + } + + Map toJson() { + return { + 'info': info + }; + } +} + +/////////////////////////////// +// HealthServiceProvider + +class HealthServiceProvider { + String id; + String name; + bool allowManualTest; + List availableMechanisms; + + HealthServiceProvider({this.id, this.name, this.allowManualTest, this.availableMechanisms}); + + factory HealthServiceProvider.fromJson(Map json) { + return (json != null) ? HealthServiceProvider( + id: json['id'], + name: json['provider_name'], + allowManualTest: json['manual_test'], + availableMechanisms: healthServiceMechanismListFromJson(json["available_mechanisms"]), + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'provider_name': name, + 'manual_test': allowManualTest, + "available_mechanisms" : healthServiceMechanismListToJson(availableMechanisms) + }; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthServiceProvider value; + try { value = HealthServiceProvider.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthServiceProvider value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +//////////////////////////////// +//HealthServiceMechanism + +enum HealthServiceMechanism{epic, mcKinley, none} + +HealthServiceMechanism healthServiceMechanismFromString(String value) { + if (value != null) { + if (value == 'Epic') { + return HealthServiceMechanism.epic; + } + else if (value == 'McKinley') { + return HealthServiceMechanism.mcKinley; + } + else if (value == 'McKinley') { + return HealthServiceMechanism.mcKinley; + } + } + return null; +} + +String healthServiceMechanismToString(HealthServiceMechanism value) { + if (value != null) { + if (value == HealthServiceMechanism.epic) { + return 'Epic'; + } + else if (value == HealthServiceMechanism.mcKinley) { + return 'McKinley'; + } + else if (value == HealthServiceMechanism.mcKinley) { + return 'McKinley'; + } + } + return null; +} + +List healthServiceMechanismListFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthServiceMechanism value; + try { value = healthServiceMechanismFromString((entry as String)); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; +} + +List healthServiceMechanismListToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthServiceMechanism value in values) { + json.add(healthServiceMechanismToString(value)); + } + } + return json; +} + + + +/////////////////////////////// +// HealthServiceLocation + +class HealthServiceLocation { + String id; + String name; + String contact; + String city; + String address1; + String address2; + String state; + String country; + String zip; + String url; + String notes; + double latitude; + double longitude; + List availableTests; + List daysOfOperation; + HealthServiceLocation({this.id, this.name, this.availableTests, this.contact, this.city, this.address1, this.address2, this.state, this.country, this.zip, this.url, this.notes, this.latitude, this.longitude, this.daysOfOperation}); + + factory HealthServiceLocation.fromJson(Map json) { + List jsoTests = json['available_tests']; + List jsonDaysOfOperation = json['days_of_operation']; + return (json != null) ? HealthServiceLocation( + id: json['id'], + name: json['name'], + contact: json["contact"], + city: json["city"], + state: json["state"], + country: json["country"], + address1: json["address_1"], + address2: json["address_2"], + zip: json["zip"], + url: json["url"], + notes: json["notes"], + latitude: AppJson.doubleValue(json["latitude"]), + longitude: AppJson.doubleValue(json["longitude"]), + availableTests: jsoTests!=null ? List.from(jsoTests) : null, + daysOfOperation: jsonDaysOfOperation!=null ? HealthLocationDayOfOperation.listFromJson(jsonDaysOfOperation) : null, + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'contact': contact, + 'city': city, + 'state': state, + 'country': country, + 'address_1': address1, + 'address_2': address2, + 'zip': zip, + 'url': url, + 'notes': notes, + 'latitude': latitude, + 'longitude': longitude, + 'available_tests': availableTests, + }; + } + + String get fullAddress{ + String address = ""; + address = address1?? ""; + if(address2?.isNotEmpty?? false) { + address += address.isNotEmpty ? ", " : ""; + address += address2; + } + if(city?.isNotEmpty?? false) { + address += address.isNotEmpty ? ", " : ""; + address += city; + } + if(state?.isNotEmpty?? false) { + address += address.isNotEmpty ? ", " : ""; + address += state; + } + return address; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthServiceLocation value; + try { value = HealthServiceLocation.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthServiceLocation value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthLocationDayOfOperation + +class HealthLocationDayOfOperation { + String name; + String openTime; + String closeTime; + + HealthLocationDayOfOperation({this.name, this.openTime, this.closeTime}); + + factory HealthLocationDayOfOperation.fromJson(Map json){ + return HealthLocationDayOfOperation( + name: json["name"], + openTime: json["open_time"], + closeTime: json["close_time"], + ); + } + + String get displayString{ + return "$name $openTime to $closeTime"; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthLocationDayOfOperation value; + try { value = HealthLocationDayOfOperation.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } +} + +/////////////////////////////// +// HealthTestType + +class HealthTestType { + String id; + String name; + List results; + + HealthTestType({this.id, this.name, this.results}); + + factory HealthTestType.fromJson(Map json) { + return (json != null) ? HealthTestType( + id: json['id'], + name: json['name'], + results: HealthTestTypeResult.listFromJson(json['results']), + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'results': HealthTestTypeResult.listToJson(results), + }; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthTestType value; + try { value = HealthTestType.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthTestType value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthTestRuleResult + +class HealthTestTypeResult { + String id; + String name; + String nextStep; + int nextStepOffset; + int nextStepExpiresOffset; + + HealthTestTypeResult({this.id, this.name, this.nextStep, this.nextStepOffset,this.nextStepExpiresOffset}); + + factory HealthTestTypeResult.fromJson(Map json) { + return (json != null) ? HealthTestTypeResult( + id: json['id'], + name: json['name'], + nextStep: json['next_step'], + nextStepOffset: json['next_step_offset'], + nextStepExpiresOffset: json['result_expires_offset'] + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'next_step': nextStep, + 'next_step_offset': nextStepOffset, + 'result_expires_offset': nextStepExpiresOffset, + }; + } + + DateTime nextStepDate(DateTime testDate) { + return ((testDate != null) && (nextStepOffset != null)) ? + testDate.add(Duration(hours: nextStepOffset)) : null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthTestTypeResult value; + try { value = HealthTestTypeResult.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthTestTypeResult value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthCounty + +class HealthCounty { + String id; + String name; + String nameDisplayText; + String state; + String country; + List guidelines; + + HealthCounty({this.id, this.name, this.nameDisplayText, this.state, this.country,this.guidelines}); + + factory HealthCounty.fromJson(Map json) { + String name = json['name']; + String state = json['state_province']; + String nameDisplayText = AppString.isStringNotEmpty(state) ? "$name, $state" : name; + return (json != null) ? HealthCounty( + id: json['id'], + name: name, + nameDisplayText: nameDisplayText, + state: state, + country: json['country'], + guidelines: HealthGuideline.fromJsonList(json['guidelines']), + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'state': state, + 'country': country, + 'guidelines': HealthGuideline.listToJson(guidelines), + }; + } + + static HealthCounty defaultCounty(Iterable counties) { + if ((counties != null) && (0 < counties.length)) { + for (HealthCounty county in counties) { + if (county.name == 'Champaign') { + return county; + } + } + return counties.first; + } + return null; + } + + static LinkedHashMap listToMap(List counties) { + LinkedHashMap countiesMap; + if (counties != null) { + countiesMap = LinkedHashMap(); + for (HealthCounty county in counties) { + countiesMap[county.id] = county; + } + } + return countiesMap; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthCounty value; + try { value = HealthCounty.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthCounty value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + + +/////////////////////////////// +// HealthGuideline + +class HealthGuideline { + String id; + String name; + List items; + + HealthGuideline({this.id,this.name,this.items}); + + factory HealthGuideline.fromJson(Map json) { + if (json == null) { + return null; + } + return HealthGuideline( + id: json['id'], + name: json['name'], + items: HealthGuidelineItem.fromJsonList(json["items"]) + ); + } + + static List fromJsonList(List jsonList) { + List sections; + if (jsonList != null) { + sections = List(); + for (dynamic jsonEntry in jsonList) { + sections.add(HealthGuideline.fromJson(jsonEntry)); + } + } + return sections; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthGuideline value in values) { + json.add(value?.toJson()); + } + } + return json; + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'type': HealthGuidelineItem.listToJson(items), + }; + } +} + +/////////////////////////////// +// HealthGuidelineItem + +class HealthGuidelineItem { + String icon; + String description; + String type; + + HealthGuidelineItem({this.icon,this.description,this.type}); + + factory HealthGuidelineItem.fromJson(Map json) { + if (json == null) { + return null; + } + return HealthGuidelineItem( + icon: json['icon'], + description: json['description'], + type: json['type'], + ); + } + + static List fromJsonList(List jsonList) { + List guidelineItems; + if (jsonList != null) { + guidelineItems = List(); + for (dynamic jsonEntry in jsonList) { + guidelineItems.add(HealthGuidelineItem.fromJson(jsonEntry)); + } + } + return guidelineItems; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthGuidelineItem value in values) { + json.add(value?.toJson()); + } + } + return json; + } + + Map toJson() { + return { + 'icon': icon, + 'description': description, + 'type': type, + }; + } +} + +/////////////////////////////// +// HealthTestRule + +class HealthTestRule { + String testTypeId; + String testType; + List results; + + HealthTestRule({this.testTypeId, this.testType, this.results}); + + factory HealthTestRule.fromJson(Map json) { + return (json != null) ? HealthTestRule( + testTypeId: json['test_type_id'], + testType: json['test_type'], + results: HealthTestRuleResult.listFromJson(json['results']), + ) : null; + } + + Map toJson() { + return { + 'test_type_id': testTypeId, + 'test_type': testType, + 'results': HealthTestRuleResult.listToJson(results), + }; + } + + static HealthTestRuleResult matchResult(List rules, {String testType, String testResult}) { + if (rules != null) { + for (HealthTestRule rule in rules) { + if ((rule?.testType != null) && (rule.testType.toLowerCase() == testType?.toLowerCase()) && (rule?.results != null)) { + for (HealthTestRuleResult ruleResult in rule.results) { + if ((ruleResult?.testResult != null) && (ruleResult.testResult.toLowerCase() == testResult?.toLowerCase())) { + return ruleResult; + } + } + } + } + } + return null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthTestRule value; + try { value = HealthTestRule.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthTestRule value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthTestRuleResult + +class HealthTestRuleResult { + String testResult; + String healthStatus; + String nextStep; + int nextStepTimeInterval; + + HealthTestRuleResult({this.testResult, this.healthStatus, this.nextStep, this.nextStepTimeInterval}); + + factory HealthTestRuleResult.fromJson(Map json) { + return (json != null) ? HealthTestRuleResult( + testResult: json['result'], + healthStatus: json['health_status'], + nextStep: json['result_next_step'], + nextStepTimeInterval: json['result_next_step_time_interval'] + ) : null; + } + + Map toJson() { + return { + 'result': testResult, + 'health_status': healthStatus, + 'result_next_step': nextStep, + 'result_next_step_time_interval': nextStepTimeInterval, + }; + } + + DateTime nextStepDate(DateTime testDate) { + return ((testDate != null) && (nextStepTimeInterval != null)) ? + testDate.add(Duration(hours: nextStepTimeInterval)) : null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthTestRuleResult value; + try { value = HealthTestRuleResult.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthTestRuleResult value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthSymptom + +class HealthSymptom { + final String id; + final String name; + + HealthSymptom({this.id, this.name}); + + factory HealthSymptom.fromJson(Map json) { + return (json != null) ? HealthSymptom( + id: json['id'], + name: json['name'], + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'name': name, + }; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthSymptom value; + try { value = HealthSymptom.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthSymptom value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthSymptomsGroup + +class HealthSymptomsGroup { + final String id; + final String name; + final List symptoms; + + HealthSymptomsGroup({this.id, this.name, this.symptoms}); + + factory HealthSymptomsGroup.fromJson(Map json) { + return (json != null) ? HealthSymptomsGroup( + id: json['id'], + name: json['name'], + symptoms: HealthSymptom.listFromJson(json['symptoms']), + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'symptoms': HealthSymptom.listToJson(symptoms), + }; + } + + static Map getCounts(List groups, Set selected) { + Map counts = Map(); + if ((groups != null) && (selected != null)) { + for (HealthSymptomsGroup group in groups) { + int count = 0; + if (group.symptoms != null) { + for (HealthSymptom symptom in group.symptoms) { + if (selected.contains(symptom.id)) { + count++; + } + } + } + counts[group.name] = count; + } + } + return counts; + } + + static List getSymptoms(List groups, Set selected) { + List symptoms = List(); + if ((groups != null) && (selected != null)) { + for (HealthSymptomsGroup group in groups) { + if ((group.symptoms != null)) { + for (HealthSymptom symptom in group.symptoms) { + if (selected.contains(symptom.id)) { + symptoms.add(symptom); + } + } + } + } + } + return symptoms; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthSymptomsGroup value; + try { value = HealthSymptomsGroup.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthSymptomsGroup value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthSymptomsRule + +class HealthSymptomsRule { + String id; + int group1Count; + int group2Count; + List results; + + HealthSymptomsRule({this.id, this.group1Count, this.group2Count, this.results}); + + factory HealthSymptomsRule.fromJson(Map json) { + return (json != null) ? HealthSymptomsRule( + id: json['id'], + group1Count: json['gr1_count'], + group2Count: json['gr2_count'], + results: HealthSymptomsRuleResult.listFromJson(json['items']), + ) : null; + } + + Map toJson() { + return { + 'id': id, + 'gr1_count': group1Count, + 'gr2_count': group2Count, + 'items': HealthSymptomsRuleResult.listToJson(results), + }; + } + + HealthSymptomsRuleResult matchResult(Map counts) { + if (counts != null) { + int gr1Count = counts['gr1'] ?? 0; + bool gr1Fulfilled = (gr1Count >= group1Count); + + int gr2Count = counts['gr2'] ?? 0; + bool gr2Fulfilled = (gr2Count >= group2Count); + + if (results != null) { + for (HealthSymptomsRuleResult result in results) { + if ((result.group1 == gr1Fulfilled) && (result.group2 == gr2Fulfilled)) { + return result; + } + } + } + } + return null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthSymptomsRule value; + try { value = HealthSymptomsRule.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthSymptomsRule value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthSymptomsRuleResult + +class HealthSymptomsRuleResult { + bool group1, group2; + String healthStatus; + String nextStep; + + HealthSymptomsRuleResult({this.group1, this.group2, this.healthStatus, this.nextStep}); + + factory HealthSymptomsRuleResult.fromJson(Map json) { + return (json != null) ? HealthSymptomsRuleResult( + group1: json['gr1'], + group2: json['gr2'], + healthStatus: json['health_status'], + nextStep: json['next_step'], + ) : null; + } + + Map toJson() { + return { + 'group1': group1, + 'group2': group2, + 'health_status': healthStatus, + 'next_step': nextStep, + }; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthSymptomsRuleResult value; + try { value = HealthSymptomsRuleResult.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthSymptomsRuleResult value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthContactTraceRule + +class HealthContactTraceRule { + int timeout; + List results; + + HealthContactTraceRule({this.timeout, this.results}); + + factory HealthContactTraceRule.fromJson(Map json) { + return (json != null) ? HealthContactTraceRule( + timeout: json['timeout'], + results: HealthContactTraceRuleResult.listFromJson(json['items']), + ) : null; + } + + Map toJson() { + return { + 'timeout': timeout, + 'items': HealthContactTraceRuleResult.listToJson(results), + }; + } + + HealthContactTraceRuleResult _matchResult({int traceTimeout, int traceDuration}) { + + if (((timeout == null) || (traceTimeout <= timeout)) && (results != null)) { + for (HealthContactTraceRuleResult result in results) { + if (result.matches(traceDuration: traceDuration)) { + return result; + } + } + } + + return null; + } + + static HealthContactTraceRuleResult matchResult(List rules, {DateTime traceDate, int traceDuration}) { + if ((rules != null) && (traceDate != null) && (traceDuration != null)) { + int traceTimeout = DateTime.now().toUtc().difference(traceDate).inHours; + for (HealthContactTraceRule rule in rules) { + HealthContactTraceRuleResult result = rule._matchResult(traceTimeout: traceTimeout, traceDuration: traceDuration); + if (result != null) { + return result; + } + } + } + return null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthContactTraceRule value; + try { value = HealthContactTraceRule.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthContactTraceRule value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// HealthContactTraceRuleResult + +class HealthContactTraceRuleResult { + int duration; + String healthStatus; + String nextStep; + + HealthContactTraceRuleResult({this.duration, this.healthStatus, this.nextStep}); + + factory HealthContactTraceRuleResult.fromJson(Map json) { + return (json != null) ? HealthContactTraceRuleResult( + duration: json['duration'], + healthStatus: json['health_status'], + nextStep: json['next_step'], + ) : null; + } + + Map toJson() { + return { + 'duration': duration, + 'health_status': healthStatus, + 'next_step': nextStep, + }; + } + + bool matches({int traceDuration}) { + return ((duration == null) || (traceDuration >= duration)); + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + HealthContactTraceRuleResult value; + try { value = HealthContactTraceRuleResult.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + values.add(value); + } + } + return values; + } + + static List listToJson(List values) { + List json; + if (values != null) { + json = []; + for (HealthContactTraceRuleResult value in values) { + json.add(value?.toJson()); + } + } + return json; + } +} + +/////////////////////////////// +// Health DateTime + +// AppDateTime.covid19ServerDateFormat + +final List _covid19ServerDateFormatsIn = [ + "yyyy-MM-ddTHH:mm:ss.SSSZ", + "yyyy-MM-ddTHH:mm:ss.SSZ", + "yyyy-MM-ddTHH:mm:ss.SZ", + "yyyy-MM-ddTHH:mm:ssZ", +]; +final String _covid19ServerDateFormatOut = "yyyy-MM-ddTHH:mm:ss.SSS"; + +DateTime healthDateTimeFromString(String dateTimeString) { + if (dateTimeString != null) { + for (String dateFormat in _covid19ServerDateFormatsIn) { + if (dateTimeString.length == dateFormat.length) { + try { return DateFormat(dateFormat).parse(dateTimeString, true); } + catch (e) { print(e?.toString()); } + } + } + } + return null; +} + +String healthDateTimeToString(DateTime dateTime) { + if (dateTime != null) { + try { return DateFormat(_covid19ServerDateFormatOut).format(dateTime) + 'Z'; } + catch (e) { print(e?.toString()); } + } + return null; +} + +/////////////////////////////// +// HealthOSFAuth + +class HealthOSFAuth{ + final String accessToken; + final String tokenType; + final int expiresIn; + final String scope; + final String patient; + + HealthOSFAuth({this.accessToken, this.tokenType, this.expiresIn, this.scope, this.patient}); + + factory HealthOSFAuth.fromJson(Mapjson){ + return HealthOSFAuth( + accessToken: json["access_token"], + tokenType: json["token_type"], + expiresIn: json["expires_in"], + scope: json["scope"], + patient: json["patient"], + ); + } + + toJson(){ + return { + "access_token": accessToken, + "token_type": tokenType, + "expires_in": expiresIn, + "scope": scope, + "patient": patient, + }; + } +} + +/////////////////////////////// +// Blob Encryption & Decryption + +String _decryptBlob(Map param) { + String encKey = (param != null) ? param['encryptedKey'] : null; + String encBlob = (param != null) ? param['encryptedBlob'] : null; + PrivateKey privateKey = (param != null) ? param['privateKey'] : null; + + String aesKey = ((privateKey != null) && (encKey != null)) ? RSACrypt.decrypt(encKey, privateKey) : null; + String blob = ((aesKey != null) && (encBlob != null)) ? AESCrypt.decrypt(encBlob, aesKey) : null; + return blob; +} + +Map _encryptBlob(Map param) { + String blob = (param != null) ? param['blob'] : null; + PublicKey publicKey = (param != null) ? param['publicKey'] : null; + String aesKey = AESCrypt.randomKey(); + + String encryptedBlob = ((blob != null) && (aesKey != null)) ? AESCrypt.encrypt(blob, aesKey) : null; + String encryptedKey = ((blob != null) && (aesKey != null) && (publicKey != null)) ? RSACrypt.encrypt(aesKey, publicKey) : null; + + return { + 'encryptedKey': encryptedKey, + 'encryptedBlob': encryptedBlob, + }; +} + + + diff --git a/lib/model/Health2.dart b/lib/model/Health2.dart new file mode 100644 index 00000000..fa4d51cb --- /dev/null +++ b/lib/model/Health2.dart @@ -0,0 +1,666 @@ + +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/AppDateTime.dart'; + +/////////////////////////////// +// HealthRulesSet2 + +class HealthRulesSet2 { + final HealthTestRulesSet2 tests; + final HealthSymptomsRulesSet2 symptoms; + final HealthContactTraceRulesSet2 contactTrace; + final HealthActionRulesSet2 actions; + final HealthDefaultsSet2 defaults; + + HealthRulesSet2({this.tests, this.symptoms, this.contactTrace, this.actions, this.defaults}); + + factory HealthRulesSet2.fromJson(Map json) { + return (json != null) ? HealthRulesSet2( + tests: HealthTestRulesSet2.fromJson(json['tests']), + symptoms: HealthSymptomsRulesSet2.fromJson(json['symptoms']), + contactTrace: HealthContactTraceRulesSet2.fromJson(json['contact_trace']), + actions: HealthActionRulesSet2.fromJson(json['actions']), + defaults: HealthDefaultsSet2.fromJson(json['defaults']), + ) : null; + } +} + +/////////////////////////////// +// HealthDefaultsSet2 + +class HealthDefaultsSet2 { + final _HealthRuleStatus2 status; + + HealthDefaultsSet2({this.status}); + + factory HealthDefaultsSet2.fromJson(Map json) { + return (json != null) ? HealthDefaultsSet2( + status: _HealthRuleStatus2.fromJson(json['status']), + ) : null; + } +} + + + +/////////////////////////////// +// HealthTestRulesSet2 + +class HealthTestRulesSet2 { + final List rules; + final Map statuses; + + HealthTestRulesSet2({this.rules, this.statuses}); + + factory HealthTestRulesSet2.fromJson(Map json) { + return (json != null) ? HealthTestRulesSet2( + rules: HealthTestRule2.listFromJson(json['rules']), + statuses: _HealthRuleStatus2.mapFromJson(json['statuses']), + ) : null; + } + + HealthTestRuleResult2 matchRuleResult({ Covid19HistoryBlob blob }) { + if ((rules != null) && (blob != null)) { + for (HealthTestRule2 rule in rules) { + if ((rule?.testType != null) && (rule?.testType?.toLowerCase() == blob?.testType?.toLowerCase()) && (rule.results != null)) { + for (HealthTestRuleResult2 ruleResult in rule.results) { + if ((ruleResult?.testResult != null) && (ruleResult.testResult.toLowerCase() == blob?.testResult?.toLowerCase())) { + return ruleResult; + } + } + } + } + } + return null; + } +} + + +/////////////////////////////// +// HealthTestRule2 + +class HealthTestRule2 { + final String testType; + final String category; + final List results; + + HealthTestRule2({this.testType, this.category, this.results}); + + factory HealthTestRule2.fromJson(Map json) { + return (json != null) ? HealthTestRule2( + testType: json['test_type'], + category: json['category'], + results: HealthTestRuleResult2.listFromJson(json['results']), + ) : null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + try { values.add(HealthTestRule2.fromJson((entry as Map)?.cast())); } + catch(e) { print(e?.toString()); } + } + } + return values; + } +} + +/////////////////////////////// +// HealthTestRuleResult2 + +class HealthTestRuleResult2 { + final String testResult; + final String category; + final _HealthRuleStatus2 status; + + HealthTestRuleResult2({this.testResult, this.category, this.status}); + + factory HealthTestRuleResult2.fromJson(Map json) { + return (json != null) ? HealthTestRuleResult2( + testResult: json['result'], + category: json['category'], + status: _HealthRuleStatus2.fromJson(json['status']), + ) : null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + try { values.add(HealthTestRuleResult2.fromJson((entry as Map)?.cast())); } + catch(e) { print(e?.toString()); } + } + } + return values; + } + + static HealthTestRuleResult2 matchRuleResult(List results, { Covid19HistoryBlob blob }) { + if (results != null) { + for (HealthTestRuleResult2 result in results) { + if (result._matchBlob(blob)) { + return result; + } + } + } + return null; + } + + bool _matchBlob(Covid19HistoryBlob blob) { + return ((testResult != null) && (testResult.toLowerCase() == blob?.testResult?.toLowerCase())); + } +} + +/////////////////////////////// +// HealthSymptomsRulesSet2 + +class HealthSymptomsRulesSet2 { + final List rules; + final List groups; + + HealthSymptomsRulesSet2({this.rules, this.groups}); + + factory HealthSymptomsRulesSet2.fromJson(Map json) { + return (json != null) ? HealthSymptomsRulesSet2( + rules: HealthSymptomsRule2.listFromJson(json['rules']), + groups: HealthSymptomsGroup.listFromJson(json['groups']), + ) : null; + } + + HealthSymptomsRule2 matchRule({ Covid19HistoryBlob blob }) { + if ((rules != null) && (groups != null) && (blob?.symptomsIds != null)) { + Map counts = HealthSymptomsGroup.getCounts(groups, blob.symptomsIds); + for (HealthSymptomsRule2 rule in rules) { + if (rule._matchCounts(counts)) { + return rule; + } + } + } + return null; + } +} + +/////////////////////////////// +// HealthSymptomsRule2 + +class HealthSymptomsRule2 { + final Map counts; + final _HealthRuleStatus2 status; + + HealthSymptomsRule2({this.counts, this.status}); + + factory HealthSymptomsRule2.fromJson(Map json) { + return (json != null) ? HealthSymptomsRule2( + counts: _countsFromJson(json['counts']), + status: _HealthRuleStatus2.fromJson(json['status']), + ) : null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + try { values.add(HealthSymptomsRule2.fromJson((entry as Map)?.cast())); } + catch(e) { print(e?.toString()); } + } + } + return values; + } + + static Map _countsFromJson(Map json) { + Map values; + if (json != null) { + values = Map(); + json.forEach((key, value) { + values[key] = _HealthRuleIntValue2.fromJson(value); + }); + } + return values; + } + + bool _matchCounts(Map testCounts) { + if (this.counts != null) { + for (String groupName in this.counts.keys) { + _HealthRuleIntValue2 value = this.counts[groupName]; + int count = (testCounts != null) ? testCounts[groupName] : null; + if (!value.match(count)) { + return false; + } + + } + } + return true; + } +} + +/////////////////////////////// +// HealthContactTraceRulesSet2 + +class HealthContactTraceRulesSet2 { + final List rules; + + HealthContactTraceRulesSet2({this.rules}); + + factory HealthContactTraceRulesSet2.fromJson(Map json) { + return (json != null) ? HealthContactTraceRulesSet2( + rules: HealthContactTraceRule2.listFromJson(json['rules']), + ) : null; + } + + HealthContactTraceRule2 matchRule({ Covid19HistoryBlob blob }) { + if ((rules != null) && (blob != null)) { + for (HealthContactTraceRule2 rule in rules) { + if (rule._matchBlob(blob)) { + return rule; + } + } + } + return null; + } +} + +/////////////////////////////// +// HealthContactTraceRule2 + +class HealthContactTraceRule2 { + final _HealthRuleIntValue2 duration; + final _HealthRuleStatus2 status; + + HealthContactTraceRule2({this.duration, this.status}); + + factory HealthContactTraceRule2.fromJson(Map json) { + return (json != null) ? HealthContactTraceRule2( + duration: _HealthRuleIntValue2.fromJson(json['duration']), + status: _HealthRuleStatus2.fromJson(json['status']), + ) : null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + try { values.add(HealthContactTraceRule2.fromJson((entry as Map)?.cast())); } + catch(e) { print(e?.toString()); } + } + } + return values; + } + + bool _matchBlob(Covid19HistoryBlob blob) { + return (duration != null) && duration.match(blob?.traceDurationInMinutes); + } +} + +/////////////////////////////// +// HealthActionRulesSet2 + +class HealthActionRulesSet2 { + final List rules; + + HealthActionRulesSet2({this.rules}); + + factory HealthActionRulesSet2.fromJson(Map json) { + return (json != null) ? HealthActionRulesSet2( + rules: HealthActionRule2.listFromJson(json['rules']), + ) : null; + } + + HealthActionRule2 matchRule({ Covid19HistoryBlob blob }) { + if (rules != null) { + for (HealthActionRule2 rule in rules) { + if (rule._matchBlob(blob)) { + return rule; + } + } + } + return null; + } +} + +/////////////////////////////// +// HealthActionRule2 + +class HealthActionRule2 { + final String type; + final _HealthRuleStatus2 status; + + HealthActionRule2({this.type, this.status}); + + factory HealthActionRule2.fromJson(Map json) { + return (json != null) ? HealthActionRule2( + type: json['type'], + status: _HealthRuleStatus2.fromJson(json['status']), + ) : null; + } + + static List listFromJson(List json) { + List values; + if (json != null) { + values = []; + for (dynamic entry in json) { + try { values.add(HealthActionRule2.fromJson((entry as Map)?.cast())); } + catch(e) { print(e?.toString()); } + } + } + return values; + } + + bool _matchBlob(Covid19HistoryBlob blob) { + return (type != null) && (type.toLowerCase() == blob?.actionType?.toLowerCase()); + } +} + +/////////////////////////////// +// _HealthRuleIntValue2 + +abstract class _HealthRuleIntValue2 { + _HealthRuleIntValue2(); + + factory _HealthRuleIntValue2.fromJson(dynamic json) { + if (json != null) { + if (json is int) { + return HealthRuleIntValue2.fromJson(json); + } + else if (json is Map) { + return HealthRuleIntInterval2.fromJson(json.cast()); + } + } + return null; + } + + bool match(int value); + int get min; + int get max; +} + +/////////////////////////////// +// HealthRuleIntValue2 + +class HealthRuleIntValue2 extends _HealthRuleIntValue2 { + final int value; + + HealthRuleIntValue2({this.value}); + + factory HealthRuleIntValue2.fromJson(dynamic json) { + return (json is int) ? HealthRuleIntValue2(value: json) : null; + } + + bool match(int value) { + return (this.value == value); + } + + int get min { return value; } + int get max { return value; } +} + +/////////////////////////////// +// HealthRuleIntInterval2 + +class HealthRuleIntInterval2 extends _HealthRuleIntValue2 { + final int min; + final int max; + + HealthRuleIntInterval2({this.min, this.max}); + + factory HealthRuleIntInterval2.fromJson(Map json) { + return (json != null) ? HealthRuleIntInterval2( + min: json['min'], + max: json['max'], + ) : null; + } + + bool match(int value) { + return (value != null) && + ((min == null) || (min <= value)) && + ((max == null) || (max >= value)); + } +} + +/////////////////////////////// +// _HealthRuleStatus2 + +abstract class _HealthRuleStatus2 { + + _HealthRuleStatus2(); + + factory _HealthRuleStatus2.fromJson(dynamic json) { + if (json is Map) { + if (json['condition'] != null) { + try { return HealthTestRuleConditionalStatus2.fromJson(json.cast()); } + catch (e) { print(e?.toString()); } + } + else { + try { return HealthRuleStatus2.fromJson(json.cast()); } + catch (e) { print(e?.toString()); } + } + } + else if (json is String) { + return HealthRuleReferenceStatus2.fromJson(json); + } + return null; + } + + static Map mapFromJson(Map json) { + Map result; + if (json != null) { + result = Map(); + json.forEach((String key, dynamic value) { + try { result[key] = _HealthRuleStatus2.fromJson((value as Map).cast()); } + catch (e) { print(e?.toString()); } + }); + } + return result; + } + + HealthRuleStatus2 eval({ List history, int historyIndex, HealthRulesSet2 rules }); +} + +/////////////////////////////// +// HealthRuleStatus2 + +class HealthRuleStatus2 extends _HealthRuleStatus2 { + final String healthStatus; + final int priority; + + final String nextStep; + final int nextStepInterval; + + final String reason; + + HealthRuleStatus2({this.healthStatus, this.priority, this.nextStep, this.nextStepInterval, this.reason }); + + factory HealthRuleStatus2.fromJson(Map json) { + return (json != null) ? HealthRuleStatus2( + healthStatus: json['health_status'], + priority: json['priority'], + nextStep: json['next_step'], + nextStepInterval: json['next_step_interval'], + reason: json['reason'], + ) : null; + } + + HealthRuleStatus2 eval({ List history, int historyIndex, HealthRulesSet2 rules }) { + return this; + } + + bool canUpdateStatus({Covid19StatusBlob blob}) { + int blobStatusWeight = covid19HealthStatusWeight(blob?.healthStatus); + int newStatusWeight = (this.healthStatus != null) ? covid19HealthStatusWeight(this.healthStatus) : blobStatusWeight; + if (blobStatusWeight < newStatusWeight) { + // status downgrade + return true; + } + else { + // status upgrade or preserve + int blobStatusPriority = blob?.priority ?? 0; + int newStatusPriority = this.priority ?? 0; + return (newStatusPriority < 0) || (blobStatusPriority <= newStatusPriority); + } + } + + DateTime nextStepDateUtc(DateTime startDateUtc) { + return ((startDateUtc != null) && (nextStepInterval != null)) ? + startDateUtc.add(Duration(days: nextStepInterval)) : null; + } +} + +/////////////////////////////// +// HealthRuleRefrenceStatus2 + +class HealthRuleReferenceStatus2 extends _HealthRuleStatus2 { + final String reference; + HealthRuleReferenceStatus2({this.reference}); + + factory HealthRuleReferenceStatus2.fromJson(String json) { + return (json != null) ? HealthRuleReferenceStatus2( + reference: json, + ) : null; + } + + HealthRuleStatus2 eval({ List history, int historyIndex, HealthRulesSet2 rules }) { + // Only test rules currently use reference status. + _HealthRuleStatus2 status = rules?.tests?.statuses[reference]; + return status?.eval(history: history, historyIndex: historyIndex, rules: rules); + } +} + +/////////////////////////////// +// HealthRuleConditionalStatus2 + +class HealthTestRuleConditionalStatus2 extends _HealthRuleStatus2 { + final String condition; + final Map params; + final _HealthRuleStatus2 successStatus; + final _HealthRuleStatus2 failStatus; + + HealthTestRuleConditionalStatus2({this.condition, this.params, this.successStatus, this.failStatus}); + + factory HealthTestRuleConditionalStatus2.fromJson(Map json) { + return (json != null) ? HealthTestRuleConditionalStatus2( + condition: json['condition'], + params: json['params'], + successStatus: _HealthRuleStatus2.fromJson(json['success']) , + failStatus: _HealthRuleStatus2.fromJson(json['fail']), + ) : null; + } + + HealthRuleStatus2 eval({ List history, int historyIndex, HealthRulesSet2 rules }) { + _HealthRuleStatus2 result; + if (condition == 'require-test') { + result = _evalRequireTest(history: history, historyIndex: historyIndex, rules: rules); + } + else if (condition == 'require-symptoms') { + result = _evalRequireSymptoms(history: history, historyIndex: historyIndex, rules: rules); + } + else if (condition == 'timeout') { + result = _evalTimeout(history: history, historyIndex: historyIndex, rules: rules); + } + return result?.eval(history: history, historyIndex: historyIndex, rules: rules); + } + + _HealthRuleStatus2 _evalRequireTest({ List history, int historyIndex, HealthRulesSet2 rules }) { + + Covid19History historyEntry = ((history != null) && (historyIndex != null) && (0 <= historyIndex) && (historyIndex < history.length)) ? history[historyIndex] : null; + DateTime historyDateMidnightLocal = historyEntry?.dateMidnightLocal; + if (historyDateMidnightLocal == null) { + return null; + } + + _HealthRuleIntValue2 interval = _HealthRuleIntValue2.fromJson(params['interval']); + if (interval == null) { + return null; + } + + dynamic category = params['category']; + if (category is List) { + category = Set.from(category); + } + for (int index = 0; index < history.length; index++) { + Covid19History entry = history[index]; + if ((index != historyIndex) && entry.isTest && entry.canTestUpdateStatus) { + DateTime entryDateMidnightLocal = entry.dateMidnightLocal; + final difference = entryDateMidnightLocal.difference(historyDateMidnightLocal).inDays; + if (interval.match(difference)) { + if (category == null) { + return successStatus; // any test matches + } + else { + HealthTestRuleResult2 entryRuleResult = rules?.tests?.matchRuleResult(blob: entry?.blob); + if ((entryRuleResult != null) && (entryRuleResult.category != null) && + (((category is String) && (category == entryRuleResult.category)) || + ((category is Set) && category.contains(entryRuleResult.category)))) + { + return successStatus; // only tests from given category matches + } + } + } + } + } + + // If positive time interval is not already expired - do not return failed status yet. + _HealthRuleIntValue2 currentInterval = _HealthRuleIntValue2.fromJson(params['current_interval']); + if (currentInterval != null) { + final difference = AppDateTime.todayMidnightLocal.difference(historyDateMidnightLocal).inDays; + if (currentInterval.match(difference)) { + return successStatus; + } + } + + return failStatus; + } + + _HealthRuleStatus2 _evalRequireSymptoms({ List history, int historyIndex, HealthRulesSet2 rules }) { + Covid19History historyEntry = ((history != null) && (historyIndex != null) && (0 <= historyIndex) && (historyIndex < history.length)) ? history[historyIndex] : null; + DateTime historyDateMidnightLocal = historyEntry?.dateMidnightLocal; + if (historyDateMidnightLocal == null) { + return null; + } + + _HealthRuleIntValue2 interval = _HealthRuleIntValue2.fromJson(params['interval']); + if (interval == null) { + return null; + } + + for (int index = 0; index < history.length; index++) { + Covid19History entry = history[index]; + if ((index != historyIndex) && entry.isSymptoms) { + DateTime entryDateMidnightLocal = entry.dateMidnightLocal; + final difference = entryDateMidnightLocal.difference(historyDateMidnightLocal).inDays; + if (interval.match(difference)) { + return successStatus; + } + } + } + + // If positive time interval is not already expired - do not return failed status yet. + _HealthRuleIntValue2 currentInterval = _HealthRuleIntValue2.fromJson(params['current_interval']); + if (currentInterval != null) { + final difference = AppDateTime.todayMidnightLocal.difference(historyDateMidnightLocal).inDays; + if (currentInterval.match(difference)) { + return successStatus; + } + } + + return failStatus; + } + + _HealthRuleStatus2 _evalTimeout({ List history, int historyIndex, HealthRulesSet2 rules }) { + Covid19History historyEntry = ((history != null) && (historyIndex != null) && (0 <= historyIndex) && (historyIndex < history.length)) ? history[historyIndex] : null; + DateTime historyDateMidnightLocal = historyEntry?.dateMidnightLocal; + if (historyDateMidnightLocal == null) { + return null; + } + + _HealthRuleIntValue2 interval = _HealthRuleIntValue2.fromJson(params['interval']); + if (interval == null) { + return null; + } + + final difference = AppDateTime.todayMidnightLocal.difference(historyDateMidnightLocal).inDays; + return interval.match(difference) ? failStatus : successStatus; // while current time is within interval 'timeout' condition fails + } +} + diff --git a/lib/model/PrivacyData.dart b/lib/model/PrivacyData.dart new file mode 100644 index 00000000..508d2664 --- /dev/null +++ b/lib/model/PrivacyData.dart @@ -0,0 +1,184 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:illinois/service/Assets.dart'; +import 'package:illinois/service/Localization.dart'; + +class PrivacyData{ + List levels; + List types; + List categories; + List features2; + + Map jsonData; + + PrivacyData({this.levels,this.types,this.categories,this.features2,this.jsonData}); + + factory PrivacyData.fromJson(Map json) { + List levelsJson = json['levels']; + List levels = (levelsJson != null) ? levelsJson.map(( + value) => PrivacyLevel.fromJson(value)) + .toList() : null; + + List typesJson = json['types']; + List types = (typesJson != null) ? typesJson.map(( + value) => PrivacyType.fromJson(value)) + .toList() : null; + + List categoriesJson = json['categories']; + List categories = (categoriesJson != null) ? categoriesJson.map(( + value) => PrivacyCategory.fromJson(value)) + .toList() : null; + + List features2Json = json['features2']; + List features2 = (features2Json != null) ? features2Json.map(( + value) => PrivacyFeature2.fromJson(value)) + .toList() : null; + + + return PrivacyData( + levels: levels, + types: types, + categories: categories, + features2: features2, + jsonData: json + ); + } + + reload() { + if (categories != null) { + List categoriesJson = jsonData['categories']; + categories = (categoriesJson != null) ? categoriesJson.map((value) => PrivacyCategory.fromJson(value)) + .toList() : null; + } + + if (types != null) { + List typesJson = jsonData['types']; + types = (typesJson != null) ? typesJson.map((value) => PrivacyType.fromJson(value)).toList() : null; + } + } + + //Util methods + String getLocalizedString(String text) { + return Localization().getStringFromMapping(text, (jsonData != null) ? jsonData['strings'] : Assets()['privacy.strings']); + } +} + +class PrivacyCategory{ + String title; + Map description; + List entries; + + PrivacyCategory({this.title,this.description,this.entries}); + + factory PrivacyCategory.fromJson(Map json) { + List entriesJson = json['entries']; + List entries = (entriesJson != null) ? entriesJson.map(( + value) => PrivacyEntry.fromJson(value)) + .toList() : null; + + return PrivacyCategory( + title:PrivacyData().getLocalizedString(json["title"]), + description:json['description'], + entries: entries + ); + } +} + +class PrivacyEntry{ + String key; + String text; + String type; + int minLevel; + + PrivacyEntry({this.key,this.text,this.type,this.minLevel}); + + factory PrivacyEntry.fromJson(Map json) { + return PrivacyEntry( + key:json["key"], + text: PrivacyData().getLocalizedString(json["text"]), + type:json["type"], + minLevel:json["min_level"] + ); + } +} + +class PrivacyLevel{ + int value; + String title; + + PrivacyLevel({this.value,this.title}); + + factory PrivacyLevel.fromJson(Map json) { + return PrivacyLevel( + value:json["value"], + title:PrivacyData().getLocalizedString(json["title"]) + ); + } +} + +class PrivacyType{ + String value; + String title; + + PrivacyType({this.value,this.title}); + + factory PrivacyType.fromJson(Map json) { + if(json!=null){ + return PrivacyType( + value:json["value"], + title:PrivacyData().getLocalizedString(json["title"]) + ); + } + return null; + } +} + +class PrivacyCategoryDescription{ + String type; + String text; + + PrivacyCategoryDescription({this.type,this.text}); + + factory PrivacyCategoryDescription.fromJson(Map json) { + if(json!=null){ + return PrivacyCategoryDescription( + type:json["type"], + text:PrivacyData().getLocalizedString(json["text"]), + ); + } + return null; + } +} + +class PrivacyFeature2{ + String key; + String text; + int maxLevel; + + PrivacyFeature2({this.key, this.text, this.maxLevel}); + + factory PrivacyFeature2.fromJson(Map json) { + if(json!=null){ + return PrivacyFeature2( + key:json["key"], + text:PrivacyData().getLocalizedString(json["text"]), + maxLevel:json["max_level"] + ); + } + return null; + } +} \ No newline at end of file diff --git a/lib/model/UserData.dart b/lib/model/UserData.dart new file mode 100644 index 00000000..a9bce8fd --- /dev/null +++ b/lib/model/UserData.dart @@ -0,0 +1,376 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/utils/Utils.dart'; + +class UserData { + + + final String uuid; + final bool overThirteen; + + static const String analyticsUuid = 'UUIDxxxxxx'; + static const int PrivacyLevel = 5; + + + Set roles; + Map> interests; + Map> favorites; + List positiveTags; + List negativeTags; + Set fcmTokens; + bool registeredVoter; + String votePlace; + bool voterByMail; + bool voted; + + UserData({this.uuid, this.roles, this.overThirteen, this.interests, this.favorites, this.positiveTags, this.negativeTags, this.fcmTokens, this.registeredVoter = false, this.votePlace, this.voterByMail, this.voted = false}); + + factory UserData.fromJson(Map json) { + return (json != null) ? UserData( + uuid: json['uuid'], + overThirteen: json['over13'], + roles: UserRole.userRolesFromList(json["roles"]), + interests: serializeInterests(json['interests']), + favorites: serializeFavorites(json['favorites']), + positiveTags: AppJson.castToStringList(json['positiveInterestTags']), + negativeTags: AppJson.castToStringList(json['negativeInterestTags']), + fcmTokens: (json['fcmTokens'] != null) ? Set.from(json['fcmTokens']) : null, + registeredVoter: json['registered_voter'] ?? false, + votePlace: json['vote_place'], + voterByMail: json['voter_by_mail'], + voted: json['voted'] ?? false + ) : null; + } + + toJson() { + return { + "uuid": uuid, + "over13": overThirteen, + "privacySettings": {"level": PrivacyLevel}, + "roles": roles != null ? roles.map((role) => role.toString()).toList() : null, + "interests": deserializeInterests(interests), + "positiveInterestTags": positiveTags, + "negativeInterestTags": negativeTags, + "favorites": deserializeFavorites(favorites), + "fcmTokens": (fcmTokens != null) ? List.from(fcmTokens) : null, + "registered_voter": registeredVoter, + "vote_place": votePlace, + "voter_by_mail": voterByMail, + "voted": voted + }; + } + + toShortJson() { + return { + "uuid": uuid, + "over13": overThirteen, + "privacySettings": {"level": PrivacyLevel}, + "roles": roles != null ? roles.map((role) => role.toString()).toList() : null, + "interests": deserializeInterests(interests), + "positiveInterestTags": positiveTags, + "negativeInterestTags": negativeTags, + "favorites": deserializeFavorites(favorites), + "registered_voter": registeredVoter, + "vote_place": votePlace, + "voter_by_mail": voterByMail, + "voted": voted + }; + } + + // Privacy + + int get privacyLevel { + return PrivacyLevel; + } + + // Interests + + //switch the whole category (no subCategories ) //Athletics/Recreation/Entertainment/etc + switchCategory(String categoryName){ + if(categoryName!=null){ + if(interests.containsKey(categoryName)){ + interests.remove(categoryName); + } else { + interests[categoryName] = new List(); //Empty list of subcategories represent that the whole category is selected + } + } + } + + //Only sports got sub categories for now + switchInterestSubCategory(String interestCategory, String subCategory){ + List subCategories = interests[interestCategory]; + if(subCategories==null){ + subCategories = new List(); + interests[interestCategory] = subCategories; + } + + //Switch + if(subCategories.contains(subCategory)){ + subCategories.remove(subCategory); + } else { + subCategories.add(subCategory); + } + } + + List getInterestSubCategories(String interestCategory){ + if(interests!=null && interests.isNotEmpty){ + return interests[interestCategory]; + } + + return null; + } + + //Interest Serialization + static Map> serializeInterests(List jsonData){ + Map> result = new Map(); + if(jsonData!=null && jsonData.isNotEmpty){ + jsonData.forEach((dynamic category){ + String categoryName = category["category"]; + List subCategories = category["subcategories"]; + result[categoryName] = AppJson.castToStringList(subCategories); + }); + } + return result; + } + + static List deserializeInterests(Map> interests){ + List result = new List(); + if(interests!=null){ + interests.forEach((categoryName, subCategories){ + result.add({"category": categoryName, "subcategories": subCategories}); + }); + } + + return result; + } + + //Favorites + void addFavorite(String favoriteType, String uuid){ + if(favoriteType==null || uuid==null) + return; + + if(favorites==null) + favorites = new Map(); + + Set typeValues = favorites[favoriteType]; + if(typeValues==null){ + typeValues = new Set(); + favorites[favoriteType] = typeValues; + } + typeValues.add(uuid); + } + + void addAllFavorites(String favoriteType, Set uiuds) { + if (AppString.isStringEmpty(favoriteType) || AppCollection.isCollectionEmpty(uiuds)) { + return; + } + if (favorites == null) { + favorites = Map(); + } + Set typeValues = favorites[favoriteType]; + if (typeValues == null) { + typeValues = Set(); + favorites[favoriteType] = typeValues; + } + typeValues.addAll(uiuds); + } + + void removeFavorite(String favoriteType, String uuid){ + if(favoriteType==null || uuid==null || favorites == null) + return; + + Set typeValues = favorites[favoriteType]; + if(typeValues==null) + return; + + typeValues.remove(uuid); + } + + void removeAllFavorites(String favoriteType, Set uiuds) { + if (AppString.isStringEmpty(favoriteType) || (favorites == null || favorites.isEmpty) || AppCollection.isCollectionEmpty(uiuds)) { + return; + } + + Set typeValues = favorites[favoriteType]; + if (typeValues == null) { + return; + } + typeValues.removeAll(uiuds); + } + + bool isFavorite(Favorite favorite){ + if(favorites==null || favorite==null || AppString.isStringEmpty(favorite.favoriteId)) + return false; + + Set favoritesOfType = favorites[favorite.favoriteKey]; + return favoritesOfType != null ? favoritesOfType.contains(favorite.favoriteId) : false; + } + + Set getFavorites(String favoriteKey){ + return favorites!=null? favorites[favoriteKey]: null; + } + + //Favorites serialization + static Map> serializeFavorites(Map jsonData){ + if(jsonData!=null && jsonData.isNotEmpty){ + Map> result = jsonData.map>((key, value) => MapEntry(key, Set.from(value))); + return result; + } + + return null; + } + + static Map deserializeFavorites(Map> favorites){ + if(favorites!=null && favorites.isNotEmpty){ + Map result = favorites.map((key,value)=>MapEntry(key, value.toList())); + return result; + } + + return null; + } + + //Tags + addPositiveTag(String tag){ + if(positiveTags==null) { + positiveTags = new List(); + } + positiveTags.add(tag); + } + + addNegativeTag(String tag){ + if(negativeTags!=null) { + negativeTags = new List(); + } + negativeTags.add(tag); + } + + removeTag(String tag){ + negativeTags?.remove(tag); + positiveTags?.remove(tag); + } + + bool containsTag(String tag){ + return (positiveTags?.contains(tag)??false) || (negativeTags?.contains(tag)??false); + } +} + +class UserRole{ + static const student = const UserRole._internal('student'); + static const employee = const UserRole._internal('employee'); + static const resident = const UserRole._internal('resident'); + + static List get values { + return [student, employee, resident]; + } + + final String _value; + + const UserRole._internal(this._value); + + factory UserRole.fromString(String userRoleString) { + if (userRoleString != null) { + if (userRoleString == 'student') { + return UserRole.student; + } + else if (userRoleString == 'employee') { + return UserRole.employee; + } + else if (userRoleString == 'resident') { + return UserRole.resident; + } + } + return null; + } + + toString() => _value; + + @override + bool operator==(dynamic obj) { + if (obj is UserRole) { + return obj._value == _value; + } + return false; + } + + @override + int get hashCode => _value.hashCode; + + toJson() { + return _value; + } + + // Static Helpers + + static Set userRolesFromList(List userRolesList) { + Set userRoles; + if (userRolesList != null) { + userRoles = new Set(); + for (dynamic userRole in userRolesList) { + if (userRole is String) { + userRoles.add(UserRole.fromString(userRole)); + } + } + } + return userRoles; + } + + static List userRolesToList(Set userRoles) { + List userRolesList; + if (userRoles != null) { + userRolesList = new List(); + for (UserRole userRole in userRoles) { + userRolesList.add(userRole.toString()); + } + } + return userRolesList; + } + + static Set targetAudienceFromUserRoles(Set roles) { + if (roles == null || roles.isEmpty) { + return null; + } + Set targetAudiences = Set(); + for (UserRole role in roles) { + if(role == UserRole.student) + targetAudiences.add('students'); + else if(role == UserRole.employee) + targetAudiences.addAll(['faculty', 'staff']); + else if(role == UserRole.resident) + targetAudiences.add('public'); + } + return targetAudiences; + } + + static String toRoleString(UserRole role) { + if (role != null) { + if (role == student) { + return Localization().getStringEx('model.user.role.student.title', 'Student'); + } else if (role == employee) { + return Localization().getStringEx('model.user.role.employee.title', 'Employee'); + } else if (role == resident) { + return Localization().getStringEx('model.user.role.resident.title', 'Resident'); + } + } + return null; + } +} + + +abstract class Favorite{ + String get favoriteId; + String get favoriteKey; +} \ No newline at end of file diff --git a/lib/model/UserPiiData.dart b/lib/model/UserPiiData.dart new file mode 100644 index 00000000..ba5c94ab --- /dev/null +++ b/lib/model/UserPiiData.dart @@ -0,0 +1,338 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:illinois/model/Auth.dart'; +import 'package:illinois/utils/Utils.dart'; + +class UserPiiData { + String pid; + String uin; + String netId; + + String userName; + String firstName; + String lastName; + String middleName; + int birthYear; + + String email; + String phone; + String address; + String state; + String zip; + String country; + + UserDocumentType documentType; + String photoBase64; + String imageUrl; + + List rawUuidList; + List uuidList; + + UserPiiData({ + this.pid, this.uin, this.netId, + this.firstName, this.lastName, this.middleName, this.userName, this.birthYear, + this.email, this.phone, this.address, this.state, this.zip, this.country, + this.documentType, this.photoBase64, this.imageUrl, + this.rawUuidList, this.uuidList, + }); + + factory UserPiiData.fromJson(Map json) { + return (json != null) ? UserPiiData( + pid: json['pid'], + uin: json['uin'], + netId: json['netid'], + + firstName: json['firstname'], + lastName: json['lastname'], + middleName: json['middlename'], + userName: json['username'], + birthYear: json['birthYear'], + + email: json['email'], + phone: json['phone'], + address: json['address'], + state: json['state'], + zip: json['zipCode'], + country: json['country'], + + documentType: userDocumentTypeFromString(json['documentType']) , + photoBase64: json['photoImageBase64'], + imageUrl: json['imageUrl'], + + rawUuidList: json['uuid'], + uuidList: _buildUuidList(json['uuid']), + ) : null; + } + + toJson() { + return { + 'pid': pid, + 'uin': uin, + 'netid': netId, + + 'firstname': firstName, + 'lastname': lastName, + 'middlename': middleName, + 'username': userName, + 'birthYear': birthYear, + + 'email': email, + 'phone': phone, + 'address': address, + 'state': state, + 'zipCode': zip, + 'country': country, + + 'documentType': userDocumentTypeToString(documentType), + 'photoImageBase64': photoBase64, + 'imageUrl': imageUrl, + + 'uuid': rawUuidList, + }; + } + + toShortJson() { + return { + 'pid': pid, + 'uin': uin, + 'netid': netId, + + 'firstname': firstName, + 'lastname': lastName, + 'middlename': middleName, + 'username': userName, + 'birthYear': birthYear, + + 'email': email, + 'phone': phone, + 'address': address, + 'state': state, + 'zipCode': zip, + 'country': country, + + 'documentType': userDocumentTypeToString(documentType), + }; + } + + factory UserPiiData.fromObject(dynamic o){ + return (o is UserPiiData) ? UserPiiData( + pid: o.pid, + uin: o.uin, + netId: o.netId, + + firstName: o.firstName, + lastName: o.lastName, + middleName: o.middleName, + userName: o.userName, + birthYear: o.birthYear, + + email: o.email, + phone: o.phone, + address: o.address, + state: o.state, + zip: o.zip, + country: o.country, + + documentType: o.documentType, + photoBase64: o.photoBase64, + imageUrl: o.imageUrl, + + rawUuidList: o.rawUuidList, + uuidList: o.uuidList, + ) : null; + } + + + bool operator ==(o) => + o is UserPiiData && + o.pid == pid && + o.uin == uin && + o.netId == netId && + + o.firstName == firstName && + o.lastName == lastName && + o.middleName == middleName && + o.userName == userName && + o.birthYear == birthYear && + + o.email == email && + o.phone == phone && + o.address == address && + o.state == state && + o.zip == zip && + o.country == country && + + o.documentType == documentType && + o.photoBase64 == photoBase64 && + o.imageUrl == imageUrl && + + DeepCollectionEquality().equals(rawUuidList, o.rawUuidList); + + + int get hashCode => + + (pid?.hashCode ?? 0) ^ + (uin?.hashCode ?? 0) ^ + (netId?.hashCode ?? 0) ^ + + (firstName?.hashCode ?? 0) ^ + (lastName?.hashCode ?? 0) ^ + (middleName?.hashCode ?? 0) ^ + (userName?.hashCode ?? 0) ^ + (birthYear?.hashCode ?? 0) ^ + + (email?.hashCode ?? 0) ^ + (phone?.hashCode ?? 0) ^ + (address?.hashCode ?? 0) ^ + (state?.hashCode ?? 0) ^ + (zip?.hashCode ?? 0) ^ + (country?.hashCode ?? 0) ^ + + (documentType?.hashCode ?? 0) ^ + (photoBase64?.hashCode ?? 0) ^ + (imageUrl?.hashCode ?? 0) ^ + + (rawUuidList?.hashCode ?? 0); + + String get fullName{ + String fullName = ''; + if ((firstName != null) && (0 < firstName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$firstName"; + } + if ((middleName != null) && (0 < middleName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$middleName"; + } + if ((lastName != null) && (0 < lastName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$lastName"; + } + return fullName; + } + + bool get identityVerified{ + return (AppString.isStringNotEmpty(firstName) || + AppString.isStringNotEmpty(middleName) || + AppString.isStringNotEmpty(lastName)) && + (AppString.isStringNotEmpty(netId) || + AppString.isStringNotEmpty(phone)); + } + + Future get photoBytes async{ + return (photoBase64 != null) ? await compute(AppBytes.decodeBase64Bytes, photoBase64) : null; + } + + bool get hasPasportInfo{ + return (documentType != null); + } + + bool updateFromAuthInfo(AuthInfo authInfo){ + bool updated = false; + + if(AppString.isStringEmpty(firstName) && AppString.isStringNotEmpty(authInfo?.firstName) ){ + firstName = authInfo.firstName; updated = true; + } + if(AppString.isStringEmpty(middleName) && AppString.isStringNotEmpty(authInfo?.middleName) ){ + middleName = authInfo.middleName; updated = true; + } + if(AppString.isStringEmpty(lastName) && AppString.isStringNotEmpty(authInfo?.lastName) ){ + lastName = authInfo.lastName; updated = true; + } + if(AppString.isStringEmpty(uin) && AppString.isStringNotEmpty(authInfo?.uin) ){ + uin = authInfo.uin; updated = true; + } + if(AppString.isStringEmpty(netId) && AppString.isStringNotEmpty(authInfo?.username) ){ + netId = authInfo.username; updated = true; + } + if(AppString.isStringEmpty(email) && AppString.isStringNotEmpty(authInfo?.email) ){ + email = authInfo.email; updated = true; + } + + return updated; + } + + + static List _buildUuidList(List list, { List uuidList, RegExp uuidRegExp }) { + + if (list != null) { + + if (uuidList == null) { + uuidList = List(); + } + + if (uuidRegExp == null) { + uuidRegExp = RegExp('[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}'); + } + + for (dynamic entry in list) { + if (entry is String) { + Iterable matches = uuidRegExp.allMatches(entry); + if (matches != null) { + for (RegExpMatch match in matches) { + String matchString = match.input.substring(match.start, match.end); + if (!uuidList.contains(matchString)) { + uuidList.add(matchString); + } + } + } + } + else if (entry is List) { + _buildUuidList(entry, uuidList: uuidList, uuidRegExp: uuidRegExp); + } + } + } + + return uuidList; + } + + void addProfileUuid(String uuid){ + if(AppString.isStringNotEmpty(uuid)) { + uuidList.add(uuid); + rawUuidList.add(uuid); + } + } + + } + +/////////////////////////////// +// UserDocumentType + +enum UserDocumentType { drivingLicense, passport } + +UserDocumentType userDocumentTypeFromString(String value) { + if (value == 'passport') { + return UserDocumentType.passport; + } + else if (value == 'drivingLicense') { + return UserDocumentType.drivingLicense; + } + else { + return null; + } +} + +String userDocumentTypeToString(UserDocumentType value) { + switch (value) { + case UserDocumentType.passport: return 'passport'; + case UserDocumentType.drivingLicense: return 'drivingLicense'; + } + return null; +} + + diff --git a/lib/service/Analytics.dart b/lib/service/Analytics.dart new file mode 100644 index 00000000..eb383037 --- /dev/null +++ b/lib/service/Analytics.dart @@ -0,0 +1,855 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as Http; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/AppNavigation.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Connectivity.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:location/location.dart'; + +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import 'package:package_info/package_info.dart'; +import 'package:device_info/device_info.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:illinois/service/User.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/LocationServices.dart'; + +class Analytics with Service implements NotificationsListener { + + // Database Data + + static const String _databaseName = "analytics.db"; + static const int _databaseVersion = 1; + static const String _databaseTable = "events"; + static const String _databaseColumn = "packet"; + static const String _databaseRowID = "rowid"; + static const int _databaseMaxPackCount = 64; + static const Duration _timerTick = const Duration(milliseconds: 100); + + // Log Data + + // Standard (shared) Attributes + static const String LogStdTimestampName = "timestamp"; + static const String LogStdAppIdName = "app_id"; + static const String LogStdAppVersionName = "app_version"; + static const String LogStdOSName = "os_name"; + static const String LogStdOSVersionName = "os_version"; + static const String LogStdLocaleName = "locale"; + static const String LogStdDeviceModelName = "device_model"; + static const String LogStdConnectionName = "connection"; + static const String LogStdLocationSvcName = "location_services"; + static const String LogStdNotifySvcName = "notification_services"; + static const String LogStdLocationName = "location"; + static const String LogStdSessionUuidName = "session_uuid"; + static const String LogStdUserUuidName = "user_uuid"; + static const String LogStdUserPrivacyLevelName = "user_privacy_level"; + static const String LogStdUserRolesName = "user_roles"; + static const String LogStdAccessibilityName = "accessibility"; + + static const String LogEvent = "event"; + static const String LogEventName = "name"; + static const String LogEventPageName = "page"; + + static const List DefaultAttributes = [ + LogStdTimestampName, + LogStdAppIdName, + LogStdAppVersionName, + LogStdOSName, + LogStdOSVersionName, + LogStdLocaleName, + LogStdDeviceModelName, + LogStdConnectionName, + LogStdLocationSvcName, + LogStdNotifySvcName, +// LogStdLocationName, + LogStdSessionUuidName, + LogStdUserUuidName, + LogStdUserPrivacyLevelName, + LogStdUserRolesName, + LogStdAccessibilityName, + ]; + + static const List HealthAttributes = [ + LogStdTimestampName, + ]; + + // Livecycle Event + // { "event" : { "name":"livecycle", "livecycle_event":"..." } } + static const String LogLivecycleEventName = "livecycle"; + static const String LogLivecycleName = "livecycle_event"; + static const String LogLivecycleEventCreate = "create"; + static const String LogLivecycleEventDestroy = "destroy"; + static const String LogLivecycleEventBackground = "background"; + static const String LogLivecycleEventForeground = "foreground"; + + // Page Event + // { "event" : { "name":"page", "page":"...", "page_name":"...", "previous_page_name":"" } } + static const String LogPageEventName = "page"; + static const String LogPageName = "page_name"; + static const String LogPagePreviousName = "previous_page_name"; + + // Select Event + // "event" : { "name":"select", "page":"...", "target":"..." } } + static const String LogSelectEventName = "select"; + static const String LogSelectTargetName = "target"; + + // Alert Event + // { "event" : { "name":"alert", "page":"...", "text":"...", "selection":"..." }} + static const String LogAlertEventName = "alert"; + static const String LogAlertTextName = "text"; + static const String LogAlertSelectionName = "selection"; + + // Http Response Event + // "event" : { "name":"http_response", "http_request_url":"...", "http_request_method":"...", "http_response_code":... } + static const String LogHttpResponseEventName = "http_response"; + static const String LogHttpRequestUrlName = "http_request_url"; + static const String LogHttpRequestMethodName = "http_request_method"; + static const String LogHttpResponseCodeName = "http_response_code"; + + // Map Route + static const String LogMapRouteEventName = "map_route"; + static const String LogMapRouteAction = "action"; + static const String LogMapRouteStartActionName = "start"; + static const String LogMapRouteFinishActionName = "finish"; + static const String LogMapRouteOrigin = "origin"; + static const String LogMapRouteDestination = "destination"; + static const String LogMapRouteLocation = "location"; + + // Map Display + static const String LogMapDisplayEventName = "map_dispaly"; + static const String LogMapDisplayAction = "action"; + static const String LogMapDisplayShowActionName = "show"; + static const String LogMapDisplayHideActionName = "hide"; + + // GeoFence Regions + static const String LogGeoFenceRegionEventName = "geofence_region"; + static const String LogGeoFenceRegionAction = "action"; + static const String LogGeoFenceRegionEnterActionName = "enter"; + static const String LogGeoFenceRegionExitActionName = "exit"; + static const String LogGeoFenceRegionRegion = "region"; + static const String LogGeoFenceRegionRegionId = "id"; + static const String LogGeoFenceRegionRegionName = "name"; + + // Illini Cash + static const String LogIllniCashEventName = "illini_cash"; + static const String LogIllniCashAction = "action"; + static const String LogIllniCashPurchaseActionName = "purchase"; + static const String LogIllniCashPurchaseAmount = "amount"; + + // Auth + static const String LogAuthEventName = "auth"; + static const String LogAuthAction = "action"; + static const String LogAuthLoginNetIdActionName = "login_netid"; + static const String LogAuthLoginPhoneActionName = "login_phone"; + static const String LogAuthLogoutActionName = "logout"; + static const String LogAuthResult = "result"; + + // Document Scan + static const String LogDocumentScanEventName = "document_scan"; + static const String LogDocumentScanType = "type"; + static const String LogDocumentScanDrivingLicenseType = "driving_license"; + static const String LogDocumentScanPassportType = "passport"; + static const String LogDocumentScanResult = "result"; + + // Health + static const String LogHealthEventName = "health"; + static const String LogHealthActionName = "action"; + static const String LogHealthStatusChangedAction = "status_changed"; + static const String LogHealthSettingChangedAction = "setting_changed"; + static const String LogHealthProviderTestProcessedAction = "provider_test_processed"; + static const String LogHealthManualTestSubmittedAction = "manual_test_submitted"; + static const String LogHealthSymptomsSubmittedAction = "symptoms_submitted"; + static const String LogHealthContactTraceProcessedAction = "contact_trace_processed"; + static const String LogHealthActionProcessedAction = "action_processed"; + static const String LogHealthReportExposuresAction = "report_exposures"; + static const String LogHealthCheckExposuresAction = "check_exposures"; + static const String LogHealthStatusName = "status"; + static const String LogHealthPrevStatusName = "previous_status"; + static const String LogHealthSettingNotifyExposuresName = "notify_exposures"; + static const String LogHealthSettingConsentName = "consent_test_results"; + static const String LogHealthProviderName = "provider"; + static const String LogHealthLocationName = "location"; + static const String LogHealthTestTypeName = "test_type"; + static const String LogHealthTestResultName = "test_result"; + static const String LogHealthSymptomsName = "symptoms"; + static const String LogHealthDurationName = "duration"; + static const String LogHealthExposureTimestampName = "exposure_timestamp"; + static const String LogHealthActionTypeName = "action_type"; + static const String LogHealthActionTextName = "action_text"; + static const String LogHealthActionTimestampName = "action_timestamp"; + + // Event Attributes + static const String LogAttributeUrl = "url"; + static const String LogAttributeEventId = "event_id"; + static const String LogAttributeEventName = "event_name"; + static const String LogAttributeEventCategory = "event_category"; + static const String LogAttributeRecurrenceId = "recurrence_id"; + static const String LogAttributeDiningId = "dining_id"; + static const String LogAttributeDiningName = "dining_name"; + static const String LogAttributePlaceId = "place_id"; + static const String LogAttributePlaceName = "place_name"; + static const String LogAttributeGameId = "game_id"; + static const String LogAttributeGameName = "game_name"; + static const String LogAttributeLaundryId = "laundry_id"; + static const String LogAttributeLaundryName = "laundry_name"; + static const String LogAttributeLocation = "location"; + + + // Data + + Database _database; + Timer _timer; + bool _inTimer = false; + + String _currentPageName; + Map _currentPageAttributes; + bool _currentPageAnonymous; + PackageInfo _packageInfo; + AndroidDeviceInfo _androidDeviceInfo; + IosDeviceInfo _iosDeviceInfo; + String _appId; + String _appVersion; + String _osVersion; + String _deviceModel; + ConnectivityStatus _connectionStatus; + String _connectionName; + String _locationServices; + String _notificationServices; + String _sessionUuid; + String _accessibilityState; + List _userRoles; + + + // Singletone Instance + + Analytics._internal(); + static final Analytics _instance = Analytics._internal(); + + factory Analytics() { + return _instance; + } + + static Analytics get instance { + return _instance; + } + + // Initialization + + @override + void createService() { + NotificationService().subscribe(this, [ + Connectivity.notifyStatusChanged, + AppLivecycle.notifyStateChanged, + AppNavigation.notifyEvent, + LocationServices.notifyStatusChanged, + User.notifyRolesUpdated, + User.notifyUserUpdated, + User.notifyUserDeleted, + NativeCommunicator.notifyMapRouteStart, + NativeCommunicator.notifyMapRouteFinish, + ]); + + } + + @override + Future initService() async { + + await _initDatabase(); + _initTimer(); + + _updateConnectivity(); + _updateLocationServices(); + _updateNotificationServices(); + _updateUserRoles(); + _updateSessionUuid(); + + PackageInfo.fromPlatform().then((PackageInfo packageInfo) { + _packageInfo = packageInfo; + _appId = _packageInfo?.packageName; + _appVersion = "${_packageInfo?.version}+${_packageInfo?.buildNumber}"; + }); + + if (defaultTargetPlatform == TargetPlatform.android) { + DeviceInfoPlugin().androidInfo.then((AndroidDeviceInfo androidDeviceInfo) { + _androidDeviceInfo = androidDeviceInfo; + _deviceModel = _androidDeviceInfo.model; + _osVersion = _androidDeviceInfo.version.release; + }); + } + else if (defaultTargetPlatform == TargetPlatform.iOS) { + DeviceInfoPlugin().iosInfo.then((IosDeviceInfo iosDeviceInfo) { + _iosDeviceInfo = iosDeviceInfo; + _deviceModel = _iosDeviceInfo.model; + _osVersion = _iosDeviceInfo.systemVersion; + }); + } + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + + _closeDatabase(); + _closeTimer(); + } + + @override + Set get serviceDependsOn { + return Set.from([Config(), User(), LocationServices(), Connectivity() ]); + } + + // Database + + Future _initDatabase() async { + if (_database == null) { + String databasePath = await getDatabasesPath(); + String databaseFile = join(databasePath, _databaseName); + _database = await openDatabase(databaseFile, version: _databaseVersion, onCreate: (db, version) { + return db.execute("CREATE TABLE IF NOT EXISTS $_databaseTable($_databaseColumn TEXT NOT NULL)",); + }); + } + } + + void _closeDatabase() { + if (_database != null) { + _database.close(); + _database = null; + } + } + + // Timer + + void _initTimer() { + if (_timer == null) { + //Log.d("Analytics: awake"); + _timer = Timer.periodic(_timerTick, _onTimer); + _inTimer = false; + } + } + + void _closeTimer() { + if (_timer != null) { + //Log.d("Analytics: asleep"); + _timer.cancel(); + _timer = null; + } + _inTimer = false; + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == Connectivity.notifyStatusChanged) { + _applyConnectivityStatus(param); + } + else if (name == LocationServices.notifyStatusChanged) { + _applyLocationServicesStatus(param); + } + else if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + else if (name == AppNavigation.notifyEvent) { + _onAppNavigationEvent(param); + } + else if (name == User.notifyRolesUpdated) { + _updateUserRoles(); + } + else if (name == User.notifyUserUpdated) { + _updateUserRoles(); + } + else if (name == User.notifyUserDeleted) { + _updateSessionUuid(); + _updateUserRoles(); + } + else if (name == NativeCommunicator.notifyMapRouteStart) { + logMapRoute(action: LogMapRouteStartActionName, params: param); + } + else if (name == NativeCommunicator.notifyMapRouteFinish) { + logMapRoute(action: LogMapRouteFinishActionName, params: param); + } + } + + // Connectivity + + void _updateConnectivity() { + _applyConnectivityStatus(Connectivity().status); +} + + void _applyConnectivityStatus(ConnectivityStatus status) { + _connectionName = _connectivityStatusToString(_connectionStatus = status); + } + + static String _connectivityStatusToString(ConnectivityStatus result) { + return result?.toString()?.substring("ConnectivityStatus.".length); + } + + // App Livecycle Service + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + + if (state == AppLifecycleState.paused) { + logLivecycle(name: LogLivecycleEventBackground); + } + else if (state == AppLifecycleState.resumed) { + _updateSessionUuid(); + _updateNotificationServices(); + logLivecycle(name: LogLivecycleEventForeground); + } + else if (state == AppLifecycleState.detached) { + logLivecycle(name: Analytics.LogLivecycleEventDestroy); + } + + } + + // App Naviagtion Service + + void _onAppNavigationEvent(Map param) { + AppNavigationEvent event = param[AppNavigation.notifyParamEvent]; + if (event == AppNavigationEvent.push) { + _logRoute(param[AppNavigation.notifyParamRoute]); + } + else if (event == AppNavigationEvent.pop) { + _logRoute(param[AppNavigation.notifyParamPreviousRoute]); + } + else if (event == AppNavigationEvent.remove) { + _logRoute(param[AppNavigation.notifyParamPreviousRoute]); + } + else if (event == AppNavigationEvent.replace) { + _logRoute(param[AppNavigation.notifyParamRoute]); + } + } + + void _logRoute(Route route) { + + WidgetBuilder builder; + if (route is CupertinoPageRoute) { + builder = route.builder; + } + else if (route is MaterialPageRoute) { + builder = route.builder; + } + + if (builder != null) { + Widget panel = builder(null); + if (panel != null) { + String panelName; + if (panel is AnalyticsPageName) { + panelName = (panel as AnalyticsPageName).analyticsPageName; + } + if (panelName == null) { + panelName = panel.runtimeType.toString(); + } + + Map panelAttributes; + if (panel is AnalyticsPageAttributes) { + panelAttributes = (panel as AnalyticsPageAttributes).analyticsPageAttributes; + } + + bool anonymous = (panel is AnalyticsPageAnonymous) ? (panel as AnalyticsPageAnonymous).analyticsPageAnonymous : true; + + logPage(name: panelName, attributes: panelAttributes, anonymous: anonymous); + } + } + } + + // Location Services + + void _updateLocationServices() { + LocationServices.instance.status.then((LocationServicesStatus locationServicesStatus) { + _applyLocationServicesStatus(locationServicesStatus); + }); + } + + void _applyLocationServicesStatus(LocationServicesStatus locationServicesStatus) { + switch (locationServicesStatus) { + case LocationServicesStatus.ServiceDisabled: _locationServices = "disabled"; break; + case LocationServicesStatus.PermissionNotDetermined: _locationServices = "not_determined"; break; + case LocationServicesStatus.PermissionDenied: _locationServices = "denied"; break; + case LocationServicesStatus.PermissionAllowed: _locationServices = "allowed"; break; + } + } + + Map get _location { + LocationData location = User().privacyMatch(3) ? LocationServices().lastLocation : null; + return (location != null) ? { + 'latitude': location.latitude, + 'longitude': location.longitude, + 'timestamp': (location.time * 1000).toInt(), + } : null; + } + + + // Notification Services + + void updateNotificationServices() { + _updateNotificationServices(); + } + + void _updateNotificationServices() { + // Android does not need for permission for user notifications + if (Platform.isAndroid) { + _notificationServices = 'enabled'; + } else if (Platform.isIOS) { + NativeCommunicator().queryNotificationsAuthorization("query").then((bool notificationsAuthorized) { + _notificationServices = notificationsAuthorized ? 'enabled' : "not_enabled"; + }); + } + } + + // Sesssion Uuid + + void _updateSessionUuid() { + _sessionUuid = Uuid().v1(); + } + + // Accessibility + + bool get accessibilityState { + return (_accessibilityState != null) ? (true.toString() == _accessibilityState) : null; + } + + set accessibilityState(bool value) { + _accessibilityState = (value != null) ? value.toString() : null; + } + + // User Roles Service + + void _updateUserRoles() { + Set roles = User().roles; + _userRoles = (roles != null) ? List.from(roles) : null; + } + + // Packets Processing + + Future _savePacket(String packet) async { + if ((packet != null) && (_database != null)) { + int result = await _database.insert(_databaseTable, { _databaseColumn : packet }); + //Log.d("Analytics: scheduled packet #$result $packet"); + _initTimer(); + return result; + } + return -1; + } + + void _onTimer(_) { + + if ((_database != null) && !_inTimer && (_connectionStatus != ConnectivityStatus.none)) { + _inTimer = true; + + _database.rawQuery("SELECT $_databaseRowID, $_databaseColumn FROM $_databaseTable ORDER BY $_databaseRowID LIMIT $_databaseMaxPackCount").then((List> records) { + if ((records != null) && (0 < records.length)) { + + String packets = '', rowIDs = ''; + for (Map record in records) { + + if (0 < packets.length) + packets += ','; + packets += '${record[_databaseColumn]}'; + + if (0 < rowIDs.length) + rowIDs += ','; + rowIDs += '${record[_databaseRowID]}'; + } + packets = '[' + packets + ']'; + rowIDs = '(' + rowIDs + ')'; + + _sendPacket(packets).then((bool success) { + if (success) { + _database.execute("DELETE FROM $_databaseTable WHERE $_databaseRowID in $rowIDs").then((_){ + //Log.d("Analytics: sent packets $rowIDs"); + _inTimer = false; + }); + } + else { + //Log.d("Analytics: failed to send packets $rowIDs"); + _inTimer = false; + } + }); + } + else { + _closeTimer(); + } + }); + } + } + + Future_sendPacket(String packet) async { + if (packet != null) { + try { + final response = await Network().post(Config().loggingUrl, body: packet, headers: { "Accept": "application/json", "Content-type":"application/json" }, auth: NetworkAuth.App, sendAnalytics: false); + return (response != null) && ((response.statusCode == 200) || (response.statusCode == 201)); + } + catch (e) { + print(e.toString()); + return false; + } + } + return false; + } + + // Public Accessories + + void logEvent(Map event, { List defaultAttributes = DefaultAttributes, bool anonymous = true}) { + if ((event != null) && User().privacyMatch(2)) { + + event[LogEventPageName] = _currentPageName; + + Map analyticsEvent = { + LogEvent: event, + }; + + for (String attributeName in defaultAttributes) { + if (attributeName == LogStdTimestampName) { + analyticsEvent[LogStdTimestampName] = DateTime.now().toUtc().toIso8601String(); + } + else if (attributeName == LogStdAppIdName) { + analyticsEvent[LogStdAppIdName]= _appId; + } + else if (attributeName == LogStdAppVersionName) { + analyticsEvent[LogStdAppVersionName]= _appVersion; + } + else if (attributeName == LogStdOSName) { + analyticsEvent[LogStdOSName]= Platform.operatingSystem; + } + else if (attributeName == LogStdOSVersionName) { + analyticsEvent[LogStdOSVersionName]=_osVersion; // Platform.operatingSystemVersion; + } + else if (attributeName == LogStdLocaleName) { + analyticsEvent[LogStdLocaleName]= Platform.localeName; + } + else if (attributeName == LogStdDeviceModelName) { + analyticsEvent[LogStdDeviceModelName]= _deviceModel; + } + else if (attributeName == LogStdConnectionName) { + analyticsEvent[LogStdConnectionName]= _connectionName; + } + else if (attributeName == LogStdLocationSvcName) { + analyticsEvent[LogStdLocationSvcName]= _locationServices; + } + else if (attributeName == LogStdNotifySvcName) { + analyticsEvent[LogStdNotifySvcName]= _notificationServices; + } + else if (attributeName == LogStdLocationName) { + analyticsEvent[LogStdLocationName]= _location; + } + else if (attributeName == LogStdSessionUuidName) { + analyticsEvent[LogStdSessionUuidName]= _sessionUuid; + } + else if (attributeName == LogStdUserUuidName) { + analyticsEvent[LogStdUserUuidName]= ((User().uuid != null) && (anonymous != false)) ? User.analyticsUuid : User().uuid; + } + else if (attributeName == LogStdUserPrivacyLevelName) { + analyticsEvent[LogStdUserPrivacyLevelName]= User().privacyLevel; + } + else if (attributeName == LogStdUserRolesName) { + analyticsEvent[LogStdUserRolesName]= _userRoles; + } + else if (attributeName == LogStdAccessibilityName) { + analyticsEvent[LogStdAccessibilityName]= _accessibilityState; + } + } + + String packet = json.encode(analyticsEvent); + if (packet != null) { + print('Analytics: $packet'); + _savePacket(packet); + } + } + } + + void logLivecycle({String name, bool anonymous = true}) { + logEvent({ + LogEventName : LogLivecycleEventName, + LogLivecycleName : name, + }, + anonymous: anonymous); + } + + String get currentPageName { + return _currentPageName; + } + + Map get currentPageAttributes { + return _currentPageAttributes; + } + + bool get currentPageAnonymous { + return _currentPageAnonymous; + } + + void logPage({String name, Map attributes, bool anonymous : true}) { + + bool previousPageAnonymous = (_currentPageAnonymous == true); + + // Update Current page name + String previousPageName = _currentPageName; + _currentPageName = name; + _currentPageAttributes = attributes; + _currentPageAnonymous = anonymous; + + // Build event data + Map event = { + LogEventName : LogPageEventName, + LogPageName : name, + LogPagePreviousName : previousPageName + }; + + if (attributes != null) { + event.addAll(attributes); + } + + // Log the event + logEvent(event, anonymous: (anonymous == true) || previousPageAnonymous); + } + + void logSelect({String target, bool anonymous = true}) { + logEvent({ + LogEventName : LogSelectEventName, + LogSelectTargetName : target, + }, anonymous: anonymous); + } + + void logAlert({String text, String selection, bool anonymous = true}) { + logEvent({ + LogEventName : LogAlertEventName, + LogAlertTextName : text, + LogAlertSelectionName : selection, + }, anonymous: anonymous); + } + + void logHttpResponse(Http.Response response, {String requestMethod, String requestUrl, bool anonymous = true}) { + Map httpResponseEvent = { + LogEventName : LogHttpResponseEventName, + LogHttpRequestUrlName : requestUrl, + LogHttpRequestMethodName : requestMethod, + LogHttpResponseCodeName : response?.statusCode + }; + logEvent(httpResponseEvent, anonymous: anonymous); + } + + void logMapRoute({String action, Map params}) { + + logEvent({ + LogEventName : LogMapRouteEventName, + LogMapRouteAction : action, + LogMapRouteOrigin : params['origin'], + LogMapRouteDestination : params['destination'], + LogMapRouteLocation : params['location'], + }); + } + + void logMapShow() { + logMapDisplay(action: LogMapDisplayShowActionName); + } + + void logMapHide() { + logMapDisplay(action: LogMapDisplayHideActionName); + } + + void logMapDisplay({String action}) { + + logEvent({ + LogEventName : LogMapDisplayEventName, + LogMapDisplayAction : action + }); + } + + void logIlliniCash({String action, Map attributes}) { + Map event = { + LogEventName : LogIllniCashEventName, + LogIllniCashAction : action, + }; + if (attributes != null) { + event.addAll(attributes); + } + logEvent(event); + } + + void logAuth({String action, bool result, Map attributes}) { + Map event = { + LogEventName : LogAuthEventName, + LogAuthAction : action, + }; + if (result != null) { + event[LogAuthResult] = result; + } + if (attributes != null) { + event.addAll(attributes); + } + logEvent(event); + } + + void logDocumentScan({String type, bool result, Map attributes, bool anonymous = true}) { + Map event = { + LogEventName : LogDocumentScanEventName, + LogDocumentScanType : type, + LogDocumentScanResult : result, + }; + if (attributes != null) { + event.addAll(attributes); + } + logEvent(event, anonymous: anonymous); + } + + void logHealth({String action, String status, String prevStatus, Map attributes, List defaultAttributes = HealthAttributes, bool anonymous = true }) { + Map event = { + LogEventName : LogHealthEventName, + LogHealthActionName : action, + }; + if (status != null) { + event[LogHealthStatusName] = status; + } + if (prevStatus != null) { + event[LogHealthPrevStatusName] = prevStatus; + } + if (attributes != null) { + event.addAll(attributes); + } + logEvent(event, defaultAttributes: defaultAttributes, anonymous: anonymous); + } +} + + +abstract class AnalyticsPageName { + String get analyticsPageName; +} + +abstract class AnalyticsPageAttributes { + Map get analyticsPageAttributes; +} + +abstract class AnalyticsPageAnonymous { + bool get analyticsPageAnonymous { + return true; + } +} diff --git a/lib/service/AppDateTime.dart b/lib/service/AppDateTime.dart new file mode 100644 index 00000000..0f7685fb --- /dev/null +++ b/lib/service/AppDateTime.dart @@ -0,0 +1,260 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import 'package:flutter/services.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:intl/intl.dart'; +import 'package:timezone/timezone.dart' as timezone; +import 'package:flutter_native_timezone/flutter_native_timezone.dart'; + +class AppDateTime with Service { + static final AppDateTime _instance = new AppDateTime._internal(); + + static final iso8601DateTimeFormat = 'yyyy-MM-ddTHH:mm:ss'; + static final eventsServerCreateDateTimeFormat = 'yyyy/MM/ddTHH:mm:ss'; + static final scheduleServerQueryDateTimeFormat = 'MM/dd/yyyy'; + static final serverResponseDateTimeFormat = 'E, dd MMM yyyy HH:mm:ss v'; + static final gameResponseDateTimeFormat = 'yyyy-MM-ddTHH:mm:ssZ'; + static final gameResponseDateTimeFormat2 = 'MM/dd/yyyy HH:mm:ss a'; + static final illiniCashTransactionHistoryDateFormat = 'MM-dd-yyyy'; + static final eventFilterDisplayDateFormat = 'MM/dd'; + static final voterDateFormat = "yyyy/MM/dd"; + static final parkingEventDateFormat = "yyyy-MM-ddTHH:mm:ssZ"; + static final covid19UpdateDateFormat = "MMMM dd, yyyy"; + static final covid19QrDateFormat = "MMMM dd, yyyy, HH:mm:ss a"; + static final covid19NewsCardDateFormat = "MMMM dd"; + static final covid19ServerDateFormat = "yyyy-MM-ddTHH:mm:ss.SSSZ"; + static final covid19OSFServerDateFormat = "yyyy-MM-ddTHH:mm:ssZ"; + static final covid19ReportTestDateFormat = 'MM/dd/yyyy h:mm a'; + + factory AppDateTime() { + return _instance; + } + + AppDateTime._internal(); + + timezone.Location _universityLocation; + String _localTimeZone; + + DateTime get now { + DateTime now = Storage().offsetDate; + return now != null ? now : DateTime.now(); + } + + + @override + Future initService() async { + _init(); + } + + _init() async { + _loadDefaultData().then((rawData) { + timezone.initializeDatabase(rawData); + timezone.Location deviceLocation = timezone.getLocation(_localTimeZone); + timezone.setLocalLocation(deviceLocation); + _universityLocation = timezone.getLocation('America/Chicago'); + }); + } + + Future> _loadDefaultData() async { + _localTimeZone = await FlutterNativeTimezone.getLocalTimezone(); + var byteData = await rootBundle.load('assets/timezone2019a.tzf'); + return byteData.buffer.asUint8List(); + } + + DateTime dateTimeFromString(String dateTimeString, {String format, bool isUtc = false}) { + if (AppString.isStringEmpty(dateTimeString)) { + return null; + } + DateFormat dateFormat; + DateTime dateTime; + if (AppString.isStringNotEmpty(format)) { + dateFormat = DateFormat(format); + } + try { + dateTime = + (dateFormat != null) ? dateFormat.parse(dateTimeString, isUtc) : DateTime.parse( + dateTimeString); + } + on Exception catch (e) { + Log.e(e.toString()); + } + return dateTime; + } + + DateTime getUtcTimeFromDeviceTime(DateTime dateTime) { + if (dateTime == null) { + return null; + } + DateTime dtUtc = dateTime.toUtc(); + return dtUtc; + } + + DateTime getDeviceTimeFromUtcTime(DateTime dateTimeUtc) { + if (dateTimeUtc == null) { + return null; + } + timezone.TZDateTime deviceDateTime = timezone.TZDateTime.from(dateTimeUtc, timezone.local); + return deviceDateTime; + } + + DateTime getUniLocalTimeFromUtcTime(DateTime dateTimeUtc) { + if (dateTimeUtc == null) { + return null; + } + timezone.TZDateTime tzDateTimeUni = timezone.TZDateTime.from(dateTimeUtc, _universityLocation); + return tzDateTimeUni; + } + + String formatUniLocalTimeFromUtcTime(DateTime dateTimeUtc, String format) { + if(dateTimeUtc != null && format != null){ + DateTime uniTime = getUniLocalTimeFromUtcTime(dateTimeUtc); + return DateFormat(format).format(uniTime); + } + return null; + } + + String formatDateTime(DateTime dateTime, + {String format, bool ignoreTimeZone = false, bool showTzSuffix = false}) { + if (dateTime == null) { + return null; + } + if (AppString.isStringEmpty(format)) { + format = iso8601DateTimeFormat; + } + bool useDeviceLocalTimeZone = Storage().debugUseDeviceLocalTimeZone; + String formattedDateTime; + DateFormat dateFormat = DateFormat(format); + if (ignoreTimeZone || useDeviceLocalTimeZone) { + try { formattedDateTime = dateFormat.format(dateTime); } + catch (e) { print(e?.toString()); } + } else { + timezone.TZDateTime tzDateTime = timezone.TZDateTime.from( + dateTime, _universityLocation); + try { formattedDateTime = dateFormat.format(tzDateTime); } + catch(e) { print(e?.toString()); } + } + if (showTzSuffix) { + formattedDateTime += ' CT'; + } + return formattedDateTime; + } + + String getDisplayDateTime(DateTime dateTimeUtc, {bool allDay = false, bool considerSettingsDisplayTime = true}) { + String timePrefix = getDisplayDay(dateTimeUtc: dateTimeUtc, allDay: allDay, considerSettingsDisplayTime: considerSettingsDisplayTime, includeAtSuffix: true); + String timeSuffix = getDisplayTime(dateTimeUtc: dateTimeUtc, allDay: allDay, considerSettingsDisplayTime: considerSettingsDisplayTime); + return '$timePrefix $timeSuffix'; + } + + String getDisplayDay({DateTime dateTimeUtc, bool allDay = false, bool considerSettingsDisplayTime = true, bool includeAtSuffix = false}) { + String displayDay = ''; + if(dateTimeUtc != null) { + bool useDeviceLocalTime = Storage().debugUseDeviceLocalTimeZone; + DateTime dateTimeToCompare = _getDateTimeToCompare(dateTimeUtc: dateTimeUtc, considerSettingsDisplayTime: considerSettingsDisplayTime); + DateTime nowDevice = DateTime.now(); + DateTime nowUtc = nowDevice.toUtc(); + DateTime nowUniLocal = getUniLocalTimeFromUtcTime(nowUtc); + DateTime nowToCompare = useDeviceLocalTime ? nowDevice : nowUniLocal; + int calendarDaysDiff = dateTimeToCompare.day - nowToCompare.day; + int timeDaysDiff = dateTimeToCompare.difference(nowToCompare).inDays; + if ((calendarDaysDiff != 0) && (nowToCompare.hour > dateTimeToCompare.hour)) { + timeDaysDiff += 1; + } + if (timeDaysDiff == 0) { + displayDay = Localization().getStringEx('model.explore.time.today', 'Today'); + if (!allDay && includeAtSuffix) { + displayDay += " ${Localization().getStringEx('model.explore.time.at', 'at')}"; + } + } + else if (timeDaysDiff == 1) { + displayDay = Localization().getStringEx('model.explore.time.tomorrow', 'Tomorrow'); + if (!allDay && includeAtSuffix) { + displayDay += " ${Localization().getStringEx('model.explore.time.at', 'at')}"; + } + } + else if ((1 < timeDaysDiff) && (timeDaysDiff < 7)) { + displayDay = formatDateTime(dateTimeToCompare, format: "EEEE", ignoreTimeZone: true, showTzSuffix: false); + } + else { + displayDay = formatDateTime(dateTimeToCompare, format: "MMM dd", ignoreTimeZone: true, showTzSuffix: false); + } + } + return displayDay; + } + + String getDisplayTime({DateTime dateTimeUtc, bool allDay = false, bool considerSettingsDisplayTime = true}) { + String timeToString = ''; + if (dateTimeUtc != null && !allDay) { + bool useDeviceLocalTime = Storage().debugUseDeviceLocalTimeZone; + DateTime dateTimeToCompare = _getDateTimeToCompare(dateTimeUtc: dateTimeUtc, considerSettingsDisplayTime: considerSettingsDisplayTime); + String format = (dateTimeToCompare.minute == 0) ? 'ha' : 'h:mma'; + timeToString = formatDateTime(dateTimeToCompare, format: format, ignoreTimeZone: true, showTzSuffix: !useDeviceLocalTime); + } + return timeToString; + } + + String getDayGreeting() { + int currentHour = DateTime.now().hour; + if (currentHour > 7 && currentHour < 12) { + return Localization().getStringEx("logic.date_time.greeting.morning", "Good morning"); + } + else if (currentHour >= 12 && currentHour < 19) { + return Localization().getStringEx("logic.date_time.greeting.afternoon", "Good afternoon"); + } + else { + return Localization().getStringEx("logic.date_time.greeting.evening", "Good evening"); + } + } + + DateTime _getDateTimeToCompare({DateTime dateTimeUtc, bool considerSettingsDisplayTime = true}) { + if (dateTimeUtc == null) { + return null; + } + DateTime dateTimeToCompare; + bool useDeviceLocalTime = Storage().debugUseDeviceLocalTimeZone; + //workaround for receiving incorrect date times from server for games: http://fightingillini.com/services/schedule_xml_2.aspx + if (useDeviceLocalTime && considerSettingsDisplayTime) { + dateTimeToCompare = getDeviceTimeFromUtcTime(dateTimeUtc); + } else { + dateTimeToCompare = getUniLocalTimeFromUtcTime(dateTimeUtc); + } + return dateTimeToCompare; + } + + static int getWeekDayFromString(String weekDayName){ + switch (weekDayName){ + case "monday" : return 1; + case "tuesday" : return 2; + case "wednesday": return 3; + case "thursday" : return 4; + case "friday" : return 5; + case "saturday" : return 6; + case "sunday" : return 7; + default: return 0; + } + } + + static DateTime get todayMidnightLocal { + DateTime now = DateTime.now(); + return DateTime(now.year, now.month, now.day); + } + +} \ No newline at end of file diff --git a/lib/service/AppLivecycle.dart b/lib/service/AppLivecycle.dart new file mode 100644 index 00000000..7e6e583b --- /dev/null +++ b/lib/service/AppLivecycle.dart @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/widgets.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; + +typedef AppLifecycleCallback = void Function(AppLifecycleState state); + +class AppLivecycleWidgetsBindingObserver extends WidgetsBindingObserver { + final AppLifecycleCallback onAppLivecycleChange; + AppLivecycleWidgetsBindingObserver({this.onAppLivecycleChange}); + + @override + Future didChangeAppLifecycleState(AppLifecycleState state) async { + if (onAppLivecycleChange != null) { + onAppLivecycleChange(state); + } + } +} + +class AppLivecycle with Service { + + static const String notifyStateChanged = "edu.illinois.rokwire.applivecycle.state.changed"; + + WidgetsBindingObserver _bindingObserver; + AppLifecycleState _state; + + // Singletone Instance + + AppLivecycle._internal(); + static final AppLivecycle _instance = AppLivecycle._internal(); + + factory AppLivecycle() { + return _instance; + } + + static AppLivecycle get instance { + return _instance; + } + + AppLifecycleState get state { + return _state; + } + + // Initialization + + @override + void createService() { + _initBinding(); + } + + @override + void destroyService() { + _closeBinding(); + } + + void ensureBinding() { + _initBinding(); + } + + void _initBinding() { + if ((WidgetsBinding.instance != null) && (_bindingObserver == null)) { + _bindingObserver = new AppLivecycleWidgetsBindingObserver(onAppLivecycleChange:_onAppLivecycleChangeState); + WidgetsBinding.instance.addObserver(_bindingObserver); + } + } + + void _closeBinding() { + if (_bindingObserver != null) { + WidgetsBinding.instance.removeObserver(_bindingObserver); + _bindingObserver = null; + } + } + + void _onAppLivecycleChangeState(AppLifecycleState state) { + _state = state; + NotificationService().notify(notifyStateChanged, state); + } +} \ No newline at end of file diff --git a/lib/service/AppNavigation.dart b/lib/service/AppNavigation.dart new file mode 100644 index 00000000..ba663b78 --- /dev/null +++ b/lib/service/AppNavigation.dart @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/NotificationService.dart'; + +enum AppNavigationEvent { push, pop, remove, replace } + +class AppNavigation extends NavigatorObserver { + + static const String notifyEvent = 'edu.illinois.rokwire.appnavigation.event'; + + static const String notifyParamEvent = 'event'; + static const String notifyParamRoute = 'route'; + static const String notifyParamPreviousRoute = 'previous_route'; + + // Singletone Instance + + AppNavigation._internal(); + static final AppNavigation _instance = AppNavigation._internal(); + + factory AppNavigation() { + return _instance; + } + + static AppNavigation get instance { + return _instance; + } + + @override + void didPush(Route route, Route previousRoute) { + NotificationService().notify(notifyEvent, { + notifyParamEvent: AppNavigationEvent.push, + notifyParamRoute : route, + notifyParamPreviousRoute : previousRoute, + }); + } + + @override + void didPop(Route route, Route previousRoute) { + NotificationService().notify(notifyEvent, { + notifyParamEvent: AppNavigationEvent.pop, + notifyParamRoute: route, + notifyParamPreviousRoute: previousRoute, + }); + } + + @override + void didRemove(Route route, Route previousRoute) { + NotificationService().notify(notifyEvent, { + notifyParamEvent: AppNavigationEvent.remove, + notifyParamRoute : route, + notifyParamPreviousRoute : previousRoute, + }); +} + + @override + void didReplace({Route newRoute, Route oldRoute }) { + NotificationService().notify(notifyEvent, { + notifyParamEvent: AppNavigationEvent.replace, + notifyParamRoute : newRoute, + notifyParamPreviousRoute : oldRoute, + }); + } + + static Widget routeRootWidget(Route route, {BuildContext context}) { + WidgetBuilder builder; + if (route is CupertinoPageRoute) { + builder = route.builder; + } + else if (route is MaterialPageRoute) { + builder = route.builder; + } + return (builder != null) ? builder(context) : null; + } +} \ No newline at end of file diff --git a/lib/service/Assets.dart b/lib/service/Assets.dart new file mode 100644 index 00000000..8646fe38 --- /dev/null +++ b/lib/service/Assets.dart @@ -0,0 +1,174 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:core'; +import 'dart:io'; +import 'dart:math'; +import 'dart:ui'; +import 'package:collection/collection.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:path/path.dart'; +import 'package:http/http.dart' as http; + +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/utils/Utils.dart'; + +class Assets with Service implements NotificationsListener { + + static const String notifyChanged = "edu.illinois.rokwire.assets.changed"; + + static const String _assetsName = "assets.json"; + + File _cacheFile; + DateTime _pausedDateTime; + + // Singleton Factory + + Assets._internal(); + static final Assets _instance = Assets._internal(); + + factory Assets() { + return _instance; + } + + Assets get instance { + return _instance; + } + + // Assets + + Map _assets; + + dynamic operator [](dynamic key) { + return AppMapPathKey.entry(_assets, key); + } + + String randomStringFromListWithKey(dynamic key) { + dynamic list = AppMapPathKey.entry(_assets, key); + dynamic entry = ((list != null) && (list is List) && (0 < list.length)) ? list[Random().nextInt(list.length)] : null; + return ((entry != null) && (entry is String)) ? entry : null; + } + + // Initialization + + @override + void createService() { + NotificationService().subscribe(this, AppLivecycle.notifyStateChanged); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + await _getCacheFile(); + await _loadFromCache(); + if (_assets == null) { + await _loadFromAssets(); + } + _loadFromNet(); + } + + @override + Set get serviceDependsOn { + return Set.from([Config()]); + } + + Future _getCacheFile() async { + Directory assetsDir = Config().assetsCacheDir; + if ((assetsDir != null) && !await assetsDir.exists()) { + await assetsDir.create(recursive: true); + } + String cacheFilePath = (assetsDir != null) ? join(assetsDir.path, _assetsName) : null; + _cacheFile = (cacheFilePath != null) ? File(cacheFilePath) : null; + } + + Future _loadFromCache() async { + try { + String assetsContent = ((_cacheFile != null) && await _cacheFile.exists()) ? await _cacheFile.readAsString() : null; + await _applyAssetsContent(assetsContent); + } catch (e) { + print(e.toString()); + } + } + + Future _loadFromAssets() async { + try { + String assetsContent = await rootBundle.loadString('assets/$_assetsName'); + await _applyAssetsContent(assetsContent); + } catch (e) { + print(e.toString()); + } + } + + Future _loadFromNet() async { + try { + http.Response response = (Config().assetsUrl != null) ? await Network().get("${Config().assetsUrl}/$_assetsName") : null; + String assetsContent = ((response != null) && (response.statusCode == 200)) ? response.body : null; + await _applyAssetsContent(assetsContent, cacheContent: true, notifyUpdate: true); + } catch (e) { + print(e.toString()); + } + } + + Future _applyAssetsContent(String assetsContent, {bool cacheContent = false, bool notifyUpdate = false}) async { + try { + Map assets = (assetsContent != null) ? AppJson.decode(assetsContent) : null; + if ((assets != null) && assets.isNotEmpty) { + if ((_assets == null) || !DeepCollectionEquality().equals(_assets, assets)) { + _assets = assets; + if (notifyUpdate) { + NotificationService().notify(notifyChanged, null); + } + if ((_cacheFile != null) && cacheContent) { + await _cacheFile.writeAsString(assetsContent, flush: true); + } + } + } + } catch (e) { + print(e.toString()); + } + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _pausedDateTime = DateTime.now(); + } + else if (state == AppLifecycleState.resumed) { + if (_pausedDateTime != null) { + Duration pausedDuration = DateTime.now().difference(_pausedDateTime); + if (Config().refreshTimeout < pausedDuration.inSeconds) { + _loadFromNet(); + } + } + } + } +} diff --git a/lib/service/Auth.dart b/lib/service/Auth.dart new file mode 100644 index 00000000..485c065e --- /dev/null +++ b/lib/service/Auth.dart @@ -0,0 +1,878 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:io'; +import 'dart:convert' as json; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as Http; +import 'package:http/http.dart'; +import 'package:illinois/model/Auth.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/model/UserPiiData.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/DeepLink.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:url_launcher/url_launcher.dart' as url_launcher; + +import 'package:illinois/service/User.dart'; +import 'package:illinois/utils/Utils.dart'; + + + +class Auth with Service implements NotificationsListener { + + static const String REDIRECT_URI = 'edu.illinois.covid://covid.illinois.edu/shib-auth'; + + static const String notifyStarted = "edu.illinois.rokwire.auth.started"; + static const String notifyAuthTokenChanged = "edu.illinois.rokwire.auth.authtoken.changed"; + static const String notifyLoggedOut = "edu.illinois.rokwire.auth.logged_out"; + static const String notifyLoginSucceeded = "edu.illinois.rokwire.auth.login_succeeded"; + static const String notifyLoginFailed = "edu.illinois.rokwire.auth.login_failed"; + static const String notifyLoginChanged = "edu.illinois.rokwire.auth.login_changed"; + static const String notifyInfoChanged = "edu.illinois.rokwire.auth.info.changed"; + static const String notifyUserPiiDataChanged = "edu.illinois.rokwire.auth.pii.changed"; + static const String notifyCardChanged = "edu.illinois.rokwire.auth.card.changed"; + + static const String _authCardName = "idCard.json"; + static const String _userPiiFileName = "piiData.json"; + + static final Auth _auth = Auth._internal(); + + AuthToken _authToken; + AuthToken get authToken{ return _authToken; } + + ShibbolethToken get shibbolethToken{ return _authToken is ShibbolethToken ? _authToken : null; } + PhoneToken get phoneToken{ return _authToken is PhoneToken ? _authToken : null; } + + AuthInfo _authInfo; + AuthInfo get authInfo{ return _authInfo; } + + UserPiiData _userPiiData; + UserPiiData get userPiiData{ return _userPiiData; } + File _userPiiCacheFile; + + AuthCard _authCard; + AuthCard get authCard{ return _authCard; } + File _authCardCacheFile; + + Future _refreshTokenFuture; + + Future get photoImageBytes async{ + Uint8List bytes; + if(Auth().isShibbolethLoggedIn){ + bytes = await Auth().authCard.photoBytes; + } + else if(Auth().isPhoneLoggedIn){ + bytes = await Auth().userPiiData.photoBytes; + } + return bytes; + } + + factory Auth() { + return _auth; + } + + Auth._internal(); + + @override + void createService() { + NotificationService().subscribe(this, [ + DeepLink.notifyUri, + AppLivecycle.notifyStateChanged, + User.notifyUserDeleted, + ]); + } + + @override + Future initService() async { + _authToken = Storage().authToken; + _authInfo = Storage().authInfo; + + _authCardCacheFile = await _getAuthCardCacheFile(); + _authCard = await _loadAuthCardFromCache(); + + _userPiiCacheFile = await _getUserPiiCacheFile(); + _userPiiData = await _loadUserPiiDataFromCache(); + + _syncProfilePiiDataIfNeed(); // No need for await + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Set get serviceDependsOn { + return Set.from([Storage()]); + } + + bool get isLoggedIn { + return _authToken != null; + } + + bool get isShibbolethLoggedIn { + return shibbolethToken != null; + } + + bool get isPhoneLoggedIn { + return phoneToken != null; + } + + bool get hasUIN { + return (0 < (authInfo?.uin?.length ?? 0)); + } + + bool get isEventEditor { + return authInfo?.userGroupMembership?.contains('urn:mace:uiuc.edu:urbana:authman:app-rokwire-service-policy-rokwire event approvers') ?? false; + } + + bool get isStadiumPollManager { + return authInfo?.userGroupMembership?.contains('urn:mace:uiuc.edu:urbana:authman:app-rokwire-service-policy-rokwire stadium poll manager') ?? false; + } + + bool get isDebugManager { + return authInfo?.userGroupMembership?.contains('urn:mace:uiuc.edu:urbana:authman:app-rokwire-service-policy-rokwire debug') ?? false; + } + + bool isMemberOf(String groupName) { + return authInfo?.userGroupMembership?.contains(groupName) ?? false; + } + + void logout(){ + _clear(true); + } + + void _clear([bool notify = false]){ + _authToken = null; + _authInfo = null; + _authCard = null; + + _applyUserPiiData(null, null); + + _saveAuthToken(); + _saveAuthInfo(); + _clearAuthCard(); + + if(notify) { + _notifyAuthInfoChanged(); + _notifyAuthCardChanged(); + _notifyAuthTokenChanged(); + _notifyAuthLoggedOut(); + } + } + + //////////////////////// + // Shibboleth Oauth + + void authenticateWithShibboleth(){ + + if ((Config().shibbolethOauthHostUrl != null) && (Config().shibbolethOauthPathUrl != null) && (Config().shibbolethClientId != null)) { + Uri uri = Uri.https( + Config().shibbolethOauthHostUrl, + Config().shibbolethOauthPathUrl, + { + 'scope': "openid profile email offline_access", + 'response_type': 'code', + 'redirect_uri': REDIRECT_URI, + 'client_id': Config().shibbolethClientId, + 'claims': json.jsonEncode({ + 'userinfo': { + 'uiucedu_uin': {'essential': true}, + }, + }), + }, + ); + var uriStr = uri.toString(); + _launchUrl(uriStr); + } + } + + Future _handleShibbolethAuthentication(code) async { + + _notifyAuthStarted(); + + NativeCommunicator().dismissSafariVC(); + + // 1. Request Tokens + AuthToken newAuthToken = await _loadShibbolethAuthTokenWithCode(code); + if(newAuthToken == null){ + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginNetIdActionName); + return; + } + + // 2. Request AuthInfo + AuthInfo newAuthInfo = await _loadAuthInfo(optAuthToken: newAuthToken); + if(newAuthInfo == null){ + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginNetIdActionName); + return; + } + + // 3. Request User Pii Pid + String newUserPiiPid = await _loadPidWithShibbolethAuth(email: newAuthInfo?.email, optAuthToken: newAuthToken); + if(newUserPiiPid == null){ + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginNetIdActionName); + return; + } + + // 4. Request UserPiiData + String newUserPiiDataString = await _loadUserPiiDataStringFromNet(pid: newUserPiiPid, optAuthToken: newAuthToken); + UserPiiData newUserPiiData = _userPiiDataFromJsonString(newUserPiiDataString); + if(newUserPiiData == null || AppCollection.isCollectionEmpty(newUserPiiData?.uuidList)){ + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginNetIdActionName); + return; + } + + // 5. Request UserData + UserData newUserData; + try { + newUserData = await User().requestUser(newUserPiiData.uuidList.first); + } on UserNotFoundException catch (_) {} + if(newUserData == null){ + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginNetIdActionName); + return; + } + + // 6. Load Auth Card + String jsonString = ((0 < (newAuthInfo?.uin?.length ?? 0))) ? await _loadAuthCardStringFromNet(optAuthToken: newAuthToken, optAuthInfo: newAuthInfo) : null; + AuthCard authCard = _authCardFromJsonString(jsonString); + + // Everything is fine - cleanup and store new tokens and data + // 7. Clear everything before proceed further. Notification is not required at this stage + _clear(false); + + // 8. Store everythong and notify everyone + // 8.1 AuthToken + _authToken = newAuthToken; + _saveAuthToken(); + _notifyAuthTokenChanged(); + + // 8.2 AuthInfo + _authInfo = newAuthInfo; + _saveAuthInfo(); + _notifyAuthInfoChanged(); + + // 8.3 UserPiiData + _applyUserPiiData(newUserPiiData, newUserPiiDataString); + + // 8.4 UserData + User().applyUserData(newUserData, applyCachedSettings: true); + + // 6.2 Update UserPiiData if need and then apply + if(newUserPiiData.updateFromAuthInfo(newAuthInfo)){ + storeUserPiiData(newUserPiiData); + } + else { + _applyUserPiiData(newUserPiiData, newUserPiiDataString); + } + + // 8.5 AuthCard + _applyAuthCard(authCard, jsonString); + + _notifyAuthLoginSucceeded(analyticsAction: Analytics.LogAuthLoginNetIdActionName); + } + + Future _syncProfilePiiDataIfNeed() async{ + if(isShibbolethLoggedIn){ + UserPiiData piiData = userPiiData; + if(piiData.updateFromAuthInfo(authInfo)){ + storeUserPiiData(piiData); + } + } + } + + Future _loadShibbolethAuthTokenWithCode(String code) async{ + String tokenUriStr = Config().shibbolethAuthTokenUrl + .replaceAll("{shibboleth_client_id}", Config().shibbolethClientId) + .replaceAll("{shibboleth_client_secret}", Config().shibbolethClientSecret); + Map bodyData = { + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': REDIRECT_URI, + }; + Http.Response response; + try { + response = await Network().post(tokenUriStr,body: bodyData); + String responseBody = (response != null && response.statusCode == 200) ? response.body : null; + Map jsonData = AppString.isStringNotEmpty(responseBody) ? AppJson.decode(responseBody) : null; + if(jsonData != null){ + return ShibbolethToken.fromJson(jsonData); + } + }catch(e){} + finally{ + Analytics().logHttpResponse(response, requestMethod: 'POST', requestUrl: tokenUriStr); + } + return null; + } + + Future _loadAuthInfo({AuthToken optAuthToken}) async{ + optAuthToken = (optAuthToken != null) ? optAuthToken : _authToken; + if (Config().userAuthUrl != null) { + try { + Http.Response userDataResp = await Network().get(Config().userAuthUrl, headers: {HttpHeaders.authorizationHeader : "${optAuthToken?.tokenType} ${optAuthToken?.accessToken}"}); + String responseBody = ((userDataResp != null) && (userDataResp.statusCode == 200)) ? userDataResp.body : null; + if ((responseBody != null) && (userDataResp.statusCode == 200)) { + var userDataMap = (responseBody != null) ? AppJson.decode(responseBody) : null; + return (userDataMap != null) ? AuthInfo.fromJson(userDataMap) : null; + } + } + catch(e) { print(e.toString()); } + } + return null; + } + + void _launchUrl(urlStr) async { + try { + if (await url_launcher.canLaunch(urlStr)) { + await url_launcher.launch(urlStr); + } + } + catch(e) { + print(e); + } + } + + //Phone verification + + ///Returns 'true' if code was send, otherwise - false + Future initiatePhoneNumber(String phoneNumberCandidate, VerificationMethod verifyMethod) async { + if (AppString.isStringEmpty(phoneNumberCandidate) || verifyMethod == null || AppString.isStringEmpty(Config().rokwireAuthUrl)) { + return false; + } + String channel = (verifyMethod == VerificationMethod.call) ? 'call' : 'sms'; + String phoneInitiateBody = '{"phoneNumber":"$phoneNumberCandidate", "channel":"$channel"}'; + var headers = { + "Content-Type": "application/json" + }; + final response = await Network().post( + '${Config().rokwireAuthUrl}/phone-initiate', body: phoneInitiateBody, headers: headers, auth: NetworkAuth.App); + if (response != null) { + return (response.statusCode >= 200 && response.statusCode <= 300); + } + else { + return false; + } + } + + ///Returns 'true' if phone number was validate successfully, otherwise - false + Future validatePhoneNumber(String code, String phoneNumber) async { + + _notifyAuthStarted(); + + if (AppString.isStringEmpty(phoneNumber) || AppString.isStringEmpty(code) || AppString.isStringEmpty(Config().rokwireAuthUrl)) { + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginPhoneActionName); + return false; + } + + // 1. Validate phone and code + AuthToken newAuthToken = await _validatePhoneCode(phone: phoneNumber, code: code); + if(newAuthToken == null) { + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginPhoneActionName); + return false; + } + + // 2. Pii Pid + String newUserPiiPid = await _loadPidWithPhoneAuth(phone: phoneNumber, optAuthToken: newAuthToken); + if(newUserPiiPid == null) { + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginPhoneActionName); + return false; + } + + // 3. UserPiiData + String newUserPiiDataString = await _loadUserPiiDataStringFromNet(pid: newUserPiiPid, optAuthToken: newAuthToken); + UserPiiData newUserPiiData = _userPiiDataFromJsonString(newUserPiiDataString); + if(newUserPiiData == null || AppCollection.isCollectionEmpty(newUserPiiData?.uuidList)){ + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginPhoneActionName); + return false; + } + + // 4. UserData + UserData newUserData; + try { + newUserData = await User().requestUser(newUserPiiData.uuidList.first); + } on UserNotFoundException catch (_) {} + if(newUserData == null){ + _notifyAuthLoginFailed(analyticsAction: Analytics.LogAuthLoginPhoneActionName); + return false; + } + + // Everything is fine - cleanup and store new tokens and data + // 5. Clear everything before proceed further. Notification is not required at this stage + _clear(false); + + // 6. Store everything and notify everyone + // 6.1 AuthToken + _authToken = newAuthToken; + _saveAuthToken(); + _notifyAuthTokenChanged(); + + // 6.2 UserPiiData + _applyUserPiiData(newUserPiiData, newUserPiiDataString); + + // 6.3 apply UserData + User().applyUserData(newUserData, applyCachedSettings: true); + + // 6.4 notifyLoggedIn event + _notifyAuthLoginSucceeded(analyticsAction: Analytics.LogAuthLoginPhoneActionName); + + return true; + } + + Future _validatePhoneCode({String phone, String code}) async{ + String phoneVerifyBody = '{"phoneNumber":"$phone", "code":"$code"}'; + var headers = { + "Content-Type": "application/json" + }; + final response = await Network().post( + '${Config().rokwireAuthUrl}/phone-verify', body: phoneVerifyBody, headers: headers, auth: NetworkAuth.App); + if ((response != null) && + (response.statusCode >= 200 && response.statusCode <= 300)) { + Map jsonData = AppJson.decode(response.body); + if (jsonData != null) { + bool succeeded = jsonData['success']; + if (succeeded && jsonData.containsKey("id_token")) { + return PhoneToken(phone: phone, idToken:jsonData["id_token"]); + } + } + } + return null; + } + + /// UserPIIData + + Future _loadPidWithPhoneAuth({String phone, AuthToken optAuthToken}) async { + return await _loadPidWithData( + data:{'uuid' : User().uuid, 'phone': phone}, + optAuthToken: optAuthToken + ); + } + + Future _loadPidWithShibbolethAuth({String email, AuthToken optAuthToken}) async { + return await _loadPidWithData( + data:{'uuid' : User().uuid, 'email': email,}, + optAuthToken: optAuthToken + ); + } + + Future _loadPidWithData({Map data, AuthToken optAuthToken}) async { + String url = (Config().userProfileUrl != null) ? '${Config().userProfileUrl}/pii' : null; + optAuthToken = (optAuthToken != null) ? optAuthToken : authToken; + + final response = await Network().post(url, + headers: {'Content-Type':'application/json', HttpHeaders.authorizationHeader: "${optAuthToken?.tokenType} ${optAuthToken?.idToken}"}, + body: json.jsonEncode(data), + ); + String responseBody = ((response != null) && (response.statusCode == 200)) ? response.body : null; + Map jsonData = (responseBody != null) ? AppJson.decode(responseBody) : null; + String userPid = (jsonData != null) ? jsonData['pid'] : null; + return userPid; + } + + Future storeUserPiiData(UserPiiData piiData) async { + if(piiData != null) { + String url = (Config().userProfileUrl != null) ? '${Config().userProfileUrl}/pii/${piiData.pid}' : null; + String body = json.jsonEncode(piiData.toJson()); + final response = await Network().put(url, + headers: {'Content-Type':'application/json'}, + body: body, + auth: NetworkAuth.User + ); + + String responseBody = ((response != null) && (response.statusCode == 200)) ? response.body : null; + Map jsonData = (responseBody != null) ? AppJson.decode(responseBody) : null; + UserPiiData userPiiData = (jsonData != null) ? UserPiiData.fromJson(jsonData) : null; + if(userPiiData != null) { + _applyUserPiiData(userPiiData, responseBody); + return userPiiData; + } else { + // This is a kind of workaround if the backend fails - still to save the data locally + _applyUserPiiData(piiData, json.jsonEncode(piiData.toJson())); + return piiData; + } + + } + return null; + } + + Future deleteUserPiiData() async{ + String url = (Config().userProfileUrl != null) ? '${Config().userProfileUrl}/pii/${Storage().userPid}' : null; + + await Network().delete(url, + headers: {'Content-Type':'application/json'}, + auth: NetworkAuth.User + ).whenComplete((){ + _applyUserPiiData(null, null); + }); + } + + void _applyUserPiiData(UserPiiData userPiiData, String userPiiDataString, [bool notify = true]) { + if (_userPiiData != userPiiData) { + _userPiiData = userPiiData; + Storage().userPid = userPiiData?.pid; + _saveUserPiiDataStringToCache(userPiiDataString); + if(notify) { + _notifyAuthUserPiiDataChanged(); + } + } + } + + Future _getUserPiiCacheFile() async { + Directory appDocDir = await getApplicationDocumentsDirectory(); + String cacheFilePath = join(appDocDir.path, _userPiiFileName); + return File(cacheFilePath); + } + + Future _loadUserPiiDataStringFromCache() async { + try { + return ((_userPiiCacheFile != null) && await _userPiiCacheFile.exists()) ? await _userPiiCacheFile.readAsString() : null; + } + on Exception catch (e) { + print(e.toString()); + } + return null; + } + + Future _saveUserPiiDataStringToCache(String value) async { + try { + if (_userPiiCacheFile != null) { + if (value != null) { + await _userPiiCacheFile.writeAsString(value, flush: true); + } + else if (await _userPiiCacheFile.exists()) { + await _userPiiCacheFile.delete(); + } + } + } + on Exception catch (e) { + print(e.toString()); + } + return null; + } + + Future _loadUserPiiDataFromCache() async { + return _userPiiDataFromJsonString(await _loadUserPiiDataStringFromCache()); + } + + Future _loadUserPiiDataStringFromNet({String pid, AuthToken optAuthToken}) async { + pid = (pid != null) ? pid : Storage().userPid; + optAuthToken = (optAuthToken != null) ? optAuthToken : authToken; + try { + String url = (Config().userProfileUrl != null) ? '${Config().userProfileUrl}/pii/$pid' : null; + final response = await Network().get(url, headers: { + HttpHeaders.authorizationHeader: "${optAuthToken?.tokenType} ${optAuthToken?.idToken}" + }); + return ((response != null) && (response.statusCode == 200)) ? response.body : null; + } + catch (e) { + print(e.toString()); + } + return null; + } + + Future _reloadUserPiiDataIfNeeded() async { + if (this.isLoggedIn) { + DateTime now = DateTime.now(); + int timeUpdate = Storage().userPiiDataTime; + DateTime dateUpdate = (0 < timeUpdate) ? DateTime.fromMillisecondsSinceEpoch(timeUpdate) : null; + if (!kReleaseMode || (dateUpdate == null) || (now.difference(dateUpdate).inSeconds < (3600 * 24))) { + await reloadUserPiiData(); + Storage().userPiiDataTime = now.millisecondsSinceEpoch; + } + } + } + + Future reloadUserPiiData({String pid, AuthToken optAuthToken}) async { + String jsonString = await _loadUserPiiDataStringFromNet(pid: pid, optAuthToken: optAuthToken); + UserPiiData userPiiData = _userPiiDataFromJsonString(jsonString); + if(userPiiData != null && userPiiData != _userPiiData) { // Redo: Ensure the request is not failed - do not remove it!!!! + _applyUserPiiData(userPiiData, jsonString); + } + return _userPiiData; + } + + UserPiiData _userPiiDataFromJsonString(String jsonString) { + try { + Map jsonData = (jsonString != null) ? AppJson.decode(jsonString) : null; + return (jsonData != null) ? UserPiiData.fromJson(jsonData) : null; + } on Exception catch (e) { + print(e.toString()); + } + return null; + } + + // Auth Card + + void _applyAuthCard(AuthCard authCard, String authCardJson) { + _authCard = authCard; + _saveAuthCardStringToCache(authCardJson); + _notifyAuthCardChanged(); + } + + Future _getAuthCardCacheFile() async { + Directory appDocDir = await getApplicationDocumentsDirectory(); + String cacheFilePath = join(appDocDir.path, _authCardName); + return File(cacheFilePath); + } + + Future _loadAuthCardStringFromCache() async { + try { + return ((_authCardCacheFile != null) && await _authCardCacheFile.exists()) ? await _authCardCacheFile.readAsString() : null; + } + on Exception catch (e) { + print(e.toString()); + } + return null; + } + + Future _saveAuthCardStringToCache(String value) async { + try { + if (_authCardCacheFile != null) { + if (value != null) { + await _authCardCacheFile.writeAsString(value, flush: true); + } + else if (await _authCardCacheFile.exists()) { + await _authCardCacheFile.delete(); + } + } + } + on Exception catch (e) { + print(e.toString()); + } + return null; + } + + Future _loadAuthCardFromCache() async { + return _authCardFromJsonString(await _loadAuthCardStringFromCache()); + } + + Future _loadAuthCardStringFromNet({AuthToken optAuthToken, AuthInfo optAuthInfo}) async { + optAuthToken = (optAuthToken != null) ? optAuthToken : authToken; + optAuthInfo = (optAuthInfo != null) ? optAuthInfo : authInfo; + try { + String url = Config().iCardUrl; + Map headers = { + 'UIN': optAuthInfo?.uin, + 'access_token': optAuthToken?.accessToken + }; + Response response = (url != null) ? await Network().post(url, headers: headers) : null; + return (response != null) && (response.statusCode == 200) ? response.body : null; + } + catch(e) { + print(e.toString()); + return null; + } + } + + Future _reloadAuthCardIfNeeded() async { + if (this.hasUIN) { + DateTime now = DateTime.now(); + int timeUpdate = Storage().authCardTime; + DateTime dateUpdate = (0 < timeUpdate) ? DateTime.fromMillisecondsSinceEpoch(timeUpdate) : null; + if (!kReleaseMode || (dateUpdate == null) || (now.difference(dateUpdate).inSeconds < (3600 * 24))) { + await _reloadAuthCard(); + Storage().authCardTime = now.millisecondsSinceEpoch; + } + } + } + + Future _reloadAuthCard() async { + String jsonString = await _loadAuthCardStringFromNet(); + AuthCard authCard = _authCardFromJsonString(jsonString); + if(authCard != null && _authCard != authCard) { // Redo: Ensure the request is not failed - do not remove it!!!! + _applyAuthCard(authCard, jsonString); + } + } + + AuthCard _authCardFromJsonString(String jsonString) { + try { + Map jsonData = (jsonString != null) ? AppJson.decode(jsonString) : null; + return (jsonData != null) ? AuthCard.fromJson(jsonData) : null; + } on Exception catch (e) { + print(e.toString()); + } + return null; + } + + // Refresh Token + + Future doRefreshToken() async { + + if(!isShibbolethLoggedIn){ + return; // Execute only if the user is loggedin + } + + if((Config().shibbolethAuthTokenUrl == null) || (Config().shibbolethClientId == null) || (Config().shibbolethClientSecret == null)) { + return; + } + + if(_refreshTokenFuture != null){ + await Future.wait([_refreshTokenFuture]); + return; + } + + Log.d("Auth: will refresh token"); + + String tokenUriStr = Config().shibbolethAuthTokenUrl + .replaceAll("{shibboleth_client_id}", Config().shibbolethClientId) + .replaceAll("{shibboleth_client_secret}", Config().shibbolethClientSecret); + Map body = { + "refresh_token": authToken?.refreshToken, + "grant_type": "refresh_token", + }; + _refreshTokenFuture = Network().post( + tokenUriStr, body: body, refreshToken: false) + .then((tokenResponse){ + _refreshTokenFuture = null; + try { + String tokenResponseBody = ((tokenResponse != null) && (tokenResponse.statusCode == 200)) ? tokenResponse.body : null; + var bodyMap = (tokenResponseBody != null) ? AppJson.decode(tokenResponseBody) : null; + _authToken = ShibbolethToken.fromJson(bodyMap); + _saveAuthToken(); + if (authToken?.idToken == null) { // Why we need this if ? + _authInfo = null; + _saveAuthInfo(); + _clearAuthCard(); + _notifyAuthCardChanged(); + _notifyAuthInfoChanged(); + } + Log.d("Auth: did refresh token: ${authToken?.idToken}"); + _notifyAuthTokenChanged(); + } + catch(e) { + print(e.toString()); + logout(); + } + + return; + }); + return _refreshTokenFuture; + } + + // Utils + + void _saveAuthToken() { + Storage().authToken = authToken; + } + + void _saveAuthInfo() { + Storage().authInfo = authInfo; + } + + void _clearAuthCard(){ + if (_authCard != null) { + _authCard = null; + _saveAuthCardStringToCache(null); + } + } + + //////// + // AuthListeners + + void _notifyAuthStarted(){ + NotificationService().notify(notifyStarted, null); + } + + void _notifyAuthTokenChanged(){ + NotificationService().notify(notifyAuthTokenChanged, null); + } + + void _notifyAuthInfoChanged(){ + NotificationService().notify(notifyInfoChanged, null); + } + + void _notifyAuthCardChanged(){ + NotificationService().notify(notifyCardChanged, null); + } + + void _notifyAuthLoginSucceeded({String analyticsAction}){ + if (analyticsAction != null) { + Analytics().logAuth(action: analyticsAction, result: true); + } + NotificationService().notify(notifyLoginSucceeded, null); + _notifyAuthLoginChanged(); + } + + void _notifyAuthLoggedOut(){ + Analytics().logAuth(action: Analytics.LogAuthLogoutActionName); + NotificationService().notify(notifyLoggedOut, null); + _notifyAuthLoginChanged(); + } + + void _notifyAuthLoginFailed({String analyticsAction}){ + if (analyticsAction != null) { + Analytics().logAuth(action: analyticsAction, result: false); + } + NotificationService().notify(notifyLoginFailed, null); + } + + void _notifyAuthLoginChanged(){ + NotificationService().notify(notifyLoginChanged, null); + } + + void _notifyAuthUserPiiDataChanged(){ + NotificationService().notify(notifyUserPiiDataChanged, null); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == DeepLink.notifyUri) { + _onDeepLinkUri(param); + } + else if (name == AppLivecycle.notifyStateChanged) { + if (param == AppLifecycleState.resumed) { + _reloadAuthCardIfNeeded(); + _reloadUserPiiDataIfNeeded(); + } + } + else if (name == User.notifyUserDeleted) { + logout(); + } + } + + void _onDeepLinkUri(Uri uri) { + if (uri != null) { + Uri shibbolethRedirectUri; + try { shibbolethRedirectUri = Uri.parse(REDIRECT_URI); } + catch(e) { print(e?.toString()); } + + var code = uri.queryParameters['code']; + if ((shibbolethRedirectUri != null) && + (shibbolethRedirectUri.scheme == uri.scheme) && + (shibbolethRedirectUri.authority == uri.authority) && + (shibbolethRedirectUri.path == uri.path) && + ((code != null) && code.isNotEmpty)) + { + _handleShibbolethAuthentication(code); + } + } + } + +} + +enum VerificationMethod { call, sms } \ No newline at end of file diff --git a/lib/service/BluetoothServices.dart b/lib/service/BluetoothServices.dart new file mode 100644 index 00000000..9ecf12ee --- /dev/null +++ b/lib/service/BluetoothServices.dart @@ -0,0 +1,133 @@ + +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; + +class BluetoothServices with Service implements NotificationsListener { + + static const String notifyStatusChanged = "edu.illinois.rokwire.bluetoothservices.status.changed"; + + BluetoothStatus _status; + + // Singletone Instance + + BluetoothServices._internal(); + static final BluetoothServices _instance = BluetoothServices._internal(); + + factory BluetoothServices() { + return _instance; + } + + static BluetoothServices get instance { + return _instance; + } + + // Iniitlaization + + @override + void createService() { + NotificationService().subscribe(this, [ + AppLivecycle.notifyStateChanged, + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + _status = await _getStatus(); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _checkStatus(); + } + } + + // API + + BluetoothStatus get status { + return _status; + } + + Future _getStatus() async { + if (Platform.isIOS) { + return _bluetoothStatusFromString(await NativeCommunicator().queryBluetoothAuthorization('query')); + } + else { + return BluetoothStatus.PermissionAllowed; + } + } + + void _checkStatus() { + _getStatus().then((BluetoothStatus status){ + if (_status != status) { + _status = status; + NotificationService().notify(notifyStatusChanged, null); + } + }); + } + + Future requestStatus() async { + if (Platform.isIOS && (_status == BluetoothStatus.PermissionNotDetermined)) { + BluetoothStatus status = _bluetoothStatusFromString(await NativeCommunicator().queryBluetoothAuthorization('request')); + if (_status != status) { + _status = status; + NotificationService().notify(notifyStatusChanged, null); + } + } + return _status; + } +} + +enum BluetoothStatus { + PermissionNotDetermined, + PermissionNotSupported, // iOS Emulator + PermissionDenied, + PermissionAllowed +} + +BluetoothStatus _bluetoothStatusFromString(String value){ + if("not_determined" == value) + return BluetoothStatus.PermissionNotDetermined; + if("not_supported" == value) + return BluetoothStatus.PermissionNotSupported; // iOS Emulator + else if("denied" == value) + return BluetoothStatus.PermissionDenied; + else if("allowed" == value) + return BluetoothStatus.PermissionAllowed; + else + return null; +} diff --git a/lib/service/Config.dart b/lib/service/Config.dart new file mode 100644 index 00000000..21a596b1 --- /dev/null +++ b/lib/service/Config.dart @@ -0,0 +1,436 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter/services.dart' show rootBundle; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/utils/Crypt.dart'; +import 'package:package_info/package_info.dart'; +import 'package:collection/collection.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +class Config with Service implements NotificationsListener { + + static const String _configsAsset = "configs.json.enc"; + + static const String notifyUpgradeRequired = "edu.illinois.rokwire.config.upgrade.required"; + static const String notifyUpgradeAvailable = "edu.illinois.rokwire.config.upgrade.available"; + static const String notifyConfigChanged = "edu.illinois.rokwire.config.changed"; + static const String notifyEnvironmentChanged = "edu.illinois.rokwire.config.environment.changed"; + + Map _config; + Map _configAsset; + ConfigEnvironment _configEnvironment; + PackageInfo _packageInfo; + Directory _appDocumentsDir; + DateTime _pausedDateTime; + + final Set _reportedUpgradeVersions = Set(); + + // Singletone Instance + + Config._internal(); + static final Config _instance = Config._internal(); + + factory Config() { + return _instance; + } + + static Config get instance { + return _instance; + } + + // Getters + + Map get otherUniversityServices { return (_config != null) ? (_config['otherUniversityServices'] ?? {}) : {}; } + Map get platformBuildingBlocks { return (_config != null) ? (_config['platformBuildingBlocks'] ?? {}) : {}; } + Map get thirdPartyServices { return (_config != null) ? (_config['thirdPartyServices'] ?? {}) : {}; } + + Map get secretKeys { return (_config != null) ? (_config['secretKeys'] ?? {}) : {}; } + Map get secretRokwire { return secretKeys['rokwire'] ?? {}; } + Map get secretShibboleth { return secretKeys['shibboleth'] ?? {}; } + Map get secretOsf { return secretKeys['osf'] ?? {}; } + Map get secretHealth { return secretKeys['health'] ?? {}; } + + Map get upgradeInfo { return (_config != null) ? (_config['upgrade'] ?? {}) : {}; } + + Map get settings { return (_config != null) ? (_config['settings'] ?? {}) : {}; } + + String get shibbolethAuthTokenUrl { return otherUniversityServices['shibboleth_auth_token_url']; } // "https://{shibboleth_client_id}:{shibboleth_client_secret}@shibboleth.illinois.edu/idp/profile/oidc/token" + String get shibbolethOauthHostUrl { return otherUniversityServices['shibboleth_oauth_host_url']; } // "shibboleth.illinois.edu" + String get shibbolethOauthPathUrl { return otherUniversityServices['shibboleth_oauth_path_url']; } // "/idp/profile/oidc/authorize" + String get userAuthUrl { return otherUniversityServices['user_auth_url']; } // "https://shibboleth.illinois.edu/idp/profile/oidc/userinfo" + String get assetsUrl { return otherUniversityServices['assets_url']; } // "https://rokwire-assets.s3.us-east-2.amazonaws.com" + String get feedbackUrl { return otherUniversityServices['feedback_url']; } // "https://forms.illinois.edu/sec/1971889" + String get iCardUrl { return otherUniversityServices['icard_url']; } // "https://www.icard.uillinois.edu/rest/rw/rwIDData/rwCardInfo" + String get privacyPolicyUrl { return otherUniversityServices['privacy_policy_url']; } // "https://www.vpaa.uillinois.edu/resources/web_privacy" + + String get loggingUrl { return platformBuildingBlocks['logging_url']; } // "https://api-dev.rokwire.illinois.edu/logs" + String get userProfileUrl { return platformBuildingBlocks['user_profile_url']; } // "https://api-dev.rokwire.illinois.edu/profiles" + String get rokwireAuthUrl { return platformBuildingBlocks['rokwire_auth_url']; } // "https://api-dev.rokwire.illinois.edu/authentication" + String get sportsServiceUrl { return platformBuildingBlocks['sports_service_url']; } // "https://api-dev.rokwire.illinois.edu/sports-service"; + String get healthUrl { return platformBuildingBlocks['health_url']; } // "https://api-dev.rokwire.illinois.edu/health" + String get health2Url { return platformBuildingBlocks['health2_url']; } // "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/covid/v2.2/dev" + String get talentChooserUrl { return platformBuildingBlocks['talent_chooser_url']; } // "https://api-dev.rokwire.illinois.edu/talent-chooser/api/ui-content" + String get transportationUrl { return platformBuildingBlocks["transportation_url"]; } // "https://api-dev.rokwire.illinois.edu/transportation" + String get locationsUrl { return platformBuildingBlocks["locations_url"]; } // "https://api-dev.rokwire.illinois.edu/location/api"; + + String get osfBaseUrl { return thirdPartyServices['osf_base_url']; } // "https://ssproxy.osfhealthcare.org/fhir-proxy" + + String get shibbolethClientId { return secretShibboleth['client_id']; } + String get shibbolethClientSecret { return secretShibboleth['client_secret']; } + + String get osfClientId { return secretOsf['client_id']; } + + String get healthPublicKey { return secretHealth['public_key']; } + String get healthApiKey { return secretHealth['api_key']; } + + String get appConfigUrl { // "https://api-dev.rokwire.illinois.edu/app/configs" + String assetUrl = (_configAsset != null) ? _configAsset['config_url'] : null; + return assetUrl ?? platformBuildingBlocks['appconfig_url']; + } + + String get rokwireApiKey { + String assetKey = (_configAsset != null) ? _configAsset['api_key'] : null; + return assetKey ?? secretRokwire['api_key']; + } + + int get refreshTimeout { + return kReleaseMode ? (settings['refreshTimeout'] ?? 0) : 0; + } + + // Initialization + + @override + void createService() { + + NotificationService().subscribe(this, [ + AppLivecycle.notifyStateChanged, + FirebaseMessaging.notifyConfigUpdate + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + + _configEnvironment = configEnvFromString(Storage().configEnvironment) ?? + (kReleaseMode ? ConfigEnvironment.production : ConfigEnvironment.dev); + + _packageInfo = await PackageInfo.fromPlatform(); + _appDocumentsDir = await getApplicationDocumentsDirectory(); + Log.d('Application Documents Directory: ${_appDocumentsDir.path}'); + + await _init(); + } + + @override + Set get serviceDependsOn { + return Set.from([Storage()]); + } + + String get _configName { + String configTarget = configEnvToString(_configEnvironment); + return "config.$configTarget.json"; + } + + File get _configFile { + String configFilePath = join(_appDocumentsDir.path, _configName); + return File(configFilePath); + } + + Future> _loadFromFile(File configFile) async { + try { + String configContent = (configFile != null) ? await configFile.readAsString() : null; + return _configFromJsonString(configContent); + } catch (e) { + print(e.toString()); + return null; + } + } + + Future> _loadFromAssets() async { + try { + String configsStrEnc = await rootBundle.loadString('assets/$_configsAsset'); + String configsStr = (configsStrEnc != null) ? AESCrypt.decode(configsStrEnc) : null; + Map configs = AppJson.decode(configsStr); + String configTarget = configEnvToString(_configEnvironment); + return (configs != null) ? configs[configTarget] : null; + } catch (e) { + print(e.toString()); + } + return null; + } + + Future _loadAsStringFromNet() async { + try { + http.Response response = await Network().get(appConfigUrl, auth: NetworkAuth.App); + return ((response != null) && (response.statusCode == 200)) ? response.body : null; + } catch (e) { + print(e.toString()); + return null; + } + } + + Map _configFromJsonString(String configJsonString) { + dynamic configJson = AppJson.decode(configJsonString); + List jsonList = (configJson is List) ? configJson : null; + if (jsonList != null) { + + jsonList.sort((dynamic cfg1, dynamic cfg2) { + return ((cfg1 is Map) && (cfg2 is Map)) ? AppVersion.compareVersions(cfg1['mobileAppVersion'], cfg2['mobileAppVersion']) : 0; + }); + + for (int index = jsonList.length - 1; index >= 0; index--) { + Map cfg = jsonList[index]; + if (AppVersion.compareVersions(cfg['mobileAppVersion'], _packageInfo.version) <= 0) { + _decodeSecretKeys(cfg); + return cfg; + } + } + } + + return null; + } + + bool _decodeSecretKeys(Map config) { + dynamic secretKeys = (config != null) ? config['secretKeys'] : null; + if (secretKeys is String) { + String jsonString = AESCrypt.decode(secretKeys); + dynamic jsonData = AppJson.decode(jsonString); + if (jsonData is Map) { + config['secretKeys'] = jsonData; + return true; + } + } + return false; + } + + Future _init() async { + + _config = await _loadFromFile(_configFile); + + if (_config == null) { + _configAsset = await _loadFromAssets(); + String configString = await _loadAsStringFromNet(); + _configAsset = null; + + _config = (configString != null) ? _configFromJsonString(configString) : null; + if (_config != null) { + _configFile.writeAsStringSync(configString, flush: true); + NotificationService().notify(notifyConfigChanged, null); + + _checkUpgrade(); + } + } + else { + _checkUpgrade(); + _updateFromNet(); + } + } + + void _updateFromNet() { + _loadAsStringFromNet().then((String configString) { + Map config = _configFromJsonString(configString); + if ((config != null) && (AppVersion.compareVersions(_config['mobileAppVersion'], config['mobileAppVersion']) <= 0) && !DeepCollectionEquality().equals(_config, config)) { + _config = config; + _configFile.writeAsString(configString, flush: true); + NotificationService().notify(notifyConfigChanged, null); + + _checkUpgrade(); + } + }); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + else if (name == FirebaseMessaging.notifyConfigUpdate) { + _updateFromNet(); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + + if (state == AppLifecycleState.paused) { + _pausedDateTime = DateTime.now(); + } + if (state == AppLifecycleState.resumed) { + if (_pausedDateTime != null) { + Duration pausedDuration = DateTime.now().difference(_pausedDateTime); + if (refreshTimeout < pausedDuration.inSeconds) { + _updateFromNet(); + } + } + } + } + + // Upgrade + + String get appVersion { + return _packageInfo.version; + } + + String get upgradeRequiredVersion { + dynamic requiredVersion = _upgradeStringEntry('required_version'); + if ((requiredVersion is String) && (AppVersion.compareVersions(_packageInfo.version, requiredVersion) < 0)) { + return requiredVersion; + } + return null; + } + + String get upgradeAvailableVersion { + dynamic availableVersion = _upgradeStringEntry('available_version'); + bool upgradeAvailable = (availableVersion is String) && + (AppVersion.compareVersions(_packageInfo.version, availableVersion) < 0) && + !Storage().reportedUpgradeVersions.contains(availableVersion) && + !_reportedUpgradeVersions.contains(availableVersion); + return upgradeAvailable ? availableVersion : null; + } + + String get upgradeUrl { + return _upgradeStringEntry('url'); + } + + void setUpgradeAvailableVersionReported(String version, { permanent : false }) { + if (permanent) { + Storage().reportedUpgradeVersion = version; + } + else { + _reportedUpgradeVersions.add(version); + } + } + + void _checkUpgrade() { + String value; + if ((value = this.upgradeRequiredVersion) != null) { + NotificationService().notify(notifyUpgradeRequired, value); + } + else if ((value = this.upgradeAvailableVersion) != null) { + NotificationService().notify(notifyUpgradeAvailable, value); + } + } + + String _upgradeStringEntry(String key) { + dynamic entry = upgradeInfo[key]; + if (entry is String) { + return entry; + } + else if (entry is Map) { + dynamic value = entry[Platform.operatingSystem.toLowerCase()]; + return (value is String) ? value : null; + } + else { + return null; + } + } + + // Environment + + set configEnvironment(ConfigEnvironment configEnvironment) { + if (_configEnvironment != configEnvironment) { + _configEnvironment = configEnvironment; + Storage().configEnvironment = configEnvToString(_configEnvironment); + + _init().then((_){ + NotificationService().notify(notifyEnvironmentChanged, null); + }); + } + } + + ConfigEnvironment get configEnvironment { + return _configEnvironment; + } + + // Assets cache path + + Directory get appDocumentsDir { + return _appDocumentsDir; + } + + Directory get assetsCacheDir { + + String assetsUrl = this.assetsUrl; + String assetsCacheDir = _appDocumentsDir?.path; + if ((assetsCacheDir != null) && (assetsUrl != null)) { + try { + Uri assetsUri = Uri.parse(assetsUrl); + if (assetsUri?.pathSegments != null) { + for (String pathSegment in assetsUri.pathSegments) { + assetsCacheDir = join(assetsCacheDir, pathSegment); + } + } + } + on Exception catch(e) { + print(e.toString()); + } + } + + return (assetsCacheDir != null) ? Directory(assetsCacheDir) : null; + } +} + +enum ConfigEnvironment { production, test, dev } + +String configEnvToString(ConfigEnvironment env) { + if (env == ConfigEnvironment.production) { + return 'production'; + } + else if (env == ConfigEnvironment.test) { + return 'test'; + } + else if (env == ConfigEnvironment.dev) { + return 'dev'; + } + else { + return null; + } +} + +ConfigEnvironment configEnvFromString(String value) { + if ('production' == value) { + return ConfigEnvironment.production; + } + else if ('test' == value) { + return ConfigEnvironment.test; + } + else if ('dev' == value) { + return ConfigEnvironment.dev; + } + else { + return null; + } +} diff --git a/lib/service/Connectivity.dart b/lib/service/Connectivity.dart new file mode 100644 index 00000000..596cc1c9 --- /dev/null +++ b/lib/service/Connectivity.dart @@ -0,0 +1,107 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; + +import 'package:connectivity/connectivity.dart' as ConnectivityPlugin; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; + +enum ConnectivityStatus { wifi, mobile, none } + +class Connectivity with Service { + + static const String notifyStatusChanged = "edu.illinois.rokwire.connectivity.status.changed"; + + ConnectivityStatus _connectivityStatus; + StreamSubscription _connectivitySubscription; + + // Singleton Factory + + Connectivity._internal(); + static final Connectivity _instance = Connectivity._internal(); + + factory Connectivity() { + return _instance; + } + + Connectivity get instance { + return _instance; + } + + // Initialization + + @override + void createService() { + _connectivitySubscription = ConnectivityPlugin.Connectivity().onConnectivityChanged.listen(_onConnectivityChanged); + ConnectivityPlugin.Connectivity().checkConnectivity().then((ConnectivityPlugin.ConnectivityResult result) { + _setConnectivityStatus(_statusFromResult(result)); + }); + } + + @override + void destroyService() { + if (_connectivitySubscription != null) { + _connectivitySubscription.cancel(); + _connectivitySubscription = null; + } + } + + void _onConnectivityChanged(ConnectivityPlugin.ConnectivityResult result) { + _setConnectivityStatus(_statusFromResult(result)); + } + + void _setConnectivityStatus(ConnectivityStatus status) { + if (_connectivityStatus != status) { + _connectivityStatus = status; + Log.d("Connectivity: ${_connectivityStatus?.toString()}" ); + NotificationService().notify(notifyStatusChanged, null); + } + } + + ConnectivityStatus _statusFromResult(ConnectivityPlugin.ConnectivityResult result) { + switch(result) { + case ConnectivityPlugin.ConnectivityResult.wifi: return ConnectivityStatus.wifi; + case ConnectivityPlugin.ConnectivityResult.mobile: return ConnectivityStatus.mobile; + case ConnectivityPlugin.ConnectivityResult.none: return ConnectivityStatus.none; + } + return null; + } + + // Connectivity + + ConnectivityStatus get status { + return _connectivityStatus; + } + + bool get isOnline { + return (_connectivityStatus != null) && (_connectivityStatus != ConnectivityStatus.none); + } + + bool get isOffline { + return (_connectivityStatus == ConnectivityStatus.none); + } + + bool get isNotOffline { + return (_connectivityStatus != ConnectivityStatus.none); + } + + bool get isDetermined { + return (_connectivityStatus != null); + } + +} diff --git a/lib/service/Crashlytics.dart b/lib/service/Crashlytics.dart new file mode 100644 index 00000000..bd76926c --- /dev/null +++ b/lib/service/Crashlytics.dart @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:firebase_crashlytics/firebase_crashlytics.dart' as Firebase; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Service.dart'; + +class Crashlytics with Service { + static final Crashlytics _crashlytics = new Crashlytics._internal(); + + factory Crashlytics() { + return _crashlytics; + } + + Crashlytics._internal(); + + @override + void createService() { + // Set `enableInDevMode` to true to see reports while in debug mode + // This is only to be used for confirming that reports are being + // submitted as expected. It is not intended to be used for everyday + // development. + Firebase.Crashlytics.instance.enableInDevMode = false; + + // Pass all uncaught errors to Firebase.Crashlytics. + FlutterError.onError = handleFlutterError; + } + + void handleFlutterError(FlutterErrorDetails details) { + FlutterError.dumpErrorToConsole(details); + Firebase.Crashlytics.instance.recordFlutterError(details); + } + + void handleZoneError(dynamic exception, StackTrace stack) { + print(exception); + Firebase.Crashlytics.instance.recordError(exception, stack); + } + + void recordError(dynamic exception, StackTrace stack) { + print(exception); + Firebase.Crashlytics.instance.recordError(exception, stack); + } + + void log(String message) { + Firebase.Crashlytics.instance.log(message); + } +} \ No newline at end of file diff --git a/lib/service/DeepLink.dart b/lib/service/DeepLink.dart new file mode 100644 index 00000000..f1bf0675 --- /dev/null +++ b/lib/service/DeepLink.dart @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:uni_links/uni_links.dart'; + +class DeepLink with Service { + static const String notifyUri = "edu.illinois.rokwire.deeplink.uri"; + + static final DeepLink _deepLink = DeepLink._internal(); + + factory DeepLink() { + return _deepLink; + } + + DeepLink._internal(); + + @override + void createService() { + getUriLinksStream().listen((Uri uri) async { + NotificationService().notify(notifyUri, uri); + }); + } +} diff --git a/lib/service/Exposure.dart b/lib/service/Exposure.dart new file mode 100644 index 00000000..7ddaa7bd --- /dev/null +++ b/lib/service/Exposure.dart @@ -0,0 +1,1020 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart'; +import 'package:illinois/model/Exposure.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/BluetoothServices.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/utils/Utils.dart'; + +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; + + +class Exposure with Service implements NotificationsListener { + + // Notifications + + static const String notifyStartStop = "edu.illinois.rokwire.exposure.start_stop"; + static const String notifyTEKsUpdated = "edu.illinois.rokwire.exposure.teks.updated"; + static const String notifyExposureUpdated = "edu.illinois.rokwire.exposure.expsure.updated"; + static const String notifyExposureThick = "edu.illinois.rokwire.exposure.expsure.thick"; + + // Native + + static const String _methodChannelName = 'edu.illinois.covid/exposure'; + + static const String _startMethodName = 'start'; + static const String _stopMethodName = 'stop'; + static const String _teksMethodName = 'TEKs'; + static const String _tekRPIsMethodName = 'tekRPIs'; + static const String _expireTEKMethodName = 'expireTEK'; + static const String _exposureRPIMethodName = 'exposureRPILog'; + static const String _exposureRSSIMethodName = 'exposureRSSILog'; + + static const String _settingsParamName = 'settings'; + static const String _tekParamName = 'tek'; + static const String _timestampParamName = 'timestamp'; + static const String _expirestampParamName = 'expirestamp'; + + static const String _tecNotificationName = 'tek'; + static const String _exposureNotificationName = 'exposure'; + static const String _exposureThickNotificationName = 'exposureThick'; + + // Database + + static const String _databaseName = "exposures.db"; + static const int _databaseVersion = 1; + + static const String _databaseExposureTable = "Exposures"; + static const String _databaseExposureTimestampField = "Timestamp"; + static const String _databaseExposureRPIField = "RPI"; + static const String _databaseExposureDurationField = "Duration"; + static const String _databaseExposureProcessedField = "Processed"; + + static const String _databaseRpiTable = "ExposureRpi"; + static const String _databaseRpiSessionIdField = "SessionId"; + static const String _databaseRpiTEKField = "TEK"; + static const String _databaseRpiTEKStartTimeField = "TEKStartTime"; + static const String _databaseRpiRPIField = "RPI"; + static const String _databaseRpiRPIStartTimeField = "RPIStartTime"; + static const String _databaseRpiEventField = "Event"; + + static const String _databaseContactTable = "ExposureContact"; + static const String _databaseContactSessionIdField = "SessionId"; + static const String _databaseContactStartTimeField = "StartTime"; + static const String _databaseContactDurationField = "Duration"; + static const String _databaseContactRPIField = "RPI"; + static const String _databaseContactSourceField = "Source"; + static const String _databaseContactAddressField = "Address"; + + static const String _databaseRssiTable = "ExposureRssi"; + static const String _databaseRssiSessionIdField = "SessionId"; + static const String _databaseRssiTimestampField = "Timestamp"; + static const String _databaseRssiRSSIField = "RSSI"; + static const String _databaseRssiRPIField = "RPI"; + static const String _databaseRssiSourceField = "Source"; + static const String _databaseRssiAddressField = "Address"; + + static const String _databaseRowID = "rowid"; + + // Time + static const int _rpiRefreshInterval = (10 * 60 * 1000); // 10 min, in milisconds + static const int _rpiCheckExposureBuffer = (30 * 60 * 1000); // 30 min as buffer time + static const int _millisecondsInDay = 24 * 60 * 60 * 1000; // 1 day, in milliseconds + static const int _exposureExpireInterval = 14 * _millisecondsInDay; // 14 days, in milliseconds + + // Data + final MethodChannel _methodChannel = const MethodChannel(_methodChannelName); + Database _database; + + bool _serviceInitialized = false; + + bool _pluginInitialized = false; + bool _isPluginStarted = false; + Map _pluginSettings; + + Timer _exposuresMonitorTimer; + DateTime _pausedDateTime; + + bool _checkingReport; + bool _checkingExposures; + int _exposureMinDuration; + int _reportTargetTimestamp; + int _lastReportTimestamp; + + int _logSessionId; + + // Singletone instance + + static final Exposure _service = Exposure._internal(); + + Exposure._internal() { + _methodChannel.setMethodCallHandler(this._nativeCallback); + } + + factory Exposure() { + return _service; + } + + // Service + + @override + void createService() { + NotificationService().subscribe(this, [ + BluetoothServices.notifyStatusChanged, + Config.notifyConfigChanged, + AppLivecycle.notifyStateChanged, + Health.notifyUserUpdated, + Health.notifyUserPrivateKeyUpdated, + Auth.notifyLoggedOut, + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + _closeDatabase(); + _destroyPlugin(); + _stopExposuresMonitor(); + } + + @override + Future initService() async { + _reportTargetTimestamp = Storage().exposureReportTargetTimestamp; + _lastReportTimestamp = Storage().exposureLastReportedTimestamp; + + await _openDatabase(); + + _initializePlugin().then((_) { + _pluginInitialized = true; + }); + + _updateExposureMinDuration(); + + _serviceInitialized = true; + } + + @override + void initServiceUI() { + checkExposures().then((_){ + _startExposuresMonitor(); + }); + checkReport(); + } + + @override + Set get serviceDependsOn { + return Set.from([Storage(), Config(), Health()]); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (_serviceInitialized) { + if (name == Config.notifyConfigChanged) { + _updatePlugin(forceRestart: true); + _updateExposuresMonitor(); + _updateExposureMinDuration(); + checkReport(); + } + else if (name == Health.notifyUserUpdated || name == Health.notifyUserPrivateKeyUpdated) { + _updatePlugin(); + _updateExposuresMonitor(); + checkReport(); + } + else if (name == Auth.notifyLoggedOut) { + _updatePlugin(); + _updateExposuresMonitor(); + } + else if (name == BluetoothServices.notifyStatusChanged) { + _updatePlugin(); + } + else if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _pausedDateTime = DateTime.now(); + _stopExposuresMonitor(); + } + else if (state == AppLifecycleState.resumed) { + Duration pausedDuration = (_pausedDateTime != null) ? DateTime.now().difference(_pausedDateTime) : null; + if ((pausedDuration != null) && (Config().refreshTimeout < pausedDuration.inSeconds)) { + checkReport(); + checkExposures().then((_){ + _startExposuresMonitor(); + }); + } + else { + _startExposuresMonitor(); + } + } + } + + // Initialize and Destroy + + bool get _serviceEnabled { + return (Config().settings['covid19ExposureMonitorEnabled'] == true) && + (Health().userExposureNotification == true); + } + + bool get _pluginEnabled { + return (BluetoothServices().status == BluetoothStatus.PermissionAllowed) && _serviceEnabled; + } + + Future _initializePlugin() async { + if (_pluginEnabled && !_isPluginStarted && _wasStarted) { + await _nativeStart(); + } + } + + Future _destroyPlugin() async { + if (_isPluginStarted) { + await _nativeStop(); + } + } + + Future _updatePlugin({bool forceRestart}) async { + if (_pluginInitialized) { + if (_pluginEnabled && _isPluginStarted && (forceRestart == true)) { + await _nativeStop(); + } + if (_pluginEnabled && !_isPluginStarted && _wasStarted) { + await _nativeStart(); + } + else if (!_pluginEnabled && _isPluginStarted) { + await _nativeStop(); + } + } + } + + // Method Channel + + Future _nativeStart({Map settings}) async { + if (settings == null) { + settings = Config().settings; + } + if (await _methodChannel.invokeMethod(_startMethodName, { _settingsParamName: settings })) { + _isPluginStarted = true; + _pluginSettings = settings; + } + } + + Future _nativeStop() async { + await _methodChannel.invokeMethod(_stopMethodName); + _isPluginStarted = false; + _pluginSettings = null; + } + + Future _expireTEK() async { + await _methodChannel.invokeMethod(_expireTEKMethodName); + } + + Future> loadTeks({int minStamp, int maxStamp}) async { + List teks; + List json = await _methodChannel.invokeMethod(_teksMethodName); + if (json != null) { + teks = []; + for (dynamic entry in json) { + ExposureTEK tek; + try { tek = ExposureTEK.fromJson((entry as Map)?.cast()); } + catch(e) { print(e?.toString()); } + if (((minStamp == null) || (minStamp <= tek.timestamp)) && + ((maxStamp == null) || (maxStamp >= tek.timestamp))) + { + teks.add(tek); + } + } + } + return teks; + } + + Future deleteTeks() async { + await _methodChannel.invokeMethod(_teksMethodName, { + 'remove': true, + }); + } + + Future> _loadTekRPIs(ExposureTEK tek) async { + Map result = await _methodChannel.invokeMethod(_tekRPIsMethodName, { + _tekParamName : tek.tek, + _timestampParamName: tek.timestamp, + _expirestampParamName: tek.expirestamp, + }); + return result?.cast(); + } + + Future _nativeCallback(MethodCall call) async { + if (call.method == _tecNotificationName) { + NotificationService().notify(notifyTEKsUpdated, null); + _clearExpiredLocalExposures(); + } + else if (call.method == _exposureNotificationName) { + _storeLocalExposure(call.arguments); + _logContact(call.arguments); + } + else if (call.method == _exposureThickNotificationName) { + NotificationService().notify(notifyExposureThick, call.arguments); + } + else if (call.method == _exposureRPIMethodName) { + _logRpi(call.arguments); + } + else if (call.method == _exposureRSSIMethodName) { + _logRssi(call.arguments); + } + return null; + } + + // Database + + Future _openDatabase() async { + if (_database == null) { + String databasePath = await getDatabasesPath(); + String databaseFile = join(databasePath, _databaseName); + _database = await openDatabase(databaseFile, version: _databaseVersion, onCreate: (db, version) async { + try { await db.execute("CREATE TABLE IF NOT EXISTS $_databaseExposureTable($_databaseExposureTimestampField INTEGER NOT NULL, $_databaseExposureRPIField TEXT NOT NULL, $_databaseExposureDurationField INTEGER NOT NULL, $_databaseExposureProcessedField INTEGER NOT NULL DEFAULT '0')",); } catch(e) { print(e?.toString()); } + try { await db.execute("CREATE TABLE IF NOT EXISTS $_databaseRpiTable($_databaseRpiSessionIdField INTEGER, $_databaseRpiTEKField TEXT, $_databaseRpiTEKStartTimeField INTEGER, $_databaseRpiRPIField TEXT, $_databaseRpiRPIStartTimeField INTEGER, $_databaseRpiEventField TEXT)",); } catch(e) { print(e?.toString()); } + try { await db.execute("CREATE TABLE IF NOT EXISTS $_databaseContactTable($_databaseContactSessionIdField INTEGER, $_databaseContactStartTimeField INTEGER, $_databaseContactDurationField INTEGER, $_databaseContactRPIField TEXT, $_databaseContactSourceField TEXT, $_databaseContactAddressField TEXT)",); } catch(e) { print(e?.toString()); } + try { await db.execute("CREATE TABLE IF NOT EXISTS $_databaseRssiTable($_databaseRssiSessionIdField INTEGER, $_databaseRssiTimestampField INTEGER, $_databaseRssiRSSIField INTEGER, $_databaseRssiRPIField TEXT, $_databaseRssiSourceField TEXT, $_databaseRssiAddressField TEXT)",); } catch(e) { print(e?.toString()); } + }); + } + } + + void _closeDatabase() { + if (_database != null) { + try { _database.close(); } catch(e) { print(e?.toString()); } + _database = null; + } + } + + // Public API + + void start({ Map settings }) { + if (_pluginEnabled && !_isPluginStarted) { + _nativeStart(settings: settings).then((_) { + _wasStarted = true; + NotificationService().notify(notifyStartStop, null); + }); + } + } + + void stop() { + if (_isPluginStarted) { + _nativeStop().then((_) { + _wasStarted = false; + NotificationService().notify(notifyStartStop, null); + }); + } + } + + Future deleteUser() async { + _stopExposuresMonitor(); + await deleteTeks(); + await clearLocalExposures(); + await _destroyPlugin(); + Storage().exposureStarted = null; + Storage().exposureLastReportedTimestamp = null; + } + + bool get isEnabled { + return _pluginEnabled; + } + + bool get isStarted { + return _isPluginStarted; + } + + Map get startSettings { + return _pluginSettings; + } + + bool get _wasStarted { + return Storage().exposureStarted; + } + + set _wasStarted(bool value) { + Storage().exposureStarted = value; + } + + static int get _currentTimestamp { + return DateTime.now().toUtc().millisecondsSinceEpoch; + } + + static int get thresholdTimestamp { + return getThresholdTimestamp(origin: _currentTimestamp); + } + + static int getThresholdTimestamp({int origin}) { + // Two weeks before origin is standard thresold for checking exposures + int midnightTimestamp = (origin ~/ _millisecondsInDay) * _millisecondsInDay; + int twoWeeksAgoMidnightTimestamp = midnightTimestamp - (14 * _millisecondsInDay); + return twoWeeksAgoMidnightTimestamp; + } + + // Local Exposures + + Future> loadLocalExposures({int timestamp, bool processed }) async { + List result; + + String query = "SELECT $_databaseRowID, $_databaseExposureTimestampField, $_databaseExposureRPIField, $_databaseExposureDurationField FROM $_databaseExposureTable"; + + String where = ''; + if (timestamp != null) { + if (where.isNotEmpty) { + where += ' AND '; + } + where += "$_databaseExposureTimestampField >= $timestamp"; + } + if (processed != null) { + if (where.isNotEmpty) { + where += ' AND '; + } + where += "$_databaseExposureProcessedField ${processed ? '<>' : '='} 0"; + } + if (where.isNotEmpty) { + query += " WHERE $where"; + } + + query += " ORDER BY $_databaseExposureTimestampField DESC"; + + List> records; + try { records = (_database != null) ? await _database.rawQuery(query) : null; } catch (e) { print(e?.toString()); } + if (records != null) { + result = []; + for (Map record in records) { + result.add(ExposureRecord( + id: record['$_databaseRowID'], + timestamp: record['$_databaseExposureTimestampField'], + rpi: record['$_databaseExposureRPIField'], + duration: record['$_databaseExposureDurationField'], + )); + } + } + + return result; + } + + Future clearLocalExposures() async { + if (_database != null) { + try { + await _database.execute("DELETE FROM $_databaseExposureTable",); + NotificationService().notify(notifyExposureUpdated, null); + } + catch(e) { print(e?.toString()); } + } + } + + /* clean up recorded RPI exposures picked up more than 14 days ago + * This method should be called strictly in "notifyTEK" + * so that every time there is a TEK update (typically per day), + * old RPI will be pruned. + */ + Future _clearExpiredLocalExposures() async { + if (_database != null) { + try { + int currentTimestamp = DateTime.now().toUtc().millisecondsSinceEpoch; + int expireTimestamp = currentTimestamp - _exposureExpireInterval; + await _database.execute("DELETE FROM $_databaseExposureTable where $_databaseExposureTimestampField < $expireTimestamp",); + } catch (e) { print(e?.toString()); } + } + } + + Future _markLocalExposureProcessed(Set exposureIds) async { + if ((_database != null) && exposureIds.isNotEmpty) { + String exposureIdsList = ''; + for (int exposureId in exposureIds) { + if (exposureIdsList.isNotEmpty) { + exposureIdsList += ', '; + } + exposureIdsList += '$exposureId'; + } + try { + await _database.execute("UPDATE $_databaseExposureTable SET $_databaseExposureProcessedField = 1 WHERE $_databaseRowID IN ($exposureIdsList)",); + NotificationService().notify(notifyExposureUpdated, null); + } + catch(e) { print(e?.toString()); } + } + } + + Future _storeLocalExposure(Map exposure) async { + int result = -1; + String rpi = (exposure != null) ? exposure['rpi'] : null; + int timestamp = (exposure != null) ? exposure['timestamp'] : null; + int duration = (exposure != null) ? exposure['duration'] : null; + Log.d('Exposure: Detected Exposure RPI: {$rpi} / duration: $duration ms'); + + if ((_database != null) && (rpi != null)) { + try { + result = await _database.insert(_databaseExposureTable, { + _databaseExposureTimestampField : timestamp ?? 0, + _databaseExposureRPIField : rpi ?? "", + _databaseExposureDurationField : duration ?? 0, + }); + if (0 <= result) { + NotificationService().notify(notifyExposureUpdated, null); + } + } + catch(e) { print(e?.toString()); } + } + return result; + } + + Future _logContact(Map exposure) async { + if ((_logSessionId != null) && (_database != null) && (exposure != null)) { + String rpi = (exposure != null) ? exposure['rpi'] : null; + int timestamp = (exposure != null) ? exposure['timestamp'] : null; + int duration = (exposure != null) ? exposure['duration'] : null; + bool isiOSRecord = (exposure != null) ? exposure['isiOSRecord'] : null; + String source = (isiOSRecord == true) ? 'iOSRecord' : 'AndroidRecord'; + String peripheralUuid = (exposure != null) ? exposure['peripheralUuid'] : null; + //int endTimestamp = (exposure != null) ? exposure['endTimestamp'] : null; + + try { + await _database.insert(_databaseContactTable, { + _databaseContactSessionIdField: _logSessionId, + _databaseContactStartTimeField: timestamp, + _databaseContactDurationField: duration, + _databaseContactRPIField: rpi, + _databaseContactSourceField: source, + _databaseContactAddressField: peripheralUuid, + }); + } catch (e) { + print(e?.toString()); + } + } + } + + Future _logRpi(Map rpiUpdates) async { + if ((_logSessionId != null) && (_database != null) && (rpiUpdates != null)) { + String rpi = (rpiUpdates != null) ? rpiUpdates['rpi'] : null; + String updateType = (rpiUpdates != null) ? rpiUpdates['updateType'] : null; + int updateTime = (rpiUpdates != null) ? rpiUpdates['timestamp'] : null; + int _i = (rpiUpdates != null) ? rpiUpdates['_i'] : null; + String tekString = (rpiUpdates != null) ? rpiUpdates['tek'] : null; + var _iTimestamp = _i * _rpiRefreshInterval; + + try { + await _database.insert(_databaseRpiTable, { + _databaseRpiSessionIdField: _logSessionId, + _databaseRpiTEKField: tekString, + _databaseRpiTEKStartTimeField: _iTimestamp, + _databaseRpiRPIField: rpi, + _databaseRpiRPIStartTimeField: updateTime, + _databaseRpiEventField: updateType, + }); + } + catch(e) { print(e?.toString()); } + } + } + + Future _logRssi(Map rssi) async { + if ((_logSessionId != null) && (_database != null) && (rssi != null)) { + int timestamp = (rssi != null) ? rssi['timestamp'] : null; + String rpi = (rssi != null) ? rssi['rpi'] : null; + int rssiVal = (rssi != null) ? rssi['rssi'] : null; + String address = (rssi != null) ? rssi['address'] : null; + bool isiOSRecord = (rssi != null) ? rssi['isiOSRecord'] : null; + String source = (isiOSRecord == true) ? 'iOSRecord' : 'AndroidRecord'; + + try { + await _database.insert(_databaseRssiTable, { + _databaseRssiSessionIdField: _logSessionId, + _databaseRssiTimestampField: timestamp, + _databaseRssiRSSIField: rssiVal, + _databaseRssiRPIField: rpi, + _databaseRssiSourceField: source, + _databaseRssiAddressField: address, + }); + } + catch(e) { print(e?.toString()); } + } + } + + // Networking + + Future reportTEKs(List teks) async { + String url = "${Config().healthUrl}/covid19/trace/report"; + String post = AppJson.encode(ExposureTEK.listToJson(teks)); + Response response = await Network().post(url, body: post, auth: NetworkAuth.App); + return (response?.statusCode == 200); + } + + Future> loadReportedTEKs({int timestamp, int dateAdded}) async { + String url = "${Config().healthUrl}/covid19/trace/exposures"; + + String params = ''; + if (timestamp != null) { + if (params.isNotEmpty) { + params += '&'; + } + params += "timestamp=$timestamp"; + } + if (dateAdded != null) { + if (params.isNotEmpty) { + params += '&'; + } + params += "date-added=$dateAdded"; + } + if (params.isNotEmpty) { + url += '?$params'; + } + + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseString != null) ? AppJson.decodeList(responseString) : null; + return (responseJson != null) ? ExposureTEK.listFromJson(responseJson) : null; + } + + // Report + + set reportTargetTimestamp(int value) { + if (_reportTargetTimestamp != value) { + Storage().exposureReportTargetTimestamp = _reportTargetTimestamp = value; + checkReport(); + } + } + + + Future checkReport() async { + + if (!_serviceEnabled || (_reportTargetTimestamp == null) || (_reportTargetTimestamp == _lastReportTimestamp)) { + return 0; + } + + if (_checkingReport == true) { + return null; + } + + Log.d('Exposure: Checking local TEKs to report...'); + _checkingReport = true; + + int minTimestamp = getThresholdTimestamp(origin: _reportTargetTimestamp); // two weeks before the target; + if ((_lastReportTimestamp != null) && (minTimestamp < _lastReportTimestamp)) { + minTimestamp = _lastReportTimestamp; + } + + int result; + await _expireTEK(); + List teks = await loadTeks(maxStamp: _reportTargetTimestamp, minStamp: minTimestamp); + if (teks == null) { + Log.d('Failed to load local TEKs'); + result = null; + } + else if (teks.isEmpty) { + Log.d('No local TEKs newer than $_reportTargetTimestamp.'); + result = 0; + } + else if (!await reportTEKs(teks)) { + Log.d('Failed to report ${teks.length} local TEKs'); + result = null; + } + else { + Log.d('Reported ${teks.length} local TEKs'); + Analytics().logHealth(action: Analytics.LogHealthReportExposuresAction); + Storage().exposureLastReportedTimestamp = _lastReportTimestamp = _reportTargetTimestamp; + result = teks.length; + } + + _checkingReport = null; + return result; + } + + // Monitor + + void _startExposuresMonitor() { + if (_serviceEnabled && (_exposuresMonitorTimer == null)) { + int monitorInterval = Config().settings['covid19ExposureMonitorTimeInterval'] ?? 900; + _exposuresMonitorTimer = Timer(Duration(seconds: monitorInterval), checkExposures); + } + } + + void _stopExposuresMonitor() { + if (_exposuresMonitorTimer != null) { + _exposuresMonitorTimer.cancel(); + _exposuresMonitorTimer = null; + } + } + + void _updateExposuresMonitor() { + if (_serviceEnabled && (_exposuresMonitorTimer == null)) { + checkExposures().then((_){ + _startExposuresMonitor(); + }); + } + else if (!_serviceEnabled && (_exposuresMonitorTimer != null)){ + _stopExposuresMonitor(); + } + } + + int get exposureMinDuration { + return _exposureMinDuration ~/ 1000; + } + + set exposureMinDuration(int value) { + if (value != null) { + _exposureMinDuration = value * 1000; + } + else { + _updateExposureMinDuration(); + } + } + + void _updateExposureMinDuration() { + _exposureMinDuration = (Config().settings['covid19ExposureServiceMinDuration'] ?? 7200) * 1000; + } + + Future checkExposures() async { + if (!_serviceEnabled || (_checkingExposures == true)) { + return null; + } + + Log.d('Exposure: Checking Infected Exposures...'); + + _checkingExposures = true; + int thresholdTimestamp = Exposure.thresholdTimestamp; + + List exposures = await Exposure().loadLocalExposures(timestamp: thresholdTimestamp, processed: false); + if (exposures == null) { + _checkingExposures = null; + Log.d('Failed to load local exposures.'); + return null; + } + else if (exposures.isEmpty) { + _checkingExposures = null; + Log.d('No local exposures for processing.'); + return 0; + } + else { + Log.d('Processing ${exposures.length} exposures newer than $thresholdTimestamp.'); + } + + List reportedTEKs = await loadReportedTEKs(timestamp: thresholdTimestamp); + if (reportedTEKs == null) { + Log.d('Failed to load reported TEKs.'); + _checkingExposures = null; + return null; + } + else if (reportedTEKs.isEmpty) { + Log.d('No TEKs newer than $thresholdTimestamp reported.'); + _checkingExposures = null; + return 0; + } + else { + Log.d('Processing ${reportedTEKs.length} TEKs newer than $thresholdTimestamp reported.'); + } + + List histories = await Health().loadCovid19History(); + + Analytics().logHealth(action: Analytics.LogHealthCheckExposuresAction); + + int detected = 0; + List results; + + + // Map scoringExposures = new Map; + // key = time interval, value = number of rpis in that time interval + Map> scoringExposures = new Map>(); + int scoringDayThreshold = _evalScoringDayThreshold(histories: histories); + + for (ExposureTEK tek in reportedTEKs) { + Map rpisMap = await _loadTekRPIs(tek); + if (rpisMap != null) { + Set rpisSet = Set.from(rpisMap.keys); + Set detectedExposures; + + DateTime exposureDateUtc; + int exposureDuration = 0; + for (ExposureRecord exposure in exposures) { + if (rpisSet.contains(exposure.rpi) && + ((exposure.timestamp + _rpiCheckExposureBuffer) >= rpisMap[exposure.rpi]) && + ((exposure.timestamp - _rpiCheckExposureBuffer - _rpiRefreshInterval) < rpisMap[exposure.rpi]) + ) { + DateTime exposureRecordDateUtc = exposure.dateUtc; + if ((exposureRecordDateUtc != null) && ((exposureDateUtc == null) || exposureRecordDateUtc.isBefore(exposureDateUtc))) { + exposureDateUtc = exposureRecordDateUtc; + } + exposureDuration += exposure.duration; + if (detectedExposures == null) { + detectedExposures = Set(); + } + detectedExposures.add(exposure.id); + + // increment the exposure in that time interval + int intervalNum = exposure.timestamp ~/ _rpiRefreshInterval; + if (intervalNum >= scoringDayThreshold) { + // filter out the date before the day threshold + Set durationRPISet = scoringExposures[intervalNum]; + if (durationRPISet == null) { + scoringExposures[intervalNum] = durationRPISet = Set(); + } + durationRPISet.add(exposure.rpi); + } + } + } + + if ((exposureDateUtc != null) && (_exposureMinDuration <= exposureDuration)) { + Covid19History result; + + Covid19History history = Covid19History.traceInList(histories, tek: tek.tek); + if (history != null) { + if ((history.dateUtc != null) && history.dateUtc.isBefore(exposureDateUtc)) { + exposureDateUtc = history.dateUtc; + } + if (history.blob?.traceDuration != null) { + exposureDuration += history.blob?.traceDuration; + } + + result = await Health().updateCovid19History( + id: history.id, + dateUtc: exposureDateUtc, + type: Covid19HistoryType.contactTrace, + blob: Covid19HistoryBlob( + traceDuration: exposureDuration, + traceTEK: tek.tek + )); + } + else { + result = await Health().addCovid19History( + dateUtc: exposureDateUtc, + type: Covid19HistoryType.contactTrace, + blob: Covid19HistoryBlob( + traceDuration: exposureDuration, + traceTEK: tek.tek + )); + } + + if (result != null) { + _markLocalExposureProcessed(detectedExposures); + if (results == null) { + results = List(); + } + results.add(result); + } + } + } + } + + if (results != null) { + NotificationService().notify(Health.notifyHistoryUpdated, null); + + String lastHealthStatus = Health().lastCovid19Status; + String newHealthStatus = lastHealthStatus; + Covid19Status status = await Health().updateStatusFromHistory(); + if (covid19HealthStatusIsValid(status?.blob?.healthStatus)) { + newHealthStatus = status?.blob?.healthStatus; + } + + for (Covid19History result in results) { + Analytics().logHealth( + action: Analytics.LogHealthContactTraceProcessedAction, + status: newHealthStatus, + prevStatus: lastHealthStatus, + attributes: { + Analytics.LogHealthDurationName: result.blob.traceDuration, + Analytics.LogHealthExposureTimestampName: result.dateUtc?.toIso8601String(), + }); + } + } + + Log.d('Detected: $detected Processed: ${results?.length ?? 0}'); + + // each time duration = 10mins. cumulative duration = 10 * #people in that interval + // finish searching, try to sum all duration: + // Zero the dose only after 2 zero intervals + // trigger the exposure notification only after there is a does above the threshold. + Log.d("scoringExposures = $scoringExposures"); + int scoringStartTime = -1, scoringEndTime = -1; + bool scoringIsExposured = false; + if (scoringExposures.isNotEmpty) { + + // there is a match + + // sort the time interval in ascending order + List enIntervalNumberList = List.from(scoringExposures.keys); + enIntervalNumberList.sort(); + + scoringStartTime = enIntervalNumberList[0]; + int lastKey = enIntervalNumberList[0] - 1; + int tempSum = 0; + + for (int k in enIntervalNumberList) { + // loop all time interval + if (k - lastKey <= 1) { + // consective time interval + tempSum += scoringExposures[k].length * 10; + } + else { + // Zero the dose only after 2 zero interval + Log.d("tempSum = $tempSum"); + scoringStartTime = k; + tempSum = scoringExposures[k].length * 10; + } + lastKey = k; + + if (tempSum > _exposureMinDuration) { + scoringIsExposured = true; + scoringEndTime = k; + Log.d("Above the threshold! Trigger exposure notification"); + break; + } + } + Log.d("tempSum = $tempSum"); + } + + // if isexposure = false, then scoringStartTime and scoringEndTime are meaningless + Log.d("is exposure = $scoringIsExposured, start = $scoringStartTime, end = $scoringEndTime"); + + _checkingExposures = null; + return detected; + } + + int _evalScoringDayThreshold({List histories}) { + int scoringDateTimestamp; + Covid19History lastTest = Covid19History.mostRecentTest(histories); + DateTime lastTestDateUtc = lastTest?.dateUtc; + if (lastTestDateUtc != null) { + int lastTestTimestamp = lastTestDateUtc.millisecondsSinceEpoch; + scoringDateTimestamp = lastTestTimestamp - _millisecondsInDay; // a day before last test timestamp + } + else { + int currentTimestamp = DateTime.now().toUtc().millisecondsSinceEpoch; + int midnightTimestamp = (currentTimestamp ~/ _millisecondsInDay) * _millisecondsInDay; + scoringDateTimestamp = midnightTimestamp - (5 * _millisecondsInDay); // five days ago midnight timestamp + } + return scoringDateTimestamp ~/ _rpiRefreshInterval; + } + + // Logging + + void startLogSession(int sessionId) { + _logSessionId = sessionId; + } + + void endLogSession(String deviceId, bool isAndroid) { + if (_logSessionId != null) { + _postSessionData(sessionId: _logSessionId, deviceId: deviceId, isAndroid: isAndroid); + _logSessionId = null; + } + } + + Future _postSessionData({int sessionId, String deviceId, bool isAndroid}) async { + List> recordRssi; + String rssiQuery = "SELECT * FROM $_databaseRssiTable WHERE $_databaseRssiSessionIdField = $sessionId"; + try { recordRssi = (_database != null) ? await _database.rawQuery(rssiQuery) : null; } catch (e) { print(e?.toString()); } + + List> recordContact; + String contactQuery = "SELECT * FROM $_databaseContactTable WHERE $_databaseContactSessionIdField = $sessionId"; + try { recordContact = (_database != null) ? await _database.rawQuery(contactQuery) : null; } catch (e) { print(e?.toString()); } + + List> recordRpi; + String rpiQuery = "SELECT * FROM $_databaseRpiTable WHERE $_databaseRpiSessionIdField = $sessionId"; + try { recordRpi = (_database != null) ? await _database.rawQuery(rpiQuery) : null; } catch (e) { print(e?.toString()); } + + Map upload = { + "deviceID": deviceId, + "isAndroid": isAndroid, + "contact": recordContact, + "rpi": recordRpi, + "rssi": recordRssi + }; + Response response = await Network().post( + 'http://ec2-18-191-37-235.us-east-2.compute.amazonaws.com:8003/PostSessionData', + body: AppJson.encode(upload), + auth: NetworkAuth.App); + return response?.statusCode == 200; + } + + + +} \ No newline at end of file diff --git a/lib/service/FirebaseMessaging.dart b/lib/service/FirebaseMessaging.dart new file mode 100644 index 00000000..13595be7 --- /dev/null +++ b/lib/service/FirebaseMessaging.dart @@ -0,0 +1,509 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:ui'; +import 'package:http/http.dart'; +import 'package:firebase_messaging/firebase_messaging.dart' as FirebaseMessagingPlugin; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/AppLivecycle.dart'; + +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/service/LocalNotifications.dart'; +import 'package:illinois/utils/Utils.dart'; + +class FirebaseMessaging with Service implements NotificationsListener { + + static const String notifyToken = "edu.illinois.rokwire.firebase.messaging.token"; + static const String notifyPopupMessage = "edu.illinois.rokwire.firebase.messaging.message.popup"; + static const String notifyScoreMessage = "edu.illinois.rokwire.firebase.messaging.message.score"; + static const String notifyConfigUpdate = "edu.illinois.rokwire.firebase.messaging.config.update"; + static const String notifyPollOpen = "edu.illinois.rokwire.firebase.messaging.poll.create"; + static const String notifyEventDetail = "edu.illinois.rokwire.firebase.messaging.event.detail"; + static const String notifyCovid19Message = "edu.illinois.rokwire.firebase.messaging.health.covid19.detail"; + static const String notifyCovid19Action = "edu.illinois.rokwire.firebase.messaging.health.covid19.action"; + static const String notifyCovid19Notification = "edu.illinois.rokwire.firebase.messaging.health.covid19.notification"; + static const String notifyAthleticsGameStarted = "edu.illinois.rokwire.firebase.messaging.athletics_game.started"; + static const String notifySettingUpdated = "edu.illinois.rokwire.firebase.messaging.setting.updated"; + + // Topic names + static const List _permanentTopis = [ + "config_update", + "popup_message", + ]; + + // Settings entry : topic name + static const Map _notifySettingTopics = { + 'notify_covid19' : 'covid19' + }; + + + String _token; + String _projectID; + DateTime _pausedDateTime; + + List> _messagesCache; + + // Singletone instance + + FirebaseMessaging._internal(); + static final FirebaseMessaging _firebase = FirebaseMessaging._internal(); + FirebaseMessagingPlugin.FirebaseMessaging _firebaseMessaging = FirebaseMessagingPlugin.FirebaseMessaging(); + + factory FirebaseMessaging() { + return _firebase; + } + + static FirebaseMessaging get instance { + return _firebase; + } + + // Public getters + + String get token => _token; + String get projectID => _projectID; + bool get hasToken => AppString.isStringNotEmpty(_token); + + // Service + + @override + void createService() { + NotificationService().subscribe(this, [ + User.notifyRolesUpdated, + User.notifyInterestsUpdated, + User.notifyUserUpdated, + User.notifyUserDeleted, + AppLivecycle.notifyStateChanged, + LocalNotifications.notifySelected + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + + // Cache messages until UI is displayed + _messagesCache = List>(); + + _firebaseMessaging.configure( + onMessage: _onFirebaseMessage, + onBackgroundMessage: null, // causes exception in FirebaseMessaging plugin + onLaunch: _onFirebaseLaunch, + onResume: _onFirebaseResume, + ); + + _firebaseMessaging.getToken().then((String token) { + _token = token; + Log.d('FCM: token: $token'); + NotificationService().notify(notifyToken, null); + _updateSubscriptions(); + }); + + //The project id is not given via the lib so we need to get it via NativeCommunicator + NativeCommunicator().queryFirebaseInfo().then((String info) { + _projectID = info; + }); + } + + @override + void initServiceUI() { + _processCachedMessages(); + } + + @override + Set get serviceDependsOn { + return Set.from([Storage(), Config(), User()]); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == LocalNotifications.notifySelected) { + _processDataMessage(AppJson.decode(param)); + } + else if (name == User.notifyRolesUpdated) { + _updateRolesSubscriptions(); + } + else if (name == User.notifyUserUpdated) { + _updateSubscriptions(); + } + else if (name == User.notifyUserDeleted) { + _updateSubscriptions(); + } + else if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _pausedDateTime = DateTime.now(); + } + else if (state == AppLifecycleState.resumed) { + if (_pausedDateTime != null) { + Duration pausedDuration = DateTime.now().difference(_pausedDateTime); + if (Config().refreshTimeout < pausedDuration.inSeconds) { + _updateSubscriptions(); + } + } + } + } + + // Subscription APIs + + Future subscribeToTopic(String topic) async { + if (topic == null) { + return false; + } + + if (_token == null) { + Log.e("FCM: Unable to subscribe to $topic topic (missing token)"); + return false; + } + + try { + String url = (Config().sportsServiceUrl != null) ? "${Config().sportsServiceUrl}/api/subscribe" : null; + String body = json.encode({'token': _token, 'topic': topic}); + Response response = await Network().post(url, body: body, auth: NetworkAuth.App); + if ((response != null) && (response.statusCode == 200)) { + Log.d("FCM: Succesfully subscribed for $topic topic"); + Storage().addFirebaseSubscriptionTopic(topic); + return true; + } else { + Log.e("FCM: Error occured on subscribing for $topic topic"); + return false; + } + } catch (e) { + Log.e(e.toString()); + return false; + } + } + + Future unsubscribeFromTopic(String topic) async { + if (topic == null) { + return false; + } + + if (_token == null) { + Log.e("FCM: Unable to unsubscribe to $topic topic (missing token)"); + return false; + } + + try { + String url = (Config().sportsServiceUrl != null) ? "${Config().sportsServiceUrl}/api/unsubscribe" : null; + String body = json.encode({'token': _token, 'topic': topic}); + Response response = await Network().post(url, body: body, auth: NetworkAuth.App); + if ((response != null) && (response.statusCode == 200)) { + Log.d("FCM: Succesfully unsubscribed from $topic topic"); + Storage().removeFirebaseSubscriptionTopic(topic); + return true; + } else { + Log.e("FCM: Error occured on unsubscribe from $topic topic"); + return false; + } + } catch (e) { + Log.e(e.toString()); + return false; + } + } + + Future send({String topic, dynamic message}) async { + try { + String url = (Config().sportsServiceUrl != null) ? "${Config().sportsServiceUrl}/api/message" : null; + String body = json.encode({'topic': topic, 'message': message}); + final response = await Network().post(url, timeout: 10, body: body, auth: NetworkAuth.App, headers: { "Accept": "application/json", "content-type": "application/json" }); + if ((response != null) && (response.statusCode == 200)) { + return true; + } else { + return false; + } + } catch (e) { + Log.e(e.toString()); + return false; + } + } + + // Message Processing + + Future _onFirebaseMessage(Map message) async { + Log.d("FCM: onFirebaseMessage"); + _onMessageProcess(message); + } + + Future _onFirebaseLaunch(Map message) async { + Log.d("FCM: onFirebaseLaunch"); + _onMessageProcess(message); + } + + Future _onFirebaseResume(Map message) async { + Log.d("FCM: onFirebaseResume"); + _onMessageProcess(message); + } + + ///We need to process Android and iOS differently as the plugin gives different format for the both platforms. + + ///Android + ///{ + /// notification: {title: null, body: null}, + /// data: {Period: 1, VisitingScore: 20, HomeScore: 14, Path: football, Type: football, IsComplete: false, ClockSeconds: -1, Custom: {"Possession":"","LastPlay":"","Clock":"","Phase":"Pregame"}, GameId: 16692, HasStarted: false} + ///} + + ///iOS + ///{GameId: 16692, IsComplete: false, gcm.message_id: 1572250193655080, VisitingScore: 20, HomeScore: 14, Custom: {"Possession":"","LastPlay":"","Clock":"","Phase":"Pregame"}, Type: football, Path: football, aps: {content-available: 1}, ClockSeconds: -1, HasStarted: false, Period: 1} + void _onMessageProcess(Map message) { + if (message != null) { + if (_messagesCache != null) { + Log.d("FCM: cacheMessage: $message"); + _messagesCache.add(message); + } + else { + _processMessage(message); + } + } + } + + void _processMessage(Map message) { + Log.d("FCM: onMessageProcess: $message"); + if (message != null) { + try { + if (Platform.isIOS) { + Log.d("FCM: iOS message"); + _processDataMessage(message.cast()); + } else { + dynamic data = message["data"]; + dynamic notification = message["notification"]; + String title = (notification != null) ? notification["title"] : null; + String body = (notification != null) ? notification["body"] : null; + if (AppString.isStringNotEmpty(title) || AppString.isStringNotEmpty(body)) { + Log.d("FCM: Android notification message"); + //Explicitly show it only when in foreground + String notificationPayload = (data != null) ? json.encode(data) : null; + LocalNotifications().showNotification(title: title, message: body, payload: notificationPayload); + } + else if (data != null) { + Log.d("FCM: Android data message"); + _processDataMessage(data.cast()); + } + } + } + catch(e) { + print(e.toString()); + } + } + } + + void _processDataMessage(Map data) { + String type = _getMessageType(data); + if (type == "config_update") { + _onConfigUpdate(data); + } + else if (type == "popup_message") { + NotificationService().notify(notifyPopupMessage, data); + } + else if (type == "poll_open") { + NotificationService().notify(notifyPollOpen, data); + } + else if (type == "event_detail") { + NotificationService().notify(notifyEventDetail, data); + } + else if ((type == "health.covid19.message") || (type == "health.covid19")) { + NotificationService().notify(notifyCovid19Message, data); + } + else if (type == "health.covid19.action") { + NotificationService().notify(notifyCovid19Action, data); + } + else if (type == "health.covid19.notification") { + NotificationService().notify(notifyCovid19Notification, data); + } + else if (type == "athletics_game_started") { + NotificationService().notify(notifyAthleticsGameStarted, data); + } + else if (_isScoreTypeMessage(type)) { + NotificationService().notify(notifyScoreMessage, data); + } + else { + Log.d("FCM: unknown message type: $type"); + } + } + + String _getMessageType(Map data) { + if (data == null) + return null; + + //1. check type + String type = data["type"]; + if (type != null) + return type; + + //2. check Type - deprecated! + String type2 = data["Type"]; + if (type2 != null) + return type2; + + //3. check Path - deprecated! + String path = data["Path"]; + if (path != null) { + String gameId = data['GameId']; + dynamic hasStarted = data['HasStarted']; + // Handle 'Game Started / Ended' notification which does not contain key 'HasStarted' + if (AppString.isStringNotEmpty(gameId) && (hasStarted == null)) { + return 'athletics_game_started'; + } else { + return path; + } + } + + //treat everything else as config update - the backend gives it without "type"! + return "config_update"; + } + + bool _isScoreTypeMessage(String type) { + return type == "football" || + type == "mbball" || + type == "wbball" || + type == "mvball" || + type == "wvball" || + type == "mtennis" || + type == "wtennis" || + type == "baseball" || + type == "softball" || + type == "wsoc"; + } + + void _onConfigUpdate(Map data) { + int interval = 5 * 60; // 5 minutes + var rng = new Random(); + int delay = rng.nextInt(interval); + Log.d("FCM: Scheduled config update after ${delay.toString()} seconds"); + Timer(Duration(seconds: delay), () { + Log.d("FCM: Perform config update"); + NotificationService().notify(notifyConfigUpdate, data); + }); + } + + void _processCachedMessages() { + if (_messagesCache != null) { + List> messagesCache = _messagesCache; + _messagesCache = null; + + for (Map message in messagesCache) { + _processMessage(message); + } + } + } + + // Settings topics + + bool get notifyCovid19 { return _getNotifySetting('notify_covid19'); } + set notifyCovid19(bool value) { _setNotifySetting('notify_covid19', value); } + + bool get _notifySettingsAvailable { + return User().privacyMatch(4); + } + + bool _getNotifySetting(String name) { + if (_notifySettingsAvailable) { + return Storage().getNotifySetting(name) ?? true; + } + else { + return false; + } + } + + void _setNotifySetting(String name, bool value) { + if (_notifySettingsAvailable && (_getNotifySetting(name) != value)) { + Storage().setNotifySetting(name, value); + NotificationService().notify(notifySettingUpdated, name); + + Set subscribedTopis = Storage().firebaseSubscriptionTopis; + _processNotifySettingSubscription(topic: _notifySettingTopics[name], value: value, subscribedTopis: subscribedTopis); + } + } + + // Subscription Management + + void _updateSubscriptions() { + if (hasToken) { + Set subscribedTopis = Storage().firebaseSubscriptionTopis; + _processPermanentSubscriptions(subscribedTopis: subscribedTopis); + _processRolesSubscriptions(subscribedTopis: subscribedTopis); + _processNotifySettingsSubscriptions(subscribedTopis: subscribedTopis); + } + } + + void _updateRolesSubscriptions() { + if (hasToken) { + _processRolesSubscriptions(subscribedTopis: Storage().firebaseSubscriptionTopis); + } + } + + void _processPermanentSubscriptions({Set subscribedTopis}) { + for (String permanentTopic in _permanentTopis) { + if ((subscribedTopis == null) || !subscribedTopis.contains(permanentTopic)) { + subscribeToTopic(permanentTopic); + } + } + } + + void _processRolesSubscriptions({Set subscribedTopis}) { + Set roles = User().roles; + for (UserRole role in UserRole.values) { + String roleTopic = role.toString(); + bool roleSubscribed = (subscribedTopis != null) && subscribedTopis.contains(roleTopic); + bool roleSelected = (roles != null) && roles.contains(role); + if (roleSelected && !roleSubscribed) { + subscribeToTopic(roleTopic); + } + else if (!roleSelected && roleSubscribed) { + unsubscribeFromTopic(roleTopic); + } + } + } + + void _processNotifySettingsSubscriptions({Set subscribedTopis}) { + _notifySettingTopics.forEach((String setting, String topic) { + bool value = _getNotifySetting(setting); + _processNotifySettingSubscription(topic: topic, value: value, subscribedTopis: subscribedTopis); + }); + } + + void _processNotifySettingSubscription({String topic, bool value, Set subscribedTopis}) { + if (topic != null) { + bool itemSubscribed = (subscribedTopis != null) && subscribedTopis.contains(topic); + if (value && !itemSubscribed) { + subscribeToTopic(topic); + } + else if (!value && itemSubscribed) { + unsubscribeFromTopic(topic); + } + } + } +} diff --git a/lib/service/FlexUI.dart b/lib/service/FlexUI.dart new file mode 100644 index 00000000..0e60027f --- /dev/null +++ b/lib/service/FlexUI.dart @@ -0,0 +1,448 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:http/http.dart' as Http; + +import 'package:collection/collection.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +class FlexUI with Service implements NotificationsListener { + + static const String notifyChanged = "edu.illinois.rokwire.flexui.changed"; + + static const String _flexUIName = "flexUI.json"; + + Map _content; + Set _features; + Http.Client _httpClient; + String _dataVersion; + File _cacheFile; + DateTime _pausedDateTime; + + // Singleton Factory + + FlexUI._internal(); + static final FlexUI _instance = FlexUI._internal(); + + factory FlexUI() { + return _instance; + } + + FlexUI get instance { + return _instance; + } + + // Service + + @override + void createService() { + NotificationService().subscribe(this,[ + User.notifyUserUpdated, + User.notifyRolesUpdated, + Auth.notifyAuthTokenChanged, + Auth.notifyCardChanged, + Auth.notifyUserPiiDataChanged, + AppLivecycle.notifyStateChanged, + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + _dataVersion = AppVersion.majorVersion(Config().appVersion, 2); + _cacheFile = await _getCacheFile(); + _content = await _loadContentFromCache(); + if (_content == null) { + await _initFromNet(); + } + else { + _features = _buildFeatures(_content); + _updateFromNet(); + } + } + + @override + Set get serviceDependsOn { + return Set.from([Config(), User(), Auth()]); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if ((name == User.notifyUserUpdated) || + (name == User.notifyRolesUpdated) || + (name == Auth.notifyAuthTokenChanged) || + (name == Auth.notifyCardChanged) || + (name == Auth.notifyUserPiiDataChanged)) + { + _updateFromNet(); + } + else if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _pausedDateTime = DateTime.now(); + } + else if (state == AppLifecycleState.resumed) { + if (_pausedDateTime != null) { + Duration pausedDuration = DateTime.now().difference(_pausedDateTime); + if (Config().refreshTimeout < pausedDuration.inSeconds) { + _updateFromNet(); + } + } + } + } + + // Flex UI + + Future _getCacheFile() async { + Directory appDocDir = await getApplicationDocumentsDirectory(); + String cacheFilePath = join(appDocDir.path, _flexUIName); + return File(cacheFilePath); + } + + Future _loadContentStringFromCache() async { + return ((_cacheFile != null) && await _cacheFile.exists()) ? await _cacheFile.readAsString() : null; + } + + Future _saveContentStringToCache(String contentString) async { + await _cacheFile?.writeAsString(contentString ?? '', flush: true); + } + + Future> _loadContentFromCache() async { + return _contentFromJsonString(await _loadContentStringFromCache()); + } + + Future _loadContentStringFromNet() async { + + try { return AppJson.encode(await _localBuild()); } catch (e) { print(e.toString()); } + + Http.Client httpClient; + + if (_httpClient != null) { + _httpClient.close(); + _httpClient = null; + } + + String url = '${Config().talentChooserUrl}/ui-content?data-version=$_dataVersion'; + + Map post = { + 'user': User().data?.toShortJson(), + 'auth_token': Auth().authToken?.toJson(), + 'auth_user': Auth().authInfo?.toJson(), + 'card': Auth().authCard?.toShortJson(), + 'pii': Auth().userPiiData?.toShortJson(), + 'platform': platformJson, + }; + + try { + String body = json.encode(post); + _httpClient = httpClient = Http.Client(); + Http.Response response = await Network().get(url, body:body, auth: NetworkAuth.App, client: _httpClient); + int responseCode = response?.statusCode ?? -1; + String responseBody = response?.body; + Log.d('FlexUI: GET $url\n$body\nResponse $responseCode:\n$responseBody\n'); + return ((response != null) && (responseCode == 200)) ? responseBody : null; + } catch (e) { + print(e.toString()); + } + finally { + if (_httpClient == httpClient) { + _httpClient = null; + } + } + + return null; + } + + Future _initFromNet() async { + String jsonString = await _loadContentStringFromNet(); + Map content = _contentFromJsonString(jsonString); + + if (content == null) { + content = await _localBuild(); + jsonString = AppJson.encode(content); + } + + if (content != null) { + _content = content; + _saveContentStringToCache(jsonString); + + _features = _buildFeatures(_content); + NotificationService().notify(notifyChanged, null); + } + } + + Future _updateFromNet() async { + String jsonString = await _loadContentStringFromNet(); + Map content = _contentFromJsonString(jsonString); + + //NB: This is not good to go in app release. + if (content == null) { + content = await _localBuild(); + jsonString = AppJson.encode(content); + } + + if ((content != null) && ((_content == null) || !DeepCollectionEquality().equals(_content, content))) { + _content = content; + _saveContentStringToCache(jsonString); + + _features = _buildFeatures(_content); + NotificationService().notify(notifyChanged, null); + } + } + + static Map _contentFromJsonString(String jsonString) { + return AppJson.decode(jsonString); + } + + static Set _buildFeatures(Map content) { + dynamic featuresList = (content != null) ? content['features'] : null; + return (featuresList is Iterable) ? Set.from(featuresList) : null; + } + + static Map get platformJson { + return { + 'os': Platform.operatingSystem, + }; + } + + // Content + + Map get content { + return _content; + } + + dynamic operator [](dynamic key) { + return (_content != null) ? _content[key] : null; + } + + Set get features { + return _features; + } + + bool hasFeature(String feature) { + return (_features == null) || _features.contains(feature); + } + + Future update() async { + return _updateFromNet(); + } + +// Local Build + + static Future> _localBuild() async { + String flexUIString = await rootBundle.loadString('assets/$_flexUIName'); + Map flexUI = AppJson.decodeMap(flexUIString); + Map contents = flexUI['content']; + Map rules = flexUI['rules']; + + Map result = Map(); + contents.forEach((String key, dynamic list) { + List resultList = List(); + for (String entry in list) { + if (_localeIsEntryAvailable(entry, group: key, rules: rules)) { + resultList.add(entry); + } + } + result[key] = resultList; + }); + + return result; + } + + static bool _localeIsEntryAvailable(String entry, { String group, Map rules }) { + + String pathEntry = (group != null) ? '$group.$entry' : null; + + Map roleRules = rules['roles']; + dynamic roleRule = (roleRules != null) ? (((pathEntry != null) ? roleRules[pathEntry] : null) ?? roleRules[entry]) : null; + if ((roleRule != null) && !_localeEvalRoleRule(roleRule)) { + return false; + } + + Map privacyRules = rules['privacy']; + dynamic privacyRule = (privacyRules != null) ? (((pathEntry != null) ? privacyRules[pathEntry] : null) ?? privacyRules[entry]) : null; + if ((privacyRule != null) && !_localeEvalPrivacyRule(privacyRule)) { + return false; + } + + Map authRules = rules['auth']; + dynamic authRule = (authRules != null) ? (((pathEntry != null) ? authRules[pathEntry] : null) ?? authRules[entry]) : null; + if ((authRule != null) && !_localeEvalAuthRule(authRule)) { + return false; + } + + Map platformRules = rules['platform']; + dynamic platformRule = (platformRules != null) ? (((pathEntry != null) ? platformRules[pathEntry] : null) ?? platformRules[entry]) : null; + if ((platformRule != null) && !_localeEvalPlatformRule(platformRule)) { + return false; + } + + Map enableRules = rules['enable']; + dynamic enableRule = (enableRules != null) ? (((pathEntry != null) ? enableRules[pathEntry] : null) ?? enableRules[entry]) : null; + if ((enableRule != null) && !_localeEvalEnableRule(enableRule)) { + return false; + } + + return true; + } + + static bool _localeEvalRoleRule(dynamic roleRule) { + + if (roleRule is String) { + + if (roleRule == 'TRUE') { + return true; + } + if (roleRule == 'FALSE') { + return false; + } + + UserRole userRole = UserRole.fromString(roleRule); + if (userRole != null) { + Set userRoles = User().roles; + return (userRoles != null) && (userRoles.contains(userRole)); + } + } + + if (roleRule is List) { + + if (roleRule.length == 1) { + return _localeEvalRoleRule(roleRule[0]); + } + + if (roleRule.length == 2) { + dynamic operation = roleRule[0]; + dynamic argument = roleRule[1]; + if (operation is String) { + if (operation == 'NOT') { + return !_localeEvalRoleRule(argument); + } + } + } + + if (roleRule.length > 2) { + bool result = _localeEvalRoleRule(roleRule[0]); + for (int index = 1; (index + 1) < roleRule.length; index += 2) { + dynamic operation = roleRule[index]; + dynamic argument = roleRule[index + 1]; + if (operation is String) { + if (operation == 'AND') { + result = result && _localeEvalRoleRule(argument); + } + else if (operation == 'OR') { + result = result || _localeEvalRoleRule(argument); + } + } + } + return result; + } + } + + return true; // allow everything that is not defined or we do not understand + } + + static bool _localeEvalPrivacyRule(dynamic privacyRule) { + return (privacyRule is int) ? User().privacyMatch(privacyRule) : true; // allow everything that is not defined or we do not understand + } + + static bool _localeEvalAuthRule(dynamic authRule) { + bool result = true; // allow everything that is not defined or we do not understand + if (authRule is Map) { + authRule.forEach((dynamic key, dynamic value) { + if (key is String) { + if ((key == 'loggedIn') && (value is bool)) { + result = result && (Auth().isLoggedIn == value); + } + else if ((key == 'shibbolethLoggedIn') && (value is bool)) { + result = result && (Auth().isShibbolethLoggedIn == value); + } + else if ((key == 'phoneLoggedIn') && (value is bool)) { + result = result && (Auth().isPhoneLoggedIn == value); + } + + else if ((key == 'shibbolethMemberOf') && (value is String)) { + result = result && Auth().isMemberOf(value); + } + else if ((key == 'eventEditor') && (value is bool)) { + result = result && (Auth().isEventEditor == value); + } + else if ((key == 'stadiumPollManager') && (value is bool)) { + result = result && (Auth().isStadiumPollManager == value); + } + + else if ((key == 'iCard') && (value is bool)) { + result = result && ((Auth().authCard != null) == value); + } + else if ((key == 'iCardNum') && (value is bool)) { + result = result && ((0 < (Auth().authCard?.cardNumber?.length ?? 0)) == value); + } + else if ((key == 'iCardLibraryNum') && (value is bool)) { + result = result && ((0 < (Auth().authCard?.libraryNumber?.length ?? 0)) == value); + } + } + }); + } + return result; + } + + static bool _localeEvalPlatformRule(dynamic platformRule) { + bool result = true; // allow everything that is not defined or we do not understand + if (platformRule is Map) { + platformRule.forEach((dynamic key, dynamic value) { + if (key is String) { + if (key == 'os') { + if (value is List) { + result = result && value.contains(Platform.operatingSystem); + } + else if (value is String) { + result = result && (value == Platform.operatingSystem); + } + } + } + }); + } + return result; + } + + static bool _localeEvalEnableRule(dynamic enableRule) { + return (enableRule is bool) ? enableRule : true; // allow everything that is not defined or we do not understand + } +} \ No newline at end of file diff --git a/lib/service/Gallery.dart b/lib/service/Gallery.dart new file mode 100644 index 00000000..217dd45f --- /dev/null +++ b/lib/service/Gallery.dart @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:typed_data'; +import 'package:flutter/services.dart'; + + +class Gallery{ + + static const MethodChannel _channel = const MethodChannel("edu.illinois.covid/gallery"); + + static const String _storeMethodName = 'store'; + + static const String _bytesParamName = 'bytes'; + static const String _nameParamName = 'name'; + + static final Gallery _instance = Gallery._internal(); + + factory Gallery() { + return _instance; + } + + Gallery._internal(); + + Future storeImage({Uint8List imageBytes, String name}) async{ + return await _channel.invokeMethod(_storeMethodName,{ + _bytesParamName: imageBytes, + _nameParamName: name + }); + } +} \ No newline at end of file diff --git a/lib/service/Health.dart b/lib/service/Health.dart new file mode 100644 index 00000000..7778df23 --- /dev/null +++ b/lib/service/Health.dart @@ -0,0 +1,1688 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +//TMP: import 'package:flutter/services.dart' show rootBundle; +import 'package:http/http.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/model/Health2.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/BluetoothServices.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Exposure.dart'; +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/LocationServices.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/utils/Crypt.dart'; +import 'package:illinois/utils/Utils.dart'; +import "package:pointycastle/export.dart"; + +class Health with Service implements NotificationsListener { + + static const String notifyCountyChanged = "edu.illinois.rokwire.health.county.changed"; + static const String notifyStatusChanged = "edu.illinois.rokwire.health.status.changed"; + static const String notifyStatusUpdated = "edu.illinois.rokwire.health.status.updated"; + static const String notifyHistoryUpdated = "edu.illinois.rokwire.health.history.updated"; + static const String notifyUserUpdated = "edu.illinois.rokwire.health.user.updated"; + static const String notifyUserPrivateKeyUpdated = "edu.illinois.rokwire.health.user.private_key.updated"; + + static const String notifyCountyStatusAvailable = "edu.illinois.rokwire.health.county.status.available"; + static const String notifyUpdatedHistoryAvailable = "edu.illinois.rokwire.health.updated.history.available"; + + static const String notifyHealthStatusChanged = "edu.illinois.rokwire.health.health_status.changed"; + + + HealthUser _user; + PrivateKey _userPrivateKey; + PublicKey _servicePublicKey; + + String _currentCountyId; + DateTime _pausedDateTime; + + bool _processingCountyStatus; + bool _loadingUpdatedHistory; + + final int _rulesVersion = 2; + + // Singletone Instance + + static final Health _instance = Health._internal(); + + factory Health() { + return _instance; + } + + Health._internal(); + + + + // Service + + @override + void createService() { + NotificationService().subscribe(this, [ + AppLivecycle.notifyStateChanged, + Auth.notifyLoginChanged, + Config.notifyConfigChanged, + FirebaseMessaging.notifyCovid19Action, + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + _currentCountyId = Storage().currentHealthCountyId; + _user = _loadUserFromStorage(); + _servicePublicKey = RsaKeyHelper.parsePublicKeyFromPem(Config().healthPublicKey); + _userPrivateKey = await _rsaUserPrivateKey; + _refreshUser(); + } + + @override + void initServiceUI() { + this.currentCountyStatus; + } + + @override + Set get serviceDependsOn { + return Set.from([Storage(), Config(), User(), Auth(), NativeCommunicator()]); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + else if (name == Auth.notifyLoginChanged) { + _onUserLoginChanged(); + } + else if (name == Config.notifyConfigChanged) { + _servicePublicKey = RsaKeyHelper.parsePublicKeyFromPem(Config().healthPublicKey); + } + else if (name == FirebaseMessaging.notifyCovid19Action) { + processAction(param); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _pausedDateTime = DateTime.now(); + } + else if (state == AppLifecycleState.resumed) { + if (_pausedDateTime != null) { + Duration pausedDuration = DateTime.now().difference(_pausedDateTime); + if (Config().refreshTimeout < pausedDuration.inSeconds) { + this.currentCountyStatus; + _refreshUser(); + } + } + } + } + + void _onUserLoginChanged() { + + if (this._isAuthenticated) { + _refreshRSAPrivateKey().then((_) { + _refreshUser().then((_) { + this.currentCountyStatus; + }); + }); + } + else { + _lastCovid19Status = null; + _healthUserPrivateKey = null; + _healthUser = null; + + NotificationService().notify(notifyStatusChanged, null); + NotificationService().notify(notifyHistoryUpdated, null); + // NotificationService().notify(notifyUserUpdated, null); + // NotificationService().notify(notifyUserPrivateKeyUpdated, null); + } + } + + // Network API: Covid19News, Covid19FAQ, Covid19Resource + + Future> loadCovid19News() async { + List newsList; + try { + int limit = Config().settings['covid19NewsLimit'] ?? 10; + String url = "${Config().healthUrl}/covid19/news?limit=$limit"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = ((response != null) && (response.statusCode == 200)) ? response.body : null; + List responseList = AppJson.decode(responseString); + if (responseList != null) { + newsList = List(); + for (dynamic responseEntry in responseList) { + newsList.add(Covid19News.fromJson(responseEntry)); + } + } + } + catch(e) { + print(e.toString()); + } + return newsList; + } + + Future loadCovid19FAQs() async { + try { + String url = "${Config().healthUrl}/covid19/faq"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = ((response != null) && (response.statusCode == 200)) ? response.body : null; + Map responseJson = AppJson.decode(responseString); + return (responseJson != null) ? Covid19FAQ.fromJson(responseJson) : null; + } + catch(e) { + print(e.toString()); + } + return null; + } + + Future> loadCovid19Resources() async { + List resourcesList; + try { + String url = "${Config().healthUrl}/covid19/resources"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = ((response != null) && (response.statusCode == 200)) ? response.body : null; + List responseList = AppJson.decode(responseString); + if (responseList != null) { + resourcesList = List(); + for (dynamic responseEntry in responseList) { + resourcesList.add(Covid19Resource.fromJson(responseEntry)); + } + } + } + catch(e) { + print(e.toString()); + } + return resourcesList; + } + + // Network API: Covid19Status + + Future loadCovid19Status() async { + return _loadCovid19Status(); + } + + Future _loadCovid19Status() async { + if (this._isLoggedIn) { + String url = "${Config().healthUrl}/covid19/v2/app-version/2.2/statuses"; + Response response = await Network().get(url, auth: NetworkAuth.User); + if (response?.statusCode == 200) { + Covid19Status status = await Covid19Status.decryptedFromJson(AppJson.decodeMap(response.body), _userPrivateKey); + _lastCovid19Status = status?.blob?.healthStatus; + return status; + } + } + return null; + } + + Future _updateCovid19Status(Covid19Status status) async { + if (this._isLoggedIn) { + String url = "${Config().healthUrl}/covid19/v2/app-version/2.2/statuses"; + Covid19Status encryptedStatus = await status?.encrypted(_user?.publicKey); + String post = AppJson.encode(encryptedStatus?.toJson()); + Response response = await Network().put(url, body: post, auth: NetworkAuth.User); + if (response?.statusCode == 200) { + _lastCovid19Status = status?.blob?.healthStatus; + _updateExposureReportTarget(status: status); + return true; + } + } + return false; + } + + Future _clearCovid19Status() async { + if (this._isAuthenticated) { + String url = "${Config().healthUrl}/covid19/v2/app-version/2.2/statuses"; + Response response = await Network().delete(url, auth: NetworkAuth.User); + return response?.statusCode == 200; + } + return false; + } + + // Network API: Covid19History + + Future> loadCovid19History() async { + if (this._isLoggedIn) { + String url = "${Config().healthUrl}/covid19/v2/histories"; + Response response = await Network().get(url, auth: NetworkAuth.User); + String responseString = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseString != null) ? AppJson.decodeList(responseString) : null; + return (responseJson != null) ? await Covid19History.listFromJson(responseJson, _decryptHistoryKeys) : null; + } + return null; + } + + Future addCovid19History({DateTime dateUtc, Covid19HistoryType type, Covid19HistoryBlob blob}) async { + return this._isLoggedIn ? await _addCovid19History(await Covid19History.encryptedFromBlob( + dateUtc: dateUtc, + type: type, + blob: blob, + publicKey: _user?.publicKey + )) : null; + } + + Future _addCovid19History(Covid19History history) async { + if (this._isLoggedIn) { + String url = "${Config().healthUrl}/covid19/v2/histories"; + String post = AppJson.encode(history?.toJson()); + Response response = await Network().post(url, body: post, auth: NetworkAuth.User); + String responseString = (response?.statusCode == 200) ? response.body : null; + return await Covid19History.decryptedFromJson(AppJson.decode(responseString), _decryptHistoryKeys); + } + return null; + } + + Future updateCovid19History({String id, String userId, DateTime dateUtc, Covid19HistoryType type, Covid19HistoryBlob blob}) async { + return this._isLoggedIn ? _updateCovid19History(await Covid19History.encryptedFromBlob( + id: id, + dateUtc: dateUtc, + type: type, + blob: blob, + publicKey: _user?.publicKey + )) : null; + } + + Future _updateCovid19History(Covid19History history) async { + if (this._isLoggedIn) { + String url = "${Config().healthUrl}/covid19/v2/histories/${history.id}"; + String post = AppJson.encode(history?.toJson()); + Response response = await Network().put(url, body: post, auth: NetworkAuth.User); + String responseString = (response?.statusCode == 200) ? response.body : null; + return await Covid19History.decryptedFromJson(AppJson.decode(responseString), _decryptHistoryKeys); + } + return null; + } + + Future _clearCovid19History() async { + if (this._isAuthenticated) { + String url = "${Config().healthUrl}/covid19/v2/histories"; + Response response = await Network().delete(url, auth: NetworkAuth.User); + return response?.statusCode == 200; + } + return false; + } + + Map get _decryptHistoryKeys { + return { + Covid19HistoryType.test : _userPrivateKey, + Covid19HistoryType.manualTestVerified : _userPrivateKey, + Covid19HistoryType.manualTestNotVerified : null, // _servicePrivateKey NA + Covid19HistoryType.symptoms : _userPrivateKey, + Covid19HistoryType.contactTrace : _userPrivateKey, + Covid19HistoryType.action : _userPrivateKey, + + }; + } + + // Network API: Covid19Event + + Future> loadCovid19Events({bool processed}) async { + if (this._isLoggedIn) { + String url = "${Config().healthUrl}/covid19/ctests"; + String params = ""; + if (processed != null) { + if (0 < params.length) { + params += "&"; + } + params += "processed=$processed"; + } + if (0 < params.length) { + url += "?$params"; + } + Response response = await Network().get(url, auth: NetworkAuth.User); + String responseString = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseString != null) ? AppJson.decodeList(responseString) : null; + return (responseJson != null) ? await Covid19Event.listFromJson(responseJson, _userPrivateKey) : null; + } + return null; + } + + Future clearCovid19Tests() async { + if (this._isAuthenticated) { + String url = "${Config().healthUrl}/covid19/ctests"; + Response response = await Network().delete(url, auth: NetworkAuth.User); + return (response?.statusCode == 200); + } + return false; + } + + // Network API: HealthServiceProvider + + Future> loadHealthServiceProviders({String countyId}) async { + String url = "${Config().healthUrl}/covid19/providers"; + + if(countyId != null) + url += "/county/$countyId"; + + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseString != null) ? AppJson.decode(responseString) : null; + return (responseJson != null) ? HealthServiceProvider.listFromJson(responseJson) : null; + } + + Future>> loadHealthServiceProvidersForCounties(Set countyIds) async { + if (AppCollection.isCollectionEmpty(countyIds)) { + return null; + } + String idsToString = countyIds.join(','); + String url = "${Config().healthUrl}/covid19/providers?county-ids=$idsToString"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = (response?.statusCode == 200) ? response.body : null; + Map responseJson = AppJson.decodeMap(responseString); + if (responseJson == null) { + return null; + } + Map> countyProvidersMap = Map(); + for (String countyId in responseJson.keys) { + dynamic providersJson = responseJson[countyId]; + List providers = HealthServiceProvider.listFromJson(providersJson); + countyProvidersMap[countyId] = providers; + } + return countyProvidersMap; + } + + // Network API: HealthServiceLocation + + Future> loadHealthServiceLocations({String countyId, String providerId})async{ + String url = "${Config().healthUrl}/covid19/locations"; + + if(countyId != null) + url += "?county-id=$countyId"; + if(providerId!=null) + url += (countyId!=null ? "&":"?")+"provider-id=$providerId"; + + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseString != null) ? AppJson.decode(responseString) : null; + return (responseJson != null) ? HealthServiceLocation.listFromJson(responseJson) : null; + } + + Future loadHealthServiceLocation({String locationId})async{ + String url = "${Config().healthUrl}/covid19/locations"; + + if(locationId!=null) + url+= "/$locationId"; + + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = (response?.statusCode == 200) ? response.body : null; + Map responseJson = (responseString != null) ? AppJson.decode(responseString) : null; + return (responseJson != null) ? HealthServiceLocation.fromJson(responseJson) : null; + } + + // Network API: HealthTestType + + Future> loadHealthServiceTestTypes({List typeIds})async{ + String url = "${Config().healthUrl}/covid19/test-types"; + + if(typeIds?.isNotEmpty??false) { + url += "?ids="; + typeIds.forEach((id){ + url+="$id,"; + }); + url = url.substring(0,url.length-1); + } + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseString != null) ? AppJson.decode(responseString) : null; + return (responseJson != null) ? HealthTestType.listFromJson(responseJson) : null; + } + + // Network API: HealthSymptomsGroup + + Future> loadSymptomsGroups() async { + switch(_rulesVersion) { + case 1: return _loadSymptomsGroups1(); + case 2: return _loadSymptomsGroups2(); + default: return null; + } + } + + Future> _loadSymptomsGroups1() async { + String url = "${Config().healthUrl}/covid19/symptom-groups"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseString = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseString != null) ? AppJson.decodeList(responseString) : null; + return (responseJson != null) ? HealthSymptomsGroup.listFromJson(responseJson) : null; + } + + Future> _loadSymptomsGroups2() async { + + if (_currentCountyId != null) { + HealthRulesSet2 rules = await _loadRules2(countyId: _currentCountyId); + return rules?.symptoms?.groups; + } + else { + String url = "${Config().health2Url}/symptoms/symptoms.json"; + Response response = await Network().get(url); + String responseBody = (response?.statusCode == 200) ? response.body : null; + //TMP:String responseBody = await rootBundle.loadString('assets/sample.health.symptoms.json'); + List responseJson = (responseBody != null) ? AppJson.decodeList(responseBody) : null; + return (responseJson != null) ? HealthSymptomsGroup.listFromJson(responseJson) : null; + } + } + + // Network API: HealthCounty + + Future> loadCounties({String name, String state, String country}) async{ + String url = "${Config().healthUrl}/covid19/counties"; + String params = ''; + if (name != null) { + params += "${(0 < params.length) ? '&' : ''}name=$name"; + } + if (state != null) { + params += "${(0 < params.length) ? '&' : ''}state_province=$state"; + } + if (country != null) { + params += "${(0 < params.length) ? '&' : ''}country=$country"; + } + if (0 < params.length) { + url += "?$params"; + } + + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseBody = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseBody != null) ? AppJson.decodeList(responseBody) : null; + return (responseJson != null) ? HealthCounty.listFromJson(responseJson) : null; + } + + // Current County + + String get currentCountyId { + return _currentCountyId; + } + + bool get processingCountyStatus { + return _processingCountyStatus; + } + + Future get currentCountyStatus async { + + if (!this._isLoggedIn) { + return null; + } + + if (_processingCountyStatus == true) { + return null; + } + _processingCountyStatus = true; + + bool needStatusRebuild = false, countyChanged = false, statusChanged = false, historyUpdated = false; + + // 1. Ensure county + if (_currentCountyId == null) { + List counties = await loadCounties(); + _currentCountyId = HealthCounty.defaultCounty(counties)?.id; + if (_currentCountyId != null) { + needStatusRebuild = countyChanged = true; + } + } + + // 2. Check for pending CTests + List events = await _processPendingEvents(); + if ((events != null) && (0 < events.length)) { + needStatusRebuild = historyUpdated = true; + } + + // 3. Check for cleared status + Covid19Status currentStatus; + if (!needStatusRebuild) { + currentStatus = await _loadCovid19Status(); + if (currentStatus == null) { + needStatusRebuild = true; + } + } + + // 4. Make sure we rebuild status at lease once daily + int lastHealthStatusEvalDateMs = Storage().lastHealthStatusEval; + DateTime lastHealthStatusDateEval = (lastHealthStatusEvalDateMs != null) ? DateTime.fromMillisecondsSinceEpoch(lastHealthStatusEvalDateMs, isUtc: false) : null; + int difference = (lastHealthStatusDateEval != null) ? AppDateTime.todayMidnightLocal.difference(lastHealthStatusDateEval).inDays : null; + if ((difference == null) || (0 < difference)) { + needStatusRebuild = true; + } + + // 5. Rebuild status if needed + String lastHealthStatus = this._lastCovid19Status; + String newHealthStatus = lastHealthStatus; + if (needStatusRebuild && (_currentCountyId != null)) { + currentStatus = await _statusForCounty(_currentCountyId); + if (currentStatus != null) { + if (await _updateCovid19Status(currentStatus)) { + statusChanged = true; + } + if (covid19HealthStatusIsValid(currentStatus?.blob?.healthStatus)) { + newHealthStatus = currentStatus?.blob?.healthStatus; + } + } + } else if (currentStatus == null) { + currentStatus = await _loadCovid19Status(); + } + + // 5. Log processed events + _logProcessedEvents(events: events, status: newHealthStatus, prevStatus: lastHealthStatus); + + // 6. Notify + _processingCountyStatus = null; + NotificationService().notify(notifyCountyStatusAvailable, currentStatus); + + if (countyChanged) { + NotificationService().notify(notifyCountyChanged, null); + } + + if (statusChanged) { + NotificationService().notify(notifyStatusChanged, currentStatus); + } + + if (historyUpdated) { + NotificationService().notify(notifyHistoryUpdated, null); + } + + // 7. Check for status update + if ((lastHealthStatus != null) && (lastHealthStatus != newHealthStatus)) { + Timer(Duration(milliseconds: 100), () { + NotificationService().notify(notifyStatusUpdated, { + 'lastHealthStatus': lastHealthStatus, + 'status': currentStatus, + }); + }); + } + + return currentStatus; + } + + Future updateStatusFromHistory() async { + + if (!this._isLoggedIn) { + return null; + } + + bool countyChanged = false, statusChanged = false; + + // 1. Ensure county + if (_currentCountyId == null) { + List counties = await loadCounties(); + _currentCountyId = HealthCounty.defaultCounty(counties)?.id; + if (_currentCountyId != null) { + countyChanged = true; + } + } + + + // 2. Build the status + Covid19Status currentStatus; + String lastHealthStatus = this._lastCovid19Status; + String newHealthStatus = lastHealthStatus; + if (_currentCountyId != null) { + currentStatus = await _statusForCounty(_currentCountyId); + if (currentStatus != null) { + if (await _updateCovid19Status(currentStatus)) { + statusChanged = true; + } + if (covid19HealthStatusIsValid(currentStatus?.blob?.healthStatus)) { + newHealthStatus = currentStatus?.blob?.healthStatus; + } + } + } + + // 3. Notify + if (countyChanged) { + NotificationService().notify(notifyCountyChanged, null); + } + + if (statusChanged) { + NotificationService().notify(notifyStatusChanged, currentStatus); + } + + // 4. Check for status update + if ((lastHealthStatus != null) && (lastHealthStatus != newHealthStatus)) { + Timer(Duration(milliseconds: 100), () { + NotificationService().notify(notifyStatusUpdated, { + 'lastHealthStatus': lastHealthStatus, + 'status': currentStatus, + }); + }); + } + + return currentStatus; + } + + bool get loadingUpdatedHistory { + return _loadingUpdatedHistory; + } + + Future> loadUpdatedHistory() async { + + if (!this._isLoggedIn) { + return null; + } + + if (_loadingUpdatedHistory == true) { + return null; + } + _loadingUpdatedHistory = true; + + bool countyChanged = false, statusChanged = false; + + // 1. Ensure county + if (_currentCountyId == null) { + List counties = await loadCounties(); + _currentCountyId = HealthCounty.defaultCounty(counties)?.id; + if (_currentCountyId != null) { + countyChanged = true; + } + } + + // 2. Check for pending CTests + List events = await _processPendingEvents(); + + // 3. Load history + List histories = await loadCovid19History(); + + // 4. Rebuild status if we had been processed pending events + Covid19Status currentStatus; + String lastHealthStatus = this._lastCovid19Status; + String newHealthStatus = lastHealthStatus; + if ((histories != null) && (_currentCountyId != null)) { + currentStatus = await _statusForCounty(_currentCountyId, histories: histories); + if (currentStatus != null) { + if (await _updateCovid19Status(currentStatus)) { + statusChanged = true; + } + if (covid19HealthStatusIsValid(currentStatus?.blob?.healthStatus)) { + newHealthStatus = currentStatus?.blob?.healthStatus; + } + } + } + + // 5. Log processed events + _logProcessedEvents(events: events, status: newHealthStatus, prevStatus: lastHealthStatus); + + // 6. Notify + _loadingUpdatedHistory = null; + NotificationService().notify(notifyUpdatedHistoryAvailable, histories); + + if (countyChanged) { + NotificationService().notify(notifyCountyChanged, null); + } + + if (statusChanged) { + NotificationService().notify(notifyStatusChanged, currentStatus); + } + + // 7. Check for status update + if ((lastHealthStatus != null) && (lastHealthStatus != newHealthStatus)) { + Timer(Duration(milliseconds: 100), () { + NotificationService().notify(notifyStatusUpdated, { + 'lastHealthStatus': lastHealthStatus, + 'status': currentStatus, + }); + }); + } + + return histories; + } + + Future switchCounty(String countyId) async { + Covid19Status status; + if ((countyId != null) && (_currentCountyId != countyId)) { + status = await _updateStatusForCounty(countyId); + Storage().currentHealthCountyId = _currentCountyId = countyId; + NotificationService().notify(notifyCountyChanged, null); + } + return status; + } + + Future _updateStatusForCounty(String countyId) async { + Covid19Status status = await _statusForCounty(countyId); + if (status != null) { + if (await _updateCovid19Status(status)) { + NotificationService().notify(notifyStatusChanged, status); + return status; + } + } + return null; + } + + Future _statusForCounty(String countyId, { List histories }) async { + switch(_rulesVersion) { + case 1: return _statusForCounty1(countyId, histories: histories); + case 2: return _statusForCounty2(countyId, histories: histories); + default: return null; + } + } + + Future _statusForCounty1(String countyId, { List histories }) async { + + if (histories == null) { + histories = await loadCovid19History(); + if (histories == null) { + return null; + } + } + + Covid19Status status = Covid19Status( + dateUtc: DateTime.now().toUtc(), + blob: Covid19StatusBlob( + healthStatus: kCovid19HealthStatusOrange, + nextStep: Localization().getStringEx('model.covid19.step.initial', 'Take a SHIELD Saliva Test when you return to campus.'), + historyBlob: null, + ), + ); + + List testRules; + HealthSymptomsRule symptomsRule; + List symptomsGroups; + List traceRules; + + // Start from older + DateTime nowUtc = DateTime.now().toUtc(); + for (int index = histories.length - 1; 0 <= index; index--) { + Covid19History history = histories[index]; + if ((history.dateUtc != null) && history.dateUtc.isBefore(nowUtc)) { + if (history.isTest && history.canTestUpdateStatus) { + if (testRules == null) { + testRules = await _loadTestRules(countyId: countyId); + } + if (testRules != null) { + HealthTestRuleResult testRuleResult = HealthTestRule.matchResult(testRules, testType: history?.blob?.testType, testResult: history?.blob?.testResult); + if (testRuleResult != null) { + bool resultHasStatus = covid19HealthStatusIsValid(testRuleResult.healthStatus); + status = Covid19Status( + dateUtc: history.dateUtc, + blob: Covid19StatusBlob( + healthStatus: resultHasStatus ? testRuleResult.healthStatus : status.blob.healthStatus, + nextStep: ((testRuleResult.nextStep != null) || resultHasStatus) ? testRuleResult.nextStep: status.blob.nextStep, + nextStepDateUtc: testRuleResult?.nextStepDate(history.dateUtc), + historyBlob: history.blob, + ), + ); + } + } + } + else if (history.isSymptoms) { + if (symptomsGroups == null) { + symptomsGroups = await loadSymptomsGroups(); + } + if (symptomsRule == null) { + symptomsRule = await _loadSymptomsRule(countyId: countyId); + } + Map symptomsCounts = HealthSymptomsGroup.getCounts(symptomsGroups, history?.blob?.symptomsIds); + if ((symptomsRule != null) && (symptomsCounts != null)) { + HealthSymptomsRuleResult symptomsRuleResult = symptomsRule.matchResult(symptomsCounts); + if (symptomsRuleResult != null) { + bool resultHasStatus = covid19HealthStatusIsValid(symptomsRuleResult.healthStatus); + status = Covid19Status( + dateUtc: history.dateUtc, + blob: Covid19StatusBlob( + healthStatus: resultHasStatus ? symptomsRuleResult.healthStatus : status.blob.healthStatus, + nextStep: ((symptomsRuleResult.nextStep != null) || resultHasStatus) ? symptomsRuleResult.nextStep : status.blob.nextStep, + historyBlob: history.blob, + ), + ); + } + } + } + else if (history.isContactTrace) { + if (traceRules == null) { + traceRules = await _loadContactTraceRules(); + } + if (traceRules != null) { + HealthContactTraceRuleResult traceRuleResult = HealthContactTraceRule.matchResult(traceRules, traceDate: history.dateUtc, traceDuration: history.blob?.traceDurationInMinutes); + if (traceRuleResult != null) { + bool resultHasStatus = covid19HealthStatusIsValid(traceRuleResult.healthStatus); + status = Covid19Status( + dateUtc: history.dateUtc, + blob: Covid19StatusBlob( + healthStatus: resultHasStatus ? traceRuleResult.healthStatus : status.blob.healthStatus, + nextStep: ((traceRuleResult.nextStep != null) || resultHasStatus) ? traceRuleResult.nextStep : status.blob.nextStep, + historyBlob: history.blob, + ), + ); + } + } + } + } + } + + return status; + } + + Future _statusForCounty2(String countyId, { List histories }) async { + + if (histories == null) { + histories = await loadCovid19History(); + if (histories == null) { + return null; + } + } + + HealthRulesSet2 rules = await _loadRules2(countyId: countyId); + if (rules == null) { + return null; + } + + + Covid19Status status; + HealthRuleStatus2 defaultStatus = rules?.defaults?.status?.eval(history: histories, historyIndex: -1, rules: rules); + if (defaultStatus != null) { + status = Covid19Status( + dateUtc: null, + blob: Covid19StatusBlob( + healthStatus: defaultStatus.healthStatus, + priority: defaultStatus.priority, + nextStep: defaultStatus.nextStep, + nextStepDateUtc: null, + reason: defaultStatus.reason, + historyBlob: null, + ), + ); + } + else { + return null; + } + + // Start from older + DateTime nowUtc = DateTime.now().toUtc(); + for (int index = histories.length - 1; 0 <= index; index--) { + + Covid19History history = histories[index]; + if ((history.dateUtc != null) && history.dateUtc.isBefore(nowUtc)) { + + HealthRuleStatus2 historyStatus; + if (history.isTest && history.canTestUpdateStatus) { + if (rules.tests != null) { + HealthTestRuleResult2 testRuleResult = rules.tests.matchRuleResult(blob: history?.blob); + historyStatus = testRuleResult?.status?.eval(history: histories, historyIndex: index, rules: rules); + } + else { + return null; + } + + } + else if (history.isSymptoms) { + if (rules.symptoms != null) { + HealthSymptomsRule2 symptomsRule = rules.symptoms.matchRule(blob: history?.blob); + historyStatus = symptomsRule?.status?.eval(history: histories, historyIndex: index, rules: rules); + } + else { + return null; + } + } + else if (history.isContactTrace) { + if (rules.contactTrace != null) { + HealthContactTraceRule2 contactTraceRule = rules.contactTrace.matchRule(blob: history?.blob); + historyStatus = contactTraceRule?.status?.eval(history: histories, historyIndex: index, rules: rules); + } + else { + return null; + } + } + else if (history.isAction) { + if (rules.actions != null) { + HealthActionRule2 actionRule = rules.actions.matchRule(blob: history?.blob); + historyStatus = actionRule?.status?.eval(history: histories, historyIndex: index, rules: rules); + } + else { + return null; + } + } + + if ((historyStatus != null) && historyStatus.canUpdateStatus(blob: status.blob)) { + status = Covid19Status( + dateUtc: history.dateUtc, + blob: Covid19StatusBlob( + healthStatus: (historyStatus.healthStatus != null) ? historyStatus.healthStatus : status.blob.healthStatus, + priority: (historyStatus.priority != null) ? historyStatus.priority.abs() : status.blob.priority, + nextStep: ((historyStatus.nextStep != null) || (historyStatus.healthStatus != null)) ? historyStatus.nextStep: status.blob.nextStep, + nextStepDateUtc: ((historyStatus.nextStepInterval != null) || (historyStatus.healthStatus != null)) ? historyStatus.nextStepDateUtc(history.dateUtc) : status.blob.nextStepDateUtc, + reason: ((historyStatus.reason != null) || (historyStatus.healthStatus != null)) ? historyStatus.reason: status.blob.reason, + historyBlob: history.blob, + ), + ); + } + } + } + + Storage().lastHealthStatusEval = AppDateTime.todayMidnightLocal.millisecondsSinceEpoch; + + return status; + } + + String get lastCovid19Status { + return (this._isLoggedIn) ? Storage().lastHealthCovid19Status : null; + } + + String get _lastCovid19Status { + return Storage().lastHealthCovid19Status; + } + + set _lastCovid19Status(String healthStatus) { + if (covid19HealthStatusIsValid(healthStatus) && (healthStatus != _lastCovid19Status)) { + Analytics().logHealth( + action: Analytics.LogHealthStatusChangedAction, + status: healthStatus, + prevStatus: lastCovid19Status + ); + Storage().lastHealthCovid19Status = healthStatus; + NotificationService().notify(notifyHealthStatusChanged, null); + } + } + + void _updateExposureReportTarget({Covid19Status status}) { + if ((status != null) && (status.blob != null) && + (status.blob.healthStatus == kCovid19HealthStatusRed) && + /*(status.blob.historyBlob != null) && status.blob.historyBlob.isTest && */ + (status.dateUtc != null)) + { + Exposure().reportTargetTimestamp = status.dateUtc.millisecondsSinceEpoch; + } + } + + // WaitingOnTable processing + + Future> _processPendingEvents() async { + + List result; + List events = await loadCovid19Events(processed: false); + if (events != null) { + result = List(); + if (0 < events.length) { + List histories = await loadCovid19History(); + for (Covid19Event event in events) { + if (!Covid19History.listContainsEvent(histories, event)) { + Covid19History eventHistory = await _applyEventHistory(event); + if (eventHistory != null) { + await _markEventAsProcessed(event); + result.add(event); + } + } + } + } + } + return result; + } + + void _logProcessedEvents({List events, String status, String prevStatus}) { + if (events != null) { + for (Covid19Event event in events) { + if (event.isTest) { + Analytics().logHealth( + action: Analytics.LogHealthProviderTestProcessedAction, + status: status, + prevStatus: prevStatus, + attributes: { + Analytics.LogHealthProviderName: event.provider, + Analytics.LogHealthTestTypeName: event.blob?.testType, + Analytics.LogHealthTestResultName: event.blob?.testResult, + }); + } + else if (event.isAction) { + Analytics().logHealth( + action: Analytics.LogHealthActionProcessedAction, + status: status, + prevStatus: prevStatus, + attributes: { + Analytics.LogHealthActionTypeName: event.blob?.actionType, + Analytics.LogHealthActionTextName: event.blob?.actionText, + }); + } + } + } + } + + Future processOsfTests({List osfTests}) async { + if (osfTests != null) { + List processed = List(); + DateTime lastOsfTestDate = Storage().lastHealthCovid19OsfTestDate; + DateTime latestOsfTestDate; + + for (Covid19OSFTest osfTest in osfTests) { + if ((osfTest.dateUtc != null) && (lastOsfTestDate == null || lastOsfTestDate.isBefore(osfTest.dateUtc))) { + Covid19History testHistory = await _applyOsfTestHistory(osfTest); + if (testHistory != null) { + processed.add(osfTest); + if ((latestOsfTestDate == null) || latestOsfTestDate.isBefore(osfTest.dateUtc)) { + latestOsfTestDate = osfTest.dateUtc; + } + } + } + } + if (latestOsfTestDate != null) { + Storage().lastHealthCovid19OsfTestDate = latestOsfTestDate; + } + + if (0 < processed.length) { + NotificationService().notify(notifyHistoryUpdated, null); + + String lastHealthStatus = this._lastCovid19Status; + String newHealthStatus = lastHealthStatus; + Covid19Status status = await updateStatusFromHistory(); + if (covid19HealthStatusIsValid(status?.blob?.healthStatus)) { + newHealthStatus = status?.blob?.healthStatus; + } + + for (Covid19OSFTest osfTest in processed) { + Analytics().logHealth( + action: Analytics.LogHealthProviderTestProcessedAction, + status: newHealthStatus, + prevStatus: lastHealthStatus, + attributes: { + Analytics.LogHealthProviderName: osfTest.provider, + Analytics.LogHealthTestTypeName: osfTest.testType, + Analytics.LogHealthTestResultName: osfTest.testResult, + }); + } + } + } + } + + Future processManualTest(Covid19ManualTest test) async { + if (test != null) { + Covid19History manualHistory = await _applyManualTestHistory(test); + if (manualHistory != null) { + Analytics().logHealth( + action: Analytics.LogHealthManualTestSubmittedAction, + attributes: { + Analytics.LogHealthProviderName: test.provider, + Analytics.LogHealthLocationName: test.location, + Analytics.LogHealthTestTypeName: test.testType, + Analytics.LogHealthTestResultName: test.testResult, + }); + NotificationService().notify(notifyHistoryUpdated, null); + return true; + } + } + return false; + } + + Future _applyEventHistory(Covid19Event event) async { + if (event.isTest) { + return await _addCovid19History(await Covid19History.encryptedFromBlob( + dateUtc: event?.blob?.dateUtc, + type: Covid19HistoryType.test, + blob: Covid19HistoryBlob( + provider: event?.provider, + providerId: event?.providerId, + testType: event?.blob?.testType, + testResult: event?.blob?.testResult, + ), + publicKey: _user?.publicKey + )); + } + else if (event.isAction) { + return await _addCovid19History(await Covid19History.encryptedFromBlob( + dateUtc: event?.blob?.dateUtc, + type: Covid19HistoryType.action, + blob: Covid19HistoryBlob( + actionType: event?.blob?.actionType, + actionText: event?.blob?.actionText, + ), + publicKey: _user?.publicKey + )); + } + else { + return null; + } + } + + Future _applyOsfTestHistory(Covid19OSFTest test) async { + return await _addCovid19History(await Covid19History.encryptedFromBlob( + dateUtc: test?.dateUtc, + type: Covid19HistoryType.test, + blob: Covid19HistoryBlob( + provider: test?.provider, + providerId: test?.providerId, + testType: test?.testType, + testResult: test?.testResult, + ), + publicKey: _user?.publicKey + )); + } + + Future _applyManualTestHistory(Covid19ManualTest test) async { + return await _addCovid19History(await Covid19History.encryptedFromBlob( + dateUtc: test?.dateUtc, + type: Covid19HistoryType.manualTestNotVerified, + blob: Covid19HistoryBlob( + provider: test?.provider, + providerId: test?.providerId, + location: test?.location, + locationId: test?.locationId, + countyId: test?.countyId, + testType: test?.testType, + testResult: test?.testResult, + ), + locationId: test?.locationId, + countyId: test?.countyId, + image: test?.image, + + publicKey: _servicePublicKey, + )); + } + + Future> _loadTestRules({String countyId}) async { + String url = "${Config().healthUrl}/covid19/rules/county/$countyId"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseBody = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseBody != null) ? AppJson.decodeList(responseBody) : null; + return (responseJson != null) ? HealthTestRule.listFromJson(responseJson) : null; + } + + Future _markEventAsProcessed(Covid19Event event) async { + String url = "${Config().healthUrl}/covid19/ctests/${event.id}"; + String post = AppJson.encode({'processed':true}); + Response response = await Network().put(url, body:post, auth: NetworkAuth.User); + if (response?.statusCode == 200) { + return true; + } + else { + Log.e('Health Service: Unable to mark covid test as processed'); + return false; + } + } + + // Symptoms processing + + Future processSymptoms({List groups, Set selected, DateTime dateUtc}) async { + + List symptoms = HealthSymptomsGroup.getSymptoms(groups, selected); + Covid19History history = await _applySymptomsHistory(symptoms, dateUtc: dateUtc ?? DateTime.now().toUtc()); + if (history != null) { + NotificationService().notify(notifyHistoryUpdated, null); + + String lastHealthStatus = this._lastCovid19Status; + String newHealthStatus = lastHealthStatus; + Covid19Status status = await updateStatusFromHistory(); + if (covid19HealthStatusIsValid(status?.blob?.healthStatus)) { + newHealthStatus = status?.blob?.healthStatus; + } + + List analyticsSymptoms = []; + symptoms?.forEach((HealthSymptom symptom) { analyticsSymptoms.add(symptom?.name); }); + Analytics().logHealth( + action: Analytics.LogHealthSymptomsSubmittedAction, + status: newHealthStatus, + prevStatus: lastHealthStatus, + attributes: { + Analytics.LogHealthSymptomsName: analyticsSymptoms + }); + + // Check for status update + if ((lastHealthStatus != null) && (lastHealthStatus != newHealthStatus)) { + return status; // Succeeded, Status updated + } + else { + return history; // Succeeded + } + } + return null; // Failed + } + + Future _applySymptomsHistory(List symptoms, { DateTime dateUtc }) async { + return await _addCovid19History(await Covid19History.encryptedFromBlob( + dateUtc: dateUtc, + type: Covid19HistoryType.symptoms, + blob: Covid19HistoryBlob( + symptoms: symptoms, + ), + publicKey: _user?.publicKey + )); + } + + Future _loadSymptomsRule({String countyId}) async { + String url = "${Config().healthUrl}/covid19/symptom-rules/county/$countyId"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseBody = (response?.statusCode == 200) ? response.body : null; + Map responseJson = (responseBody != null) ? AppJson.decodeMap(responseBody) : null; + return (responseJson != null) ? HealthSymptomsRule.fromJson(responseJson) : null; + } + + // Contact Trace processing + + // Used only from debug panel, see Exposure.checkExposures + Future processContactTrace({DateTime dateUtc, int duration}) async { + + Covid19History history = await _addCovid19History(await Covid19History.encryptedFromBlob( + dateUtc: dateUtc, + type: Covid19HistoryType.contactTrace, + blob: Covid19HistoryBlob( + traceDuration: duration, + ), + publicKey: _user?.publicKey + )); + + if (history != null) { + NotificationService().notify(notifyHistoryUpdated, null); + + String lastHealthStatus = this._lastCovid19Status; + String newHealthStatus = lastHealthStatus; + Covid19Status status = await updateStatusFromHistory(); + if (covid19HealthStatusIsValid(status?.blob?.healthStatus)) { + newHealthStatus = status?.blob?.healthStatus; + } + + Analytics().logHealth( + action: Analytics.LogHealthContactTraceProcessedAction, + status: newHealthStatus, + prevStatus: lastHealthStatus, + attributes: { + Analytics.LogHealthDurationName: duration, + Analytics.LogHealthExposureTimestampName: dateUtc?.toIso8601String(), + }); + + return true; + } + return false; + } + + Future> _loadContactTraceRules({String countyId}) async { + String url = "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Assets/covid19_contact_trace_rules.json"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseBody = (response?.statusCode == 200) ? response.body : null; + List responseJson = (responseBody != null) ? AppJson.decodeList(responseBody) : null; + return (responseJson != null) ? HealthContactTraceRule.listFromJson(responseJson) : null; + } + + // Actions processing + + Future processAction(Map action) async { + /*action = { + "type": "health.covid19.action", + "health.covid19.action.date": "2020-07-30T21:23:47Z", + "health.covid19.action.type": "require-test-48", + "health.covid19.action.text": "You must take a COVID-19 test in next 48 hours", + }*/ + + if (action != null) { + DateTime dateUtc = healthDateTimeFromString(AppJson.stringValue(action['health.covid19.action.date'])); + if (dateUtc == null) { + dateUtc = DateTime.now().toUtc(); + } + String actionType = AppJson.stringValue(action['health.covid19.action.type']); + String actionText = AppJson.stringValue(action['health.covid19.action.text']); + + if ((actionType != null) || (actionText != null)) { + Covid19History history = await _addCovid19History(await Covid19History.encryptedFromBlob( + dateUtc: dateUtc, + type: Covid19HistoryType.action, + blob: Covid19HistoryBlob( + actionType: actionType, + actionText: actionText, + ), + publicKey: _user?.publicKey + )); + + if (history != null) { + NotificationService().notify(notifyHistoryUpdated, null); + + String lastHealthStatus = this._lastCovid19Status; + String newHealthStatus = lastHealthStatus; + Covid19Status status = await updateStatusFromHistory(); + if (covid19HealthStatusIsValid(status?.blob?.healthStatus)) { + newHealthStatus = status?.blob?.healthStatus; + } + + Analytics().logHealth( + action: Analytics.LogHealthActionProcessedAction, + status: newHealthStatus, + prevStatus: lastHealthStatus, + attributes: { + Analytics.LogHealthActionTypeName: actionType, + Analytics.LogHealthActionTextName: actionText, + Analytics.LogHealthActionTimestampName: dateUtc?.toIso8601String(), + }); + + return true; + } + } + } + return false; + } + + // Consolidated Rules + + Future _loadRules2({String countyId}) async { + String url = "${Config().health2Url}/rules/county/$countyId/rules.json"; + Response response = await Network().get(url); + String responseBody = (response?.statusCode == 200) ? response.body : null; +//TMP:String responseBody = await rootBundle.loadString('assets/sample.health.rules.json'); + Map responseJson = (responseBody != null) ? AppJson.decodeMap(responseBody) : null; + return (responseJson != null) ? HealthRulesSet2.fromJson(responseJson) : null; + } + + // Access + + Future> _loadAccessRules({String countyId}) async { + String url = "${Config().healthUrl}/covid19/access-rules/county/$countyId"; + Response response = await Network().get(url, auth: NetworkAuth.App); + String responseBody = (response?.statusCode == 200) ? response.body : null; + return (responseBody != null) ? AppJson.decodeMap(responseBody) : null; + } + + Future isAccessGranted(String healthStatus) async { + Map accessRules = await _loadAccessRules(countyId: _currentCountyId); + return (accessRules != null) && (accessRules[healthStatus] == kCovid19AccessGranted); + } + + + // Health User + + bool get _isAuthenticated { + return (Auth().authToken?.idToken != null); + } + + bool get _isLoggedIn { + return this._isAuthenticated && (_user?.publicKey != null) && (_userPrivateKey != null); + } + + String get _userId { + return Auth().authInfo?.uin ?? Auth().phoneToken?.phone; + } + + bool get isUserLoggedIn { + return this._isLoggedIn; + } + + Future loginUser({bool consent, bool exposureNotification, AsymmetricKeyPair keys}) async { + + if (!this._isAuthenticated) { + return null; + } + + HealthUser user; + try { + user = await _loadUser(); + } + catch (e) { + print(e?.toString()); + return null; // Load user request failed -> login failed + } + + bool userUpdated; + if (user == null) { + // User had not logged in -> create new user + user = HealthUser(uuid: User().uuid); + userUpdated = true; + } + + // Always update user info. + String userInfo = AppString.isStringNotEmpty(Auth().authInfo?.fullName) ? Auth().authInfo.fullName : Auth().phoneToken?.phone; + await user.encryptBlob(HealthUserBlob(info: userInfo), _servicePublicKey); + // update user info only if we have something to set + // userUpdated = true; + + // User RSA keys + if ((user.publicKeyString == null) || (keys != null)) { + if (keys == null) { + keys = await RsaKeyHelper.computeRSAKeyPair(RsaKeyHelper.getSecureRandom()); + } + if (keys != null) { + user.publicKeyString = RsaKeyHelper.encodePublicKeyToPemPKCS1(keys.publicKey); + userUpdated = true; + } + else { + return null; // unable to generate RSA key pair + } + } + + // Consent + Map analyticsSettingsAttributes = {}; + if (consent != null) { + if (consent != user.consent) { + analyticsSettingsAttributes[Analytics.LogHealthSettingConsentName] = consent; + user.consent = consent; + userUpdated = true; + } + } + + // Exposure Notification + if (exposureNotification != null) { + if (exposureNotification != user.exposureNotification) { + analyticsSettingsAttributes[Analytics.LogHealthSettingNotifyExposuresName] = exposureNotification; + user.exposureNotification = exposureNotification; + userUpdated = true; + } + } + + // Save + if (userUpdated == true) { + bool userSaved = await _saveUser(user); + if (!userSaved){ + return null; + } + } + + if (analyticsSettingsAttributes != null) { + Analytics().logHealth( action: Analytics.LogHealthSettingChangedAction, attributes: analyticsSettingsAttributes, defaultAttributes: Analytics.DefaultAttributes); + } + + if (keys?.privateKey != null) { + if (!await setUserRSAPrivateKey(keys.privateKey)) { + return null; + } + } + + if (exposureNotification == true) { + if (await LocationServices().status == LocationServicesStatus.PermissionNotDetermined) { + await LocationServices().requestPermission(); + } + + if (BluetoothServices().status == BluetoothStatus.PermissionNotDetermined) { + await BluetoothServices().requestStatus(); + } + } + + return user; + } + + Future repostHealthHistory() async{ + HealthUser user = await _loadUser(); + user.repost = true; + await _saveUser(user); + } + + Future loadRSAPrivateKey() async { + return _rsaUserPrivateKey; + } + + Future setUserRSAPrivateKey(PrivateKey privateKey) async { + if (_userId != null) { + bool result; + if (privateKey != null) { + String privateKeyString = RsaKeyHelper.encodePrivateKeyToPemPKCS1(privateKey); + result = await NativeCommunicator().setHealthRSAPrivateKey(userId: _userId, value: privateKeyString); + } + else { + result = await NativeCommunicator().removeHealthRSAPrivateKey(userId: _userId); + } + if (result == true) { + _healthUserPrivateKey = privateKey; + this.currentCountyStatus; + return true; + } + } + return false; + } + + Future get _rsaUserPrivateKey async { + String privateKeyString = (_userId != null) ? await NativeCommunicator().getHealthRSAPrivateKey(userId: _userId) : null; + return (privateKeyString != null) ? RsaKeyHelper.parsePrivateKeyFromPem(privateKeyString) : null; + } + + set _healthUserPrivateKey(PrivateKey value) { + if (_userPrivateKey != value) { + _userPrivateKey = value; + NotificationService().notify(notifyUserPrivateKeyUpdated); + } + } + + Future _refreshRSAPrivateKey() async { + _healthUserPrivateKey = await _rsaUserPrivateKey; + } + + Future loadRSAPublicKey() async { + try { + HealthUser user = await _loadUser(); + return user?.publicKey; + } catch(e){ + print(e?.toString()); + return null; + } + } + + Future> refreshRSAKeys() async { + AsymmetricKeyPair keys = await RsaKeyHelper.computeRSAKeyPair(RsaKeyHelper.getSecureRandom()); + + HealthUser user = await loginUser(keys: keys); + if(user != null){ + await clearUserData(); // The old history is useless + return keys; + } + + return null; // Failure - keep the old keys + } + + Future loadUser() async { + try { return await _loadUser(); } + catch (e) { print(e?.toString()); } + return null; + } + + Future _loadUser() async { + if (this._isAuthenticated) { + String url = "${Config().healthUrl}/covid19/user"; + Response response = await Network().get(url, auth: NetworkAuth.User); + if (response?.statusCode == 200) { + HealthUser user = HealthUser.fromJson(AppJson.decodeMap(response.body)); // Return user or null if does not exist for sure. + _healthUser = user; + return user; + } + throw Exception("${response?.statusCode ?? '000'} ${response?.body ?? 'Unknown error occured'}"); + } + throw Exception("User not logged in"); + } + + Future _saveUser(HealthUser user) async { + if (this._isAuthenticated) { + String url = "${Config().healthUrl}/covid19/login"; + String post = AppJson.encode(user?.toJson()); + Response response = await Network().post(url, body: post, auth: NetworkAuth.User); + if ((response != null) && (response.statusCode == 200)) { + _healthUser = user; + return true; + } + } + return false; + } + + Future _clearUser() async { + if (this._isAuthenticated) { + String url = "${Config().healthUrl}/covid19/user/clear"; + Response response = await Network().get(url, auth: NetworkAuth.User); + return response?.statusCode == 200; + } + return false; + } + + Future _refreshUser() async { + try { await _loadUser(); } + catch (e) { print(e?.toString()); } + } + + set _healthUser(HealthUser user) { + if (_user != user) { + _saveUserToStorage(_user = HealthUser.fromUser(user)); + NotificationService().notify(notifyUserUpdated, null); + } + } + + static HealthUser _loadUserFromStorage() { + return HealthUser.fromJson(AppJson.decode(Storage().healthUser)); + } + + static void _saveUserToStorage(HealthUser user) { + Storage().healthUser = AppJson.encode(user?.toJson()); + } + + HealthUser get healthUser { + return this._isLoggedIn ? _user : null; + } + + bool get userExposureNotification { + return this._isLoggedIn ? ( _user?.exposureNotification ?? false) : false; + } + + bool get userConsent { + return this._isLoggedIn ? (_user?.consent ?? false) : false; + } + + PublicKey get userPublicKey { + return this._isLoggedIn ? _user?.publicKey : null; + } + + Future deleteUser() async { + if (await _clearUser()) { + NativeCommunicator().removeHealthRSAPrivateKey(userId: _userId); + + Storage().currentHealthCountyId = _currentCountyId = null; + Storage().lastHealthProvider = null; + Storage().lastHealthCovid19Status = null; + Storage().lastHealthCovid19OsfTestDate = null; + _healthUserPrivateKey = null; + _healthUser = null; + + NotificationService().notify(notifyCountyChanged, null); + NotificationService().notify(notifyStatusChanged, null); + NotificationService().notify(notifyHistoryUpdated, null); + NotificationService().notify(notifyUserUpdated, null); + + return true; + } + return false; + } + + Future clearUserData() async { + if (await _clearCovid19History()) { + Covid19Status status = await updateStatusFromHistory(); + // Notify after status update, loadUpdatedHistory can triger anoher status rebuild + NotificationService().notify(notifyHistoryUpdated, null); + if (status == null) { + _clearCovid19Status(); + } + return true; + } + return false; + } +} + diff --git a/lib/service/LocalNotifications.dart b/lib/service/LocalNotifications.dart new file mode 100644 index 00000000..a88fbe1f --- /dev/null +++ b/lib/service/LocalNotifications.dart @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Log.dart'; + +class LocalNotifications with Service { + + static const String notifySelected = "edu.illinois.rokwire.localnotifications.selected"; + + static final LocalNotifications _instance = new LocalNotifications._internal(); + + factory LocalNotifications() { + return _instance; + } + + LocalNotifications._internal(); + + FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin; + + @override + void createService() { + } + + @override + void destroyService() { + } + + @override + Future initService() async { + initPlugin(); + } + + @override + Set get serviceDependsOn { + return Set.from([NativeCommunicator()]); + } + + void initPlugin() { + if (_flutterLocalNotificationsPlugin == null) { + //int privacyLevel = Storage().getPrivacyLevel()?.toInt(); + //if ((privacyLevel != null) && (privacyLevel >= 4)) + if (Platform.isIOS) { + NativeCommunicator().queryNotificationsAuthorization("query").then((bool notificationsAuthorized) { + if (notificationsAuthorized) { + _initPlugin(); + } + }); + } + else { + _initPlugin(); + } + } + } + + void _initPlugin() { + _flutterLocalNotificationsPlugin = new FlutterLocalNotificationsPlugin(); + var initializationSettingsAndroid = new AndroidInitializationSettings('app_icon'); + var initializationSettingsIOS = new IOSInitializationSettings(onDidReceiveLocalNotification: _onDidReceiveLocalNotification); + var initializationSettings = new InitializationSettings(initializationSettingsAndroid, initializationSettingsIOS); + _flutterLocalNotificationsPlugin.initialize(initializationSettings, onSelectNotification: _onSelectNotification); + } + + Future _onSelectNotification(String payload) async { + Log.d('Android: on select local notification: ' + payload); + NotificationService().notify(notifySelected, payload); + } + + Future _onDidReceiveLocalNotification(int id, String title, String body, String payload) async { + Log.d('iOS: on did receive local notification: ' + payload); + } + + Future showNotification({String title, String message, String payload = ''}) async { + if (_flutterLocalNotificationsPlugin != null) { + var androidPlatformChannelSpecifics = AndroidNotificationDetails('1000', 'DEFAULT_CHANNEL', 'It is default channel', importance: Importance.Max, priority: Priority.High); + var iOSPlatformChannelSpecifics = IOSNotificationDetails(presentAlert: true, presentSound: true,); + var platformChannelSpecifics = NotificationDetails(androidPlatformChannelSpecifics, iOSPlatformChannelSpecifics); + await _flutterLocalNotificationsPlugin.show(0, title, message, platformChannelSpecifics, payload: payload, ); + } + } + +} diff --git a/lib/service/Localization.dart b/lib/service/Localization.dart new file mode 100644 index 00000000..78bd4c56 --- /dev/null +++ b/lib/service/Localization.dart @@ -0,0 +1,311 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:io'; +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:http/http.dart' as http; +import 'package:illinois/utils/Utils.dart'; +import 'package:path/path.dart'; + +class Localization with Service implements NotificationsListener { + + // Notifications + static const String notifyLocaleChanged = "edu.illinois.rokwire.localization.locale.updated"; + static const String notifyStringsUpdated = "edu.illinois.rokwire.localization.strings.updated"; + + // Singleton Factory + static final Localization _logic = Localization._internal(); + + factory Localization() { + return _logic; + } + + Localization._internal(); + + // Multilanguage support + final List supportedLanguages = ['en', 'es','zh']; + Iterable supportedLocales() => supportedLanguages.map((language) => Locale(language, "")); + + // Data + Directory _assetsDir; + + Locale _defaultLocale; + Map _defaultStrings; + + Locale _currentLocale; + Map _localeStrings; + + DateTime _pausedDateTime; + + // Service + + @override + void createService() { + NotificationService().subscribe(this, AppLivecycle.notifyStateChanged); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + + _assetsDir = await _getAssetsDir(); + + String defaultLanguage = supportedLanguages[0]; + _defaultLocale = Locale.fromSubtags(languageCode : defaultLanguage); + _defaultStrings = await _loadStrings(defaultLanguage); + _updateDefaultStrings(); + + String curentLanguage = Storage().currentLanguage; + if (curentLanguage != null) { + _currentLocale = Locale.fromSubtags(languageCode : curentLanguage); + _localeStrings = await _loadStrings(curentLanguage); + _updateLocaleStrings(); + } + } + + @override + Set get serviceDependsOn { + return Set.from([Storage(), Config() ]); + } + + // Locale + + Locale get currentLocale { + return _currentLocale ?? _defaultLocale; + } + + set currentLocale(Locale value) { + if ((value == null) || (value.languageCode == _defaultLocale.languageCode)) { + // use default + _currentLocale = null; + _localeStrings = null; + Storage().currentLanguage = null; + //Notyfy when we change the locale (valid change) + NotificationService().notify(notifyLocaleChanged, null); + } + else if ((_currentLocale == null) || (_currentLocale.languageCode != value.languageCode)) { + _currentLocale = value; + Storage().currentLanguage = value.languageCode; + _loadStrings(value.languageCode).then((Map strings) { + _localeStrings = strings; + _updateLocaleStrings(); + }); + //Notyfy when we change the locale (valid change) + NotificationService().notify(notifyLocaleChanged, null); + } + } + + // Load / Update + + Future _getAssetsDir() async { + Directory assetsDir = Config().assetsCacheDir; + if ((assetsDir != null) && !await assetsDir.exists()) { + await assetsDir.create(recursive: true); + } + return assetsDir; + } + + Future> _loadStrings(String language) async { + + dynamic jsonData; + String assetName = 'strings.$language.json'; + + try { + String cacheFilePath = (_assetsDir != null) ? join(_assetsDir.path, assetName) : null; + File cacheFile = (cacheFilePath != null) ? File(cacheFilePath) : null; + + String jsonString = ((cacheFile != null) && await cacheFile.exists()) ? await cacheFile.readAsString() : null; + jsonData = AppJson.decode(jsonString); + } on Exception catch (e) { print(e.toString()); } + if ((jsonData != null) && (jsonData is Map)) { + return jsonData; + } + + try { + String jsonString = await rootBundle.loadString('assets/$assetName'); + jsonData = AppJson.decode(jsonString); + } on Exception catch (e) { print(e.toString()); } + return ((jsonData != null) && (jsonData is Map)) ? jsonData : null; + } + + Future> _updateStringsFromNet(String language, Map strings) async { + Map jsonData; + try { + String assetName = 'strings.$language.json'; + http.Response response = (Config().assetsUrl != null) ? await Network().get("${Config().assetsUrl}/$assetName") : null; + String jsonString = ((response != null) && (response.statusCode == 200)) ? response.body : null; + jsonData = (jsonString != null) ? AppJson.decode(jsonString) : null; + if ((jsonData != null) && jsonData.isNotEmpty && ((strings == null) || !DeepCollectionEquality().equals(jsonData, strings))) { + String cacheFilePath = (_assetsDir != null) ? join(_assetsDir.path, assetName) : null; + File cacheFile = (cacheFilePath != null) ? File(cacheFilePath) : null; + if (cacheFile != null) { + await cacheFile.writeAsString(jsonString, flush: true); + } + } + } catch (e) { + print(e.toString()); + } + return jsonData; + } + + void _updateDefaultStrings() { + if (_defaultLocale != null) { + _updateStringsFromNet(_defaultLocale.languageCode, _defaultStrings).then((Map update) { + if (update != null) { + _defaultStrings = update; + NotificationService().notify(notifyStringsUpdated, null); + } + }); + } + } + + void _updateLocaleStrings() { + if (_currentLocale != null) { + _updateStringsFromNet(_currentLocale.languageCode, _localeStrings).then((Map update) { + if (update != null) { + _localeStrings = update; + NotificationService().notify(notifyStringsUpdated, null); + } + }); + } + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _pausedDateTime = DateTime.now(); + } + if (state == AppLifecycleState.resumed) { + if (_pausedDateTime != null) { + Duration pausedDuration = DateTime.now().difference(_pausedDateTime); + if (Config().refreshTimeout < pausedDuration.inSeconds) { + _updateDefaultStrings(); + _updateLocaleStrings(); + } + } + } + } + + // Strings + + String getString(String key, {String defaults, String language}) { + String value; + if ((value == null) && (_localeStrings != null) && ((language == null) || (language == _currentLocale?.languageCode))) { + value = _localeStrings[key]; + } + if ((value == null) && (_defaultStrings != null) && ((language == null) || (language == _defaultLocale?.languageCode))) { + value = _defaultStrings[key]; + } + return ((value != null) && (value is String)) ? value : defaults; + } + + String getStringEx(String key, String defaults) { + return getString(key, defaults: defaults); + } + + String getStringFromMapping(String text, Map stringsMap) { + if ((text != null) && (stringsMap != null)) { + String entry; + if ((entry = _getStringFromLanguageMapping(text, stringsMap[_currentLocale?.languageCode])) != null) { + return entry; + } + if ((entry = _getStringFromLanguageMapping(text, stringsMap[_defaultLocale?.languageCode])) != null) { + return entry; + } + } + return text; + } + + String getStringFromKeyMapping(String key, Map stringsMap, {String defaults = ''}) { + String text; + if (AppString.isStringNotEmpty(key)) { + //1. Get text value from assets + text = Localization().getStringFromMapping(key, stringsMap); // returns 'key' if text is not found + //2. If there is no text for this key then get text value from strings + if (AppString.isStringEmpty(text) || text == key) { + text = Localization().getStringEx(key, defaults); + } + } + return AppString.getDefaultEmptyString(value: text, defaultValue: defaults); + } + + static String _getStringFromLanguageMapping(String text, Map languageMap) { + if (languageMap is Map) { + String languageTextEntry = languageMap[text]; + if (languageTextEntry is String) { + return languageTextEntry; + } + } + return null; + } + +} + +class AppLocalizations { + Locale locale; + + AppLocalizations(Locale locale) { + Localization().currentLocale = this.locale = locale; + } + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static Future load(Locale locale) async { + return AppLocalizations(locale); + } +} + +class AppLocalizationsDelegate extends LocalizationsDelegate { + + const AppLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) { + return Localization().supportedLanguages.contains(locale.languageCode); + } + + @override + Future load(Locale locale) { + return AppLocalizations.load(locale); + } + + @override + bool shouldReload(LocalizationsDelegate old) { + return true; + } +} \ No newline at end of file diff --git a/lib/service/LocationServices.dart b/lib/service/LocationServices.dart new file mode 100644 index 00000000..d09e4fe3 --- /dev/null +++ b/lib/service/LocationServices.dart @@ -0,0 +1,215 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:location/location.dart'; + +enum LocationServicesStatus { + ServiceDisabled, + PermissionNotDetermined, + PermissionDenied, + PermissionAllowed +} + +class LocationServices with Service implements NotificationsListener { + + static const String notifyStatusChanged = "edu.illinois.rokwire.locationservices.status.changed"; + static const String notifyLocationChanged = "edu.illinois.rokwire.locationservices.location.changed"; + + LocationServicesStatus _lastStatus; + LocationData _lastLocation; + StreamSubscription _locationMonitor; + + // Singletone Instance + + LocationServices._internal(); + static final LocationServices _instance = LocationServices._internal(); + + factory LocationServices() { + return _instance; + } + + static LocationServices get instance { + return _instance; + } + + // Iniitlaization + + @override + void createService() { + NotificationService().subscribe(this, [ + AppLivecycle.notifyStateChanged, + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + _closeLocationMonitor(); + } + + @override + Future initService() async { + this.status.then((_){}); + } + + Future get status async { + + if (!await Location().serviceEnabled()) { + _lastStatus = LocationServicesStatus.ServiceDisabled; + } + else if (!Storage().locationServicesPermisionRequested) { + _lastStatus = LocationServicesStatus.PermissionNotDetermined; + } + else { + _lastStatus = await Location().hasPermission() ? LocationServicesStatus.PermissionAllowed : LocationServicesStatus.PermissionDenied; + } + + _updateLocationMonitor(); + return _lastStatus; + } + + Future requestService() async { + + if (!await Location().serviceEnabled()) { + if (!await Location().requestService()) { + _lastStatus = LocationServicesStatus.ServiceDisabled; + } + else { + _lastStatus = await this.status; + _notifyStatusChanged(); + } + } + else { + _lastStatus = await this.status; + } + + _updateLocationMonitor(); + return status; + } + + Future requestPermission() async { + + if (!await Location().serviceEnabled()) { + _lastStatus = LocationServicesStatus.ServiceDisabled; + } + else if (Storage().locationServicesPermisionRequested) { + _lastStatus = await Location().hasPermission() ? LocationServicesStatus.PermissionAllowed : LocationServicesStatus.PermissionDenied; + } + else { + _lastStatus = _locationServicesStatusFromString(await NativeCommunicator().queryLocationServicesPermission('request')); + Storage().locationServicesPermisionRequested = true; + _notifyStatusChanged(); + } + + _updateLocationMonitor(); + return _lastStatus; + } + + Future get location async { + return (await this.status == LocationServicesStatus.PermissionAllowed) ? await Location().getLocation() : null; + } + + // Location Monitor + + LocationData get lastLocation { + return _lastLocation; + } + + void _updateLocationMonitor() { + + if ((_lastStatus == LocationServicesStatus.PermissionAllowed) && (_locationMonitor == null)) { + _openLocationMonitor(); + } + else if ((_lastStatus != LocationServicesStatus.PermissionAllowed) && (_locationMonitor != null)) { + _closeLocationMonitor(); + } + } + + void _openLocationMonitor() { + if (_locationMonitor == null) { + _locationMonitor = Location().onLocationChanged().listen((LocationData location) { + _lastLocation = location; + _notifyLocationChanged(); + }); + } + } + + void _closeLocationMonitor() { + if (_locationMonitor != null) { + _locationMonitor.cancel(); + _locationMonitor = null; + } + } + + // Helpers + + void _notifyStatusChanged() { + NotificationService().notify(notifyStatusChanged, _lastStatus); + } + + void _notifyLocationChanged() { + NotificationService().notify(notifyLocationChanged, _lastLocation); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + LocationServicesStatus lastStatus = _lastStatus; + this.status.then((_) { + if (lastStatus != _lastStatus) { + _notifyStatusChanged(); + } + }); + + } + else if (state == AppLifecycleState.paused) { + this.status.then((_) { + }); + } + } +} + + +LocationServicesStatus _locationServicesStatusFromString(String value) { + if (value == 'disabled') { + return LocationServicesStatus.ServiceDisabled; + } else if (value == 'not_determined') { + return LocationServicesStatus.PermissionNotDetermined; + } else if (value == 'denied') { + return LocationServicesStatus.PermissionDenied; + } else if (value == 'allowed') { + return LocationServicesStatus.PermissionAllowed; + } + else { + return null; + } +} diff --git a/lib/service/Log.dart b/lib/service/Log.dart new file mode 100644 index 00000000..63d11f16 --- /dev/null +++ b/lib/service/Log.dart @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:logger/logger.dart'; + +abstract class Log { + static final _logger = Logger(); + + static v(String message) { + _logger.v(message); + } + + static d(String message) { + _logger.d(message); + } + + static i(String message) { + _logger.i(message); + } + + static w(String message) { + _logger.w(message); + } + + static e(String message) { + _logger.e(message); + } +} diff --git a/lib/service/NativeCommunicator.dart b/lib/service/NativeCommunicator.dart new file mode 100644 index 00000000..e39541b0 --- /dev/null +++ b/lib/service/NativeCommunicator.dart @@ -0,0 +1,384 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/utils/Utils.dart'; + +class NativeCommunicator with Service { + + static const String notifyMapSelectExplore = "edu.illinois.rokwire.nativecommunicator.map.explore.select"; + static const String notifyMapClearExplore = "edu.illinois.rokwire.nativecommunicator.map.explore.clear"; + + static const String notifyMapRouteStart = "edu.illinois.rokwire.nativecommunicator.map.route.start"; + static const String notifyMapRouteFinish = "edu.illinois.rokwire.nativecommunicator.map.route.finish"; + + final MethodChannel _platformChannel = const MethodChannel("edu.illinois.covid/core"); + + // Singletone + static final NativeCommunicator _communicator = new NativeCommunicator._internal(); + + factory NativeCommunicator() { + return _communicator; + } + + NativeCommunicator._internal(); + + // Initialization + + @override + void createService() { + _platformChannel.setMethodCallHandler(_handleMethodCall); + } + + @override + Future initService() async { + await _nativeInit(); + } + + @override + Set get serviceDependsOn { + return Set.from([Config()]); + } + + Future _nativeInit() async { + try { + await _platformChannel.invokeMethod('init', { "keys": Config().secretKeys }); + } on PlatformException catch (e) { + print(e.message); + } + } + + Future launchExploreMapDirections({dynamic target}) async { + dynamic jsonData; + try { + if (target != null) { + if (target is List) { + jsonData = List(); + for (dynamic entry in target) { + jsonData.add(entry.toJson()); + } + } + else { + jsonData = target.toJson(); + } + } + } on PlatformException catch (e) { + print(e.message); + } + + if (jsonData != null) { + await launchMapDirections(jsonData: jsonData); + } + } + + Future launchMapDirections({dynamic jsonData}) async { + try { + String lastPageName = Analytics().currentPageName; + Map lastPageAttributes = Analytics().currentPageAttributes; + Analytics().logPage(name: 'MapDirections'); + Analytics().logMapShow(); + + await _platformChannel.invokeMethod('directions', { + 'explore': jsonData, + 'options': { + 'showDebugLocation': Storage().debugMapLocationProvider, + 'hideLevels': Storage().debugMapHideLevels, + }}); + + Analytics().logMapHide(); + Analytics().logPage(name: lastPageName, attributes: lastPageAttributes); + } on PlatformException catch (e) { + print(e.message); + } + } + + Future launchSelectLocation({dynamic explore}) async { + try { + + String lastPageName = Analytics().currentPageName; + Map lastPageAttributes = Analytics().currentPageAttributes; + Analytics().logPage(name: 'MapSelectLocation'); + Analytics().logMapShow(); + + dynamic jsonData = (explore != null) ? explore.toJson() : null; + String result = await _platformChannel.invokeMethod('pickLocation', {"explore": jsonData}); + + Analytics().logMapHide(); + Analytics().logPage(name: lastPageName, attributes: lastPageAttributes); + return result; + + } on PlatformException catch (e) { + print(e.message); + } + + return null; + } + + Future launchMap({dynamic target, dynamic markers}) async { + try { + String lastPageName = Analytics().currentPageName; + Map lastPageAttributes = Analytics().currentPageAttributes; + Analytics().logPage(name: 'Map'); + Analytics().logMapShow(); + + await _platformChannel.invokeMethod('map', { + 'target': target, + 'options': { + 'showDebugLocation': Storage().debugMapLocationProvider, + 'hideLevels': Storage().debugMapHideLevels, + }, + 'markers': markers, + }); + + Analytics().logMapHide(); + Analytics().logPage(name: lastPageName, attributes: lastPageAttributes); + + } on PlatformException catch (e) { + print(e.message); + } + } + + FuturelaunchNotification({String title, String subtitle, String body, bool sound = true}) async { + await _platformChannel.invokeMethod('showNotification', { + 'title': title, + 'subtitle': subtitle, + 'body': body, + 'sound': sound, + }); + } + + Future dismissSafariVC() async { + try { + await _platformChannel.invokeMethod('dismissSafariVC'); + } on PlatformException catch (e) { + print(e.message); + } + } + + Future dismissLaunchScreen() async { + try { + await _platformChannel.invokeMethod('dismissLaunchScreen'); + } on PlatformException catch (e) { + print(e.message); + } + } + + Future addCardToWallet(List cardData) async { + try { + String cardBase64Data = base64Encode(cardData); + await _platformChannel.invokeMethod('addToWallet', { "cardBase64Data" : cardBase64Data }); + } on PlatformException catch (e) { + print(e.message); + } + } + + Future microBlinkScan({List recognizers}) async { + try { + return await _platformChannel.invokeMethod('microBlinkScan', { 'recognizers' : recognizers }); + } on PlatformException catch (e) { + print(e.message); + } + return null; + } + + Future> enabledOrientations(List orientationsList) async { + List result; + try { + dynamic inputStringsList = AppDeviceOrientation.toStrList(orientationsList); + dynamic outputStringsList = await _platformChannel.invokeMethod('enabledOrientations', { "orientations" : inputStringsList }); + result = AppDeviceOrientation.fromStrList(outputStringsList); + } on PlatformException catch (e) { + print(e.message); + } + return result; + } + + Future queryFirebaseInfo() async { + String result; + try { + result = await _platformChannel.invokeMethod('firebaseInfo'); + } on PlatformException catch (e) { + print(e.message); + } + return result; + } + + Future queryNotificationsAuthorization(String method) async { + bool result = false; + try { + result = await _platformChannel.invokeMethod('notifications_authorization', {"method": method }); + } on PlatformException catch (e) { + print(e.message); + } + return result; + } + + Future queryLocationServicesPermission(String method) async { + String result; + try { + result = await _platformChannel.invokeMethod('location_services_permission', {"method": method }); + } on PlatformException catch (e) { + print(e.message); + } + return result; + } + + Future queryBluetoothAuthorization(String method) async { + String result; + try { + result = await _platformChannel.invokeMethod('bluetooth_authorization', {"method": method }); + } on PlatformException catch (e) { + print(e.message); + } + return result; + } + + Future getDeviceId() async { + String result; + try { + result = await _platformChannel.invokeMethod('deviceId'); + }on PlatformException catch (e) { + print(e.message); + } + return result; + } + + Future getHealthRSAPrivateKey({String userId}) async { + String result; + try { + result = await _platformChannel.invokeMethod('healthRSAPrivateKey', { + 'userId': userId, + }); + } catch (e) { + print(e?.toString()); + } + return result; + } + + Future setHealthRSAPrivateKey({String userId, String value}) async { + bool result; + try { + result = await _platformChannel.invokeMethod('healthRSAPrivateKey', { + 'userId': userId, + 'value': value, + }); + } catch (e) { + print(e?.toString()); + } + return result; + } + + Future removeHealthRSAPrivateKey({String userId}) async { + bool result; + try { + result = await _platformChannel.invokeMethod('healthRSAPrivateKey', { + 'userId': userId, + 'remove': true, + }); + } catch (e) { + print(e?.toString()); + } + return result; + } + + Future getBarcodeImageData(Map params) async { + try { + String base64String = await _platformChannel.invokeMethod('barcode', params); + return (base64String != null) ? base64Decode(base64String) : null; + } + catch (e) { + print(e.message); + } + return null; + } + + Future launchTest() async { + try { + await _platformChannel.invokeMethod('test'); + } on PlatformException catch (e) { + print(e.message); + } + } + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case "map.explore.select": + _notifyMapSelectExplore(call.arguments); + break; + case "map.explore.clear": + _notifyMapClearExplore(call.arguments); + break; + + case "map.route.start": + _notifyMapRouteStart(call.arguments); + break; + case "map.route.finish": + _notifyMapRouteFinish(call.arguments); + break; + + case "firebase_message": + //PS use firebase messaging plugin! + //FirebaseMessaging().onMessage(call.arguments); + break; + + default: + break; + } + return null; + } + + void _notifyMapSelectExplore(dynamic arguments) { + dynamic jsonData = (arguments is String) ? AppJson.decode(arguments) : null; + Map params = (jsonData is Map) ? jsonData.cast() : null; + int mapId = (params is Map) ? params['mapId'] : null; + dynamic exploreJson = (params is Map) ? params['explore'] : null; + + NotificationService().notify(notifyMapSelectExplore, { + 'mapId': mapId, + 'exploreJson': exploreJson + }); + } + + void _notifyMapClearExplore(dynamic arguments) { + dynamic jsonData = (arguments is String) ? AppJson.decode(arguments) : null; + Map params = (jsonData is Map) ? jsonData.cast() : null; + int mapId = (params is Map) ? params['mapId'] : null; + + NotificationService().notify(notifyMapClearExplore, { + 'mapId': mapId, + }); + } + + void _notifyMapRouteStart(dynamic arguments) { + dynamic jsonData = (arguments is String) ? AppJson.decode(arguments) : null; + Map params = (jsonData is Map) ? jsonData.cast() : null; + NotificationService().notify(notifyMapRouteStart, params); + } + + void _notifyMapRouteFinish(dynamic arguments) { + dynamic jsonData = (arguments is String) ? AppJson.decode(arguments) : null; + Map params = (jsonData is Map) ? jsonData.cast() : null; + NotificationService().notify(notifyMapRouteFinish, params); + } +} diff --git a/lib/service/Network.dart b/lib/service/Network.dart new file mode 100644 index 00000000..f16b422b --- /dev/null +++ b/lib/service/Network.dart @@ -0,0 +1,418 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:http/http.dart' as Http; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Connectivity.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/utils/Utils.dart'; + +import 'Crashlytics.dart'; + +enum NetworkAuth { + App, + User, + Access, +} + +class Network { + + static const String RokwireApiKey = 'ROKWIRE-API-KEY'; + static const String RokwireHSApiKey = 'ROKWIRE-HS-API-KEY'; + + static final Network _network = new Network._internal(); + factory Network() { + return _network; + } + + Network._internal(); + + Future _get2(dynamic url, { String body, Encoding encoding, Map headers, NetworkAuth auth, int timeout, Http.Client client }) async { + try { + + Uri uri; + if (url is Uri) { + uri = url; + } + else if (url is String) { + uri = Uri.parse(url); + } + + if (uri != null) { + + Http.Client localClient; + if (client == null) { + client = localClient = Http.Client(); + } + + Http.Request request = Http.Request("GET", uri); + + if (headers != null) { + headers.forEach((String key, String value) { + request.headers[key] = value; + }); + } + + if (encoding != null) { + request.encoding = encoding; + } + + if (body != null) { + request.body = body; + } + + Future responseStreamFuture = client.send(request); + if ((responseStreamFuture != null) && (timeout != null)) { + responseStreamFuture = responseStreamFuture.timeout(Duration(seconds: timeout)); + } + + Http.StreamedResponse responseStream = await responseStreamFuture; + + if (localClient != null) { + localClient.close(); + } + + return (responseStream != null) ? Http.Response.fromStream(responseStream) : null; + } + } catch (e) { + Log.e(e.toString()); + Crashlytics().recordError(e, null); + } + return null; + } + + Future _get(url, { String body, Encoding encoding, Map headers, NetworkAuth auth, int timeout, Http.Client client} ) async { + if (Connectivity().isNotOffline) { + try { + if (url != null) { + + Map requestHeaders = _prepareHeaders(headers, auth, url); + + Future response; + if (body != null) { + response = _get2(url, headers: requestHeaders, body: body, encoding: encoding, timeout: timeout, client: client); + } + else if (client != null) { + response = client.get(url, headers: requestHeaders); + } + else { + response = Http.get(url, headers: requestHeaders); + } + + if ((response != null) && (timeout != null)) { + response = response.timeout(Duration(seconds: timeout), onTimeout: _responseTimeoutHandler); + } + + return response; + } + } catch (e) { + Log.e(e.toString()); + Crashlytics().recordError(e, null); + } + } + return null; + } + + Future get(url, { String body, Encoding encoding, Map headers, NetworkAuth auth, Http.Client client, int timeout = 60, bool refreshToken = true, bool sendAnalytics = true, String analyticsUrl, bool analyticsAnonymous }) async { + Http.Response response = await _get(url, headers: headers, body: body, encoding: encoding, auth: auth, client: client, timeout: timeout); + if (sendAnalytics) { + Analytics().logHttpResponse(response, requestMethod:'GET', requestUrl: analyticsUrl ?? url, anonymous: analyticsAnonymous); + } + + _saveCookiesFromResponse(url, response); + + if (refreshToken && (response is Http.Response) && _requiresRefreshToken(response, auth)) { + await Auth().doRefreshToken(); + return _get(url, body: body, headers: headers, auth: auth, client: client, timeout: timeout); + } + else { + return response; + } + } + + Future _post(url, { body, Encoding encoding, Map headers, NetworkAuth auth, int timeout}) async{ + if (Connectivity().isNotOffline) { + try { + Future response = (url != null) ? Http.post(url, headers: _prepareHeaders(headers, auth, url), body: body, encoding: encoding) : null; + return ((response != null) && (timeout != null)) ? response.timeout(Duration(seconds: timeout), onTimeout: _responseTimeoutHandler) : response; + } catch (e) { + Log.e(e.toString()); + Crashlytics().recordError(e, null); + } + } + return null; + } + + Future post(url, { body, Encoding encoding, Map headers, NetworkAuth auth, int timeout = 60, bool refreshToken = true, bool sendAnalytics = true, String analyticsUrl, bool analyticsAnonymous }) async{ + Http.Response response = await _post(url, body: body, encoding: encoding, headers: headers, auth: auth, timeout: timeout); + if (sendAnalytics) { + Analytics().logHttpResponse(response, requestMethod:'POST', requestUrl: analyticsUrl ?? url, anonymous: analyticsAnonymous); + } + + _saveCookiesFromResponse(url, response); + + if (refreshToken && (response is Http.Response) && _requiresRefreshToken(response, auth)) { + await Auth().doRefreshToken(); + return _post(url, body: body, encoding: encoding, headers: headers, auth: auth, timeout: timeout); + } + else { + return response; + } + } + + Future _put(url, { body, Encoding encoding, Map headers, NetworkAuth auth, int timeout, Http.Client client }) async { + if (Connectivity().isNotOffline) { + try { + Future response = (url != null) ? + ((client != null) ? + client.put(url, headers: _prepareHeaders(headers, auth, url), body: body, encoding: encoding) : + Http.put(url, headers: _prepareHeaders(headers, auth, url), body: body, encoding: encoding)) : + null; + + return ((response != null) && (timeout != null)) ? response.timeout(Duration(seconds: timeout), onTimeout: _responseTimeoutHandler) : response; + + } catch (e) { + Log.e(e.toString()); + Crashlytics().recordError(e, null); + } + } + return null; + } + + Future put(url, { body, Encoding encoding, Map headers, NetworkAuth auth, int timeout = 60, Http.Client client, bool refreshToken = true, bool sendAnalytics = true, String analyticsUrl, bool analyticsAnonymous }) async { + Http.Response response = await _put(url, body: body, encoding: encoding, headers: headers, auth: auth, timeout: timeout, client: client); + if (sendAnalytics) { + Analytics().logHttpResponse(response, requestMethod:'PUT', requestUrl: analyticsUrl ?? url, anonymous: analyticsAnonymous); + } + + _saveCookiesFromResponse(url, response); + + if (refreshToken && (response is Http.Response) && _requiresRefreshToken(response, auth)) { + await Auth().doRefreshToken(); + return _put(url, body: body, encoding: encoding, headers: headers, auth: auth, timeout: timeout, client: client); + } + else { + return response; + } + } + + Future _patch(url, { body, Encoding encoding, Map headers, NetworkAuth auth, int timeout }) async { + if (Connectivity().isNotOffline) { + try { + Future response = (url != null) ? Http.patch(url, headers: _prepareHeaders(headers, auth, url), body: body, encoding: encoding) : null; + return ((response != null) && (timeout != null)) ? response.timeout(Duration(seconds: timeout), onTimeout: _responseTimeoutHandler) : response; + } catch (e) { + Log.e(e.toString()); + Crashlytics().recordError(e, null); + } + } + return null; + } + + Future patch(url, { body, Encoding encoding, Map headers, NetworkAuth auth, int timeout = 60, bool refreshToken = true, bool sendAnalytics = true, String analyticsUrl, bool analyticsAnonymous }) async { + Http.Response response = await _patch(url, body: body, encoding: encoding, headers: headers, auth: auth, timeout: timeout); + if (sendAnalytics) { + Analytics().logHttpResponse(response, requestMethod:'PATCH', requestUrl: analyticsUrl ?? url, anonymous: analyticsAnonymous); + } + + _saveCookiesFromResponse(url, response); + + if (refreshToken && (response is Http.Response) && _requiresRefreshToken(response, auth)) { + await Auth().doRefreshToken(); + return _patch(url, body: body, encoding: encoding, headers: headers, auth: auth, timeout: timeout); + } + else { + return response; + } + } + + Future _delete(url, { Map headers, NetworkAuth auth, int timeout }) async { + if (Connectivity().isNotOffline) { + try { + Future response = (url != null) ? Http.delete(url, headers: _prepareHeaders(headers, auth, url)) : null; + return ((response != null) && (timeout != null)) ? response.timeout(Duration(seconds: timeout), onTimeout: _responseTimeoutHandler) : response; + } catch (e) { + Log.e(e.toString()); + Crashlytics().recordError(e, null); + } + } + return null; + } + + Future delete(url, { Map headers, NetworkAuth auth, int timeout = 60, bool refreshToken = true, bool sendAnalytics = true, String analyticsUrl, bool analyticsAnonymous }) async { + Http.Response response = await _delete(url, headers: headers, auth: auth, timeout: timeout); + if (sendAnalytics) { + Analytics().logHttpResponse(response, requestMethod:'DELETE', requestUrl: analyticsUrl ?? url, anonymous: analyticsAnonymous); + } + + _saveCookiesFromResponse(url, response); + + if (refreshToken && (response is Http.Response) && _requiresRefreshToken(response, auth)) { + await Auth().doRefreshToken(); + return _delete(url, headers: headers, auth: auth, timeout: timeout); + } + else { + return response; + } + } + + Future _read(url, { Map headers, NetworkAuth auth, int timeout = 60 }) async { + if (Connectivity().isNotOffline) { + try { + Future response = (url != null) ? Http.read(url, headers: _prepareHeaders(headers, auth, url)) : null; + return ((response != null) && (timeout != null)) ? response.timeout(Duration(seconds: timeout)) : response; + } catch (e) { + Log.e(e.toString()); + Crashlytics().recordError(e, null); + } + } + return null; + } + + Future read(url, { Map headers, NetworkAuth auth, int timeout = 60 }) async { + return _read(url, headers: headers, auth: auth, timeout: timeout); + } + + Future _readBytes(url, { Map headers, NetworkAuth auth, int timeout = 60 }) async{ + if (Connectivity().isNotOffline) { + try { + Future response = (url != null) ? Http.readBytes(url, headers: _prepareHeaders(headers, auth, url)) : null; + return ((response != null) && (timeout != null)) ? response.timeout(Duration(seconds: timeout), onTimeout: _responseBytesHandler) : response; + } catch (e) { + Log.e(e.toString()); + Crashlytics().recordError(e, null); + } + } + return null; + } + + Future readBytes(url, { Map headers, NetworkAuth auth, int timeout = 60 }) async { + return _readBytes(url, headers: headers, auth: auth, timeout: timeout); + } + + Map _prepareHeaders(Map headers, NetworkAuth auth, String url) { + + if (auth == NetworkAuth.App) { + String rokwireApiKey = Config().rokwireApiKey; + if ((rokwireApiKey != null) && rokwireApiKey.isNotEmpty) { + if (headers == null) { + headers = new Map(); + } + headers[RokwireApiKey] = rokwireApiKey; + } + } + else if (auth == NetworkAuth.User) { + String idToken = Auth().authToken?.idToken; + String tokenType = Auth().authToken?.tokenType ?? 'Bearer'; + if ((idToken != null) && idToken.isNotEmpty) { + if (headers == null) { + headers = new Map(); + } + headers[HttpHeaders.authorizationHeader] = "$tokenType $idToken"; + } + } + else if (auth == NetworkAuth.Access) { + String accessToken = Auth().authToken?.accessToken; + if ((accessToken != null) && accessToken.isNotEmpty) { + if (headers == null) { + headers = new Map(); + } + headers['access_token'] = accessToken; + } + } + + //cookies + String cookies = _loadCookiesForRequest(url); + if (AppString.isStringNotEmpty(cookies)) { + if (headers == null) { + headers = new Map(); + } + headers["Cookie"] = cookies; + } + + return headers; + } + + bool _requiresRefreshToken(Http.Response response, NetworkAuth auth){ + return (response != null + && ( +// response.statusCode == 400 || + response.statusCode == 401 + ) + && Auth().isLoggedIn + && (NetworkAuth.User == auth || NetworkAuth.Access == auth)); + } + + void _saveCookiesFromResponse(String url, Http.Response response) { + if (AppString.isStringEmpty(url) || response == null) + return; + + Map responseHeaders = response.headers; + if (responseHeaders == null) + return; + + String setCookie = responseHeaders["set-cookie"]; + if (AppString.isStringEmpty(setCookie)) + return; + + //Split format like this "AWSALB2=12342; Path=/; Expires=Mon, 21 Oct 2019 12:48:37 GMT,AWSALB=1234; Path=/; Expires=Mon, 21 Oct 2019 12:48:37 GMT" + List cookiesData = setCookie.split(new RegExp(",(?! )")); //comma not followed by a space + if (cookiesData == null || cookiesData.length == 0) + return; + + List cookies = List(); + for (String cookieData in cookiesData) { + Cookie cookie = Cookie.fromSetCookieValue(cookieData); + cookies.add(cookie); + } + + var cj = new CookieJar(); + cj.saveFromResponse(Uri.parse(url), cookies); + } + + String _loadCookiesForRequest(String url) { + var cj = new CookieJar(); + List cookies = cj.loadForRequest(Uri.parse(url)); + if (cookies == null || cookies.length == 0) + return null; + + String result = ""; + for (Cookie cookie in cookies) { + result += cookie.name + "=" + cookie.value + "; "; + } + + //remove the last "; " + result = result.substring(0, result.length - 2); + + return result; + } + + Http.Response _responseTimeoutHandler() { + return null; + } + + Uint8List _responseBytesHandler() { + return null; + } +} + diff --git a/lib/service/NotificationService.dart b/lib/service/NotificationService.dart new file mode 100644 index 00000000..c18e43fa --- /dev/null +++ b/lib/service/NotificationService.dart @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class NotificationService { + + static final NotificationService _instance = NotificationService._internal(); + + factory NotificationService() { + return _instance; + } + + NotificationService._internal(); + + static NotificationService get instance { + return _instance; + } + + Map> _listeners = Map(); + + void subscribe(NotificationsListener listener, names) { + if (names is List) { + for (String name in names) { + if (name is String) { + _subscribe(listener, name); + } + } + } + else if (names is String) { + _subscribe(listener, names); + } + } + + void _subscribe(NotificationsListener listener, String name) { + if ((listener != null) && (name != null)) { + Set listenersForName = _listeners[name]; + if (listenersForName == null) { + _listeners[name] = listenersForName = Set(); + } + listenersForName.add(listener); + } + } + + void unsubscribe(NotificationsListener listener, { dynamic names }) { + if (names is List) { + for (String name in names) { + if (name is String) { + _unsubscribe(listener, name); + } + } + } + else if (names is String) { + _unsubscribe(listener, names); + } + else if (names == null) { + _unsubscribeAll(listener); + } + } + + void _unsubscribe(NotificationsListener listener, String name) { + if ((listener != null) && (name != null)) { + // Unsubscribe for 'name' + Set listenersForName = _listeners[name]; + if (listenersForName != null) { + listenersForName.remove(listener); + } + } + } + + void _unsubscribeAll(NotificationsListener listener) { + // Remove all subscriptions of listener + for (Set listenersForName in _listeners.values) { + listenersForName.remove(listener); + } + } + + void notify(String name, [dynamic param]) { + Set listenersForName = _listeners[name]; + if (listenersForName != null) { + for (NotificationsListener listener in listenersForName) { + listener.onNotification(name, param); + } + } + } + +} + +abstract class NotificationsListener { + void onNotification(String name, dynamic param); +} diff --git a/lib/service/OSFHealth.dart b/lib/service/OSFHealth.dart new file mode 100644 index 00000000..ba4879e2 --- /dev/null +++ b/lib/service/OSFHealth.dart @@ -0,0 +1,187 @@ + + + +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:http/http.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/DeepLink.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart' as url_launcher; + +class OSFHealth with Service implements NotificationsListener { + + static const String notifyOnFetchBegin = "edu.illinois.rokwire.osfhealth.fetch.begin"; + static const String notifyOnFetchFinished = "edu.illinois.rokwire.osfhealth.fetch.finished"; + + static const OSF_REDIRECT_URI = 'https://osf.rokwire.illinois.edu/oauth'; + + // Singletone Instance + + OSFHealth._internal(); + + static final OSFHealth _instance = new OSFHealth._internal(); + + factory OSFHealth() { + return _instance; + } + + // Initialization + + @override + void createService() { + NotificationService().subscribe(this, [ + DeepLink.notifyUri, + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + } + + @override + Set get serviceDependsOn { + return Set.from([Storage(), Config(), Auth()]); + } + + Future authenticate() async{ + var uriStr = '${Config().osfBaseUrl}/oauth2/authorize?response_type=code&client_id=${Config().osfClientId}&redirect_uri=$OSF_REDIRECT_URI'; + url_launcher.canLaunch(uriStr).then((bool result) { + if (result) { + url_launcher.launch(uriStr,); + } + }); + } + + Future _handleOSFAuthentication(String code) async{ + NotificationService().notify(notifyOnFetchBegin, null); + NativeCommunicator().dismissSafariVC(); + try { + Response response = await Network().post("${Config().osfBaseUrl}/oauth2/token", + headers: {"Accept": "application/json"}, + body: { + "code": code, + 'grant_type': 'authorization_code', + 'redirect_uri': OSF_REDIRECT_URI, + "client_id": Config().osfClientId, + } + ); + if (response?.statusCode == 200) { + String responseBody = (response?.statusCode == 200) ? response.body : null; + Map responseJson = (responseBody != null) ? AppJson.decode(responseBody) : null; + if (responseJson != null) { + AppToast.show("Logged in successfully"); + HealthOSFAuth _osfAuth = HealthOSFAuth.fromJson(responseJson); + + Response observationResponse = await Network().get("${Config().osfBaseUrl}/api/FHIR/DSTU2/Observation/?patient=${_osfAuth.patient}&category=laboratory", + headers: { + "Accept": "application/json", + "Authorization": "Bearer ${_osfAuth.accessToken}" + }, + ); + if (observationResponse?.statusCode == 200) { + String observationBody = (observationResponse?.statusCode == 200) ? observationResponse.body : null; + print(observationBody); + List osfTests = List(); + Map observJson = (observationBody != null) ? AppJson.decode(observationBody) : null; + if(observJson != null){ + List resultsList = observJson["entry"]; + if(resultsList is List){ + for(Map resultEntry in resultsList){ + if(resultEntry is Map){ + String code = AppMapPathKey.entry(resultEntry, "resource.code.text"); + String dateStr = AppMapPathKey.entry(resultEntry, "resource.issued"); + String valueStr = AppMapPathKey.entry(resultEntry, "resource.valueCodeableConcept.text"); + if(valueStr == null){ + valueStr = AppMapPathKey.entry(resultEntry, "resource.valueString"); + } + if(code != null && dateStr != null && valueStr != null){ + DateTime dateUtc; + try { + dateUtc = DateFormat(AppDateTime.covid19OSFServerDateFormat).parse(dateStr, true); + } catch(e){ print(e); } + osfTests.add(Covid19OSFTest( + dateUtc: dateUtc, + provider: Storage().lastHealthProvider?.name, + providerId: Storage().lastHealthProvider?.id, + testType: code, + testResult: valueStr + )); + } + //AppJson + } + } + } + } + if(osfTests.isNotEmpty){ + Health().processOsfTests(osfTests: osfTests); + } + } + } + } + else { + AppToast.show("POST ${response.request.url.toString()} \n${response.reasonPhrase}"); + } + } finally { + NotificationService().notify(notifyOnFetchFinished, null); + } + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == DeepLink.notifyUri) { + _onDeepLinkUri(param); + } + } + + // Deeplink + + void _onDeepLinkUri(Uri uri) { + if (uri != null) { + Uri osfRedirectUri; + try { osfRedirectUri = Uri.parse(OSF_REDIRECT_URI); } + catch(e) { print(e?.toString()); } + + var code = uri.queryParameters['code']; + if ((osfRedirectUri != null) && + (osfRedirectUri.scheme == uri.scheme) && + (osfRedirectUri.authority == uri.authority) && + (osfRedirectUri.path == uri.path) && + ((code != null) && code.isNotEmpty)) + { + _handleOSFAuthentication(code); + } + } + } +} \ No newline at end of file diff --git a/lib/service/Onboarding.dart b/lib/service/Onboarding.dart new file mode 100644 index 00000000..5a607686 --- /dev/null +++ b/lib/service/Onboarding.dart @@ -0,0 +1,265 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingConsentPanel.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingFinalPanel.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingHowItWorks.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingIntroPanel.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingQrCodePanel.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingResidentInfoPanel.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingReviewScanPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingAuthBluetoothPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingLoginPhoneConfirmPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingGetStartedPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingAuthLocationPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingLoginNetIdPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingLoginPhonePanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingAuthNotificationsPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingRolesPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingLoginPhoneVerifyPanel.dart'; + +class Onboarding with Service implements NotificationsListener { + + static const String notifyFinished = "edu.illinois.rokwire.onboarding.finished"; + + List _contentCodes; + + // Singleton Factory + + Onboarding._internal(); + static final Onboarding _instance = Onboarding._internal(); + + factory Onboarding() { + return _instance; + } + + Onboarding get instance { + return _instance; + } + + // Service + + @override + void createService() { + NotificationService().subscribe(this,[ + FlexUI.notifyChanged, + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + _contentCodes = FlexUI()['onboarding']; + } + + @override + Set get serviceDependsOn { + return Set.from([FlexUI()]); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == FlexUI.notifyChanged) { + _contentCodes = FlexUI()['onboarding']; + } + } + + // Implementation + + Widget get startPanel { + dynamic widget = _nextPanel(null); + return (widget is Widget) ? widget : null; + } + + void next(BuildContext context, OnboardingPanel panel, {bool replace = false}) { + dynamic nextPanel = _nextPanel(panel); + if (nextPanel is Widget) { + if (replace) { + Navigator.pushReplacement(context, CupertinoPageRoute(builder: (context) => nextPanel)); + } + else { + Navigator.push(context, CupertinoPageRoute(builder: (context) => nextPanel)); + } + } + else if ((nextPanel is bool) && !nextPanel) { + finish(context); + } + } + + void finish(BuildContext context) { + NotificationService().notify(notifyFinished, context); + } + + dynamic _nextPanel(OnboardingPanel panel) { + if (_contentCodes != null) { + int nextPanelIndex; + if (panel == null) { + nextPanelIndex = 0; + } + else { + String panelCode = _getPanelCode(panel: panel); + int panelIndex = _contentCodes.indexOf(panelCode); + if (0 <= panelIndex) { + nextPanelIndex = panelIndex + 1; + } + } + + if (nextPanelIndex != null) { + while (nextPanelIndex < _contentCodes.length) { + String nextPanelCode = _contentCodes[nextPanelIndex]; + OnboardingPanel nextPanel = _createPanel(code: nextPanelCode, context: panel?.onboardingContext ?? {}); + if (nextPanel.onboardingCanDisplay) { + return nextPanel as Widget; + } + else { + nextPanelIndex++; + } + } + return false; + } + } + return null; + } + + OnboardingPanel _createPanel({String code, Map context}) { + if (code != null) { + if (code == 'get_started') { + return OnboardingGetStartedPanel(onboardingContext: context); + } + else if (code == 'notifications_auth') { + return OnboardingAuthNotificationsPanel(onboardingContext: context); + } + else if (code == 'location_auth') { + return OnboardingAuthLocationPanel(onboardingContext: context); + } + else if (code == 'bluetooth_auth') { + return OnboardingAuthBluetoothPanel(onboardingContext: context); + } + else if (code == 'roles') { + return OnboardingRolesPanel(onboardingContext: context); + } + else if (code == 'login_netid') { + return OnboardingLoginNetIdPanel(onboardingContext: context); + } + else if (code == 'login_phone') { + return OnboardingLoginPhonePanel(onboardingContext: context); + } + else if (code == 'verify_phone') { + return OnboardingLoginPhoneVerifyPanel(onboardingContext: context); + } + else if (code == 'confirm_phone') { + return OnboardingLoginPhoneConfirmPanel(onboardingContext: context); + } + else if (code == 'resident_info') { + return Covid19OnBoardingResidentInfoPanel(onboardingContext: context); + } + else if (code == 'review_scan') { + return Covid19OnBoardingReviewScanPanel(onboardingContext: context); + } + else if (code == 'covid19_intro') { + return Covid19OnBoardingIntroPanel(onboardingContext: context); + } + else if (code == 'covid19_how_works') { + return Covid19OnBoardingHowItWorks(onboardingContext: context); + } + else if (code == 'covid19_consent') { + return Covid19OnBoardingConsentPanel(onboardingContext: context); + } + else if (code == 'covid19_qrcode') { + return Covid19OnBoardingQrCodePanel(onboardingContext: context); + } + else if (code == 'covid19_final') { + return Covid19OnBoardingFinalPanel(onboardingContext: context); + } + } + return null; + } + + static String _getPanelCode({OnboardingPanel panel}) { + if (panel is OnboardingGetStartedPanel) { + return 'get_started'; + } + else if (panel is OnboardingAuthNotificationsPanel) { + return 'notifications_auth'; + } + else if (panel is OnboardingAuthLocationPanel) { + return 'location_auth'; + } + else if (panel is OnboardingAuthBluetoothPanel) { + return 'bluetooth_auth'; + } + else if (panel is OnboardingRolesPanel) { + return 'roles'; + } + else if (panel is OnboardingLoginNetIdPanel) { + return 'login_netid'; + } + else if (panel is OnboardingLoginPhonePanel) { + return 'login_phone'; + } + else if (panel is OnboardingLoginPhoneVerifyPanel) { + return 'verify_phone'; + } + else if (panel is OnboardingLoginPhoneConfirmPanel) { + return 'confirm_phone'; + } + else if (panel is Covid19OnBoardingResidentInfoPanel) { + return 'resident_info'; + } + else if (panel is Covid19OnBoardingReviewScanPanel) { + return 'review_scan'; + } + else if (panel is Covid19OnBoardingIntroPanel) { + return 'covid19_intro'; + } + else if (panel is Covid19OnBoardingHowItWorks) { + return 'covid19_how_works'; + } + else if (panel is Covid19OnBoardingConsentPanel) { + return 'covid19_consent'; + } + else if (panel is Covid19OnBoardingQrCodePanel) { + return 'covid19_qrcode'; + } + else if (panel is Covid19OnBoardingFinalPanel) { + return 'covid19_final'; + } + return null; + } + +} + +abstract class OnboardingPanel { + + Map get onboardingContext { + return null; + } + + bool get onboardingCanDisplay { + return true; + } +} \ No newline at end of file diff --git a/lib/service/Service.dart b/lib/service/Service.dart new file mode 100644 index 00000000..2e83cdfc --- /dev/null +++ b/lib/service/Service.dart @@ -0,0 +1,160 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/Assets.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/BluetoothServices.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Connectivity.dart'; +import 'package:illinois/service/Crashlytics.dart'; +import 'package:illinois/service/DeepLink.dart'; +import 'package:illinois/service/Exposure.dart'; +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/LocationServices.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/OSFHealth.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/service/LocalNotifications.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/service/User.dart'; + +abstract class Service { + + void createService() { + } + + void destroyService() { + } + + Future initService() async { + } + + void initServiceUI() async { + } + + Set get serviceDependsOn { + return null; + } +} + +class Services { + static final Services _instance = Services._internal(); + + factory Services() { + return _instance; + } + + Services._internal(); + + static Services get instance { + return _instance; + } + + List _services = [ + // Add highest priority services at top + + Crashlytics(), + Storage(), + Config(), + + AppLivecycle(), + AppDateTime(), + Connectivity(), + LocationServices(), + BluetoothServices(), + NativeCommunicator(), + LocalNotifications(), + DeepLink(), + + Localization(), + Assets(), + Styles(), + Auth(), + User(), + Analytics(), + FirebaseMessaging(), + FlexUI(), + Onboarding(), + Health(), + Exposure(), + OSFHealth(), + + // These do not rely on Service initialization API so they are not registered as services. + // ... + ]; + + void create() { + _sort(); + for (Service service in _services) { + service.createService(); + } + } + + void destroy() { + for (Service service in _services) { + service.destroyService(); + } + } + + Future init() async { + for (Service service in _services) { + await service.initService(); + } + } + + void initUI() { + for (Service service in _services) { + service.initServiceUI(); + } + } + + void _sort() { + + List queue = List(); + while (_services.isNotEmpty) { + // start with lowest priority service + Service svc = _services.last; + _services.removeLast(); + + // Move to TBD anyone from Queue that depends on svc + Set svcDependents = svc.serviceDependsOn; + if (svcDependents != null) { + for (int index = queue.length - 1; index >= 0; index--) { + Service queuedSvc = queue[index]; + if (svcDependents.contains(queuedSvc)) { + queue.removeAt(index); + _services.add(queuedSvc); + } + } + } + + // Move svc from TBD to Queue, mark it as processed + queue.add(svc); + } + + _services = queue.reversed.toList(); + } + +} + diff --git a/lib/service/Storage.dart b/lib/service/Storage.dart new file mode 100644 index 00000000..8ff3d0fc --- /dev/null +++ b/lib/service/Storage.dart @@ -0,0 +1,531 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'package:illinois/model/Auth.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class Storage with Service { + + static const String notifySettingChanged = "edu.illinois.rokwire.setting.changed"; + + static final Storage _appStore = new Storage._internal(); + + factory Storage() { + return _appStore; + } + + Storage._internal(); + + SharedPreferences _sharedPreferences; + + @override + Future initService() async { + Log.d("Init Storage"); + _sharedPreferences = await SharedPreferences.getInstance(); + } + + void deleteEverything(){ + for(String key in _sharedPreferences.getKeys()){ + if(key != _configEnvKey){ // skip selected environment + _sharedPreferences.remove(key); + } + } + } + + String _getStringWithName(String name, {String defaultValue}) { + return _sharedPreferences.getString(name) ?? defaultValue; + } + + void _setStringWithName(String name, String value) { + _sharedPreferences.setString(name, value); + NotificationService().notify(notifySettingChanged, name); + } + + List _getStringListWithName(String name, {List defaultValue}) { + return _sharedPreferences.getStringList(name) ?? defaultValue; + } + + void _setStringListWithName(String name, List value) { + _sharedPreferences.setStringList(name, value); + NotificationService().notify(notifySettingChanged, name); + } + + bool _getBoolWithName(String name, {bool defaultValue = false}) { + return _sharedPreferences.getBool(name) ?? defaultValue; + } + + void _setBoolWithName(String name, bool value) { + _sharedPreferences.setBool(name, value); + NotificationService().notify(notifySettingChanged, name); + } + + int _getIntWithName(String name, {int defaultValue = 0}) { + return _sharedPreferences.getInt(name) ?? defaultValue; + } + + void _setIntWithName(String name, int value) { + _sharedPreferences.setInt(name, value); + NotificationService().notify(notifySettingChanged, name); + } + + /*double _getDoubleWithName(String name, {double defaultValue = 0.0}) { + return _sharedPreferences.getDouble(name) ?? defaultValue; + } + + void _setDoubleWithName(String name, double value) { + _sharedPreferences.setDouble(name, value); + NotificationService().notify(notifySettingChanged, name); + }*/ + + + dynamic operator [](String name) { + return _sharedPreferences.get(name); + } + + // Notifications + + bool getNotifySetting(String name) { + return _getBoolWithName(name, defaultValue: null); + } + + void setNotifySetting(String name, bool value) { + return _setBoolWithName(name, value); + } + + ///////////// + // User + + static const String userKey = 'user'; + + UserData get userData { + final String userToString = _getStringWithName(userKey); + final Map userToJson = AppJson.decode(userToString); + return (userToJson != null) ? UserData.fromJson(userToJson) : null; + } + + set userData(UserData user) { + String userToString = (user != null) ? json.encode(user) : null; + _setStringWithName(userKey, userToString); + } + + ///////////// + // UserRoles + + static const String userRolesKey = 'user_roles'; + + List get userRolesJson { + final String userRolesToString = _getStringWithName("user_roles"); + return AppJson.decode(userRolesToString); + } + + Set get userRoles { + final List userRolesToJson = userRolesJson; + return (userRolesToJson != null) ? Set.from(userRolesToJson.map((value)=>UserRole.fromString(value))) : null; + } + + set userRoles(Set userRoles) { + String userRolesToString = (userRoles != null) ? json.encode(userRoles.toList()) : null; + _setStringWithName(userRolesKey, userRolesToString); + } + + static const String phoneNumberKey = 'user_phone_number'; + + String get phoneNumber { + return _getStringWithName(phoneNumberKey); + } + + set phoneNumber(String phoneNumber) { + _setStringWithName(phoneNumberKey, phoneNumber); + } + + static const String localUserUuidKey = 'user_local_uuid'; + + String get localUserUuid { + return _getStringWithName(localUserUuidKey); + } + + set localUserUuid(String value) { + _setStringWithName(localUserUuidKey, value); + } + + ///////////// + // UserPII + + static const String userPidKey = 'user_pid'; + + String get userPid { + return _getStringWithName(userPidKey); + } + + set userPid(String userPid) { + _setStringWithName(userPidKey, userPid); + } + + static const String userPiiDataTimeKey = '_user_pii_data_time'; + + int get userPiiDataTime { + return _getIntWithName(userPiiDataTimeKey); + } + + set userPiiDataTime(int value) { + _setIntWithName(userPiiDataTimeKey, value); + } + + /////////////// + // On Boarding + + static const String onBoardingPassedKey = 'on_boarding_passed'; + + bool get onBoardingPassed { + return _getBoolWithName(onBoardingPassedKey, defaultValue: false); + } + + set onBoardingPassed(bool showOnBoarding) { + _setBoolWithName(onBoardingPassedKey, showOnBoarding); + } + + //////////////// + // Upgrade + + static const String reportedUpgradeVersionsKey = 'reported_upgrade_versions'; + + Set get reportedUpgradeVersions { + List list = _getStringListWithName(reportedUpgradeVersionsKey); + return (list != null) ? Set.from(list) : Set(); + } + + set reportedUpgradeVersion(String version) { + if (version != null) { + Set versions = reportedUpgradeVersions; + versions.add(version); + _setStringListWithName(reportedUpgradeVersionsKey, versions.toList()); + } + } + + //////////////////////////// + // Last Run Version + + static const String lastRunVersionKey = 'last_run_version'; + + String get lastRunVersion { + return _getStringWithName(lastRunVersionKey); + } + + set lastRunVersion(String value) { + _setStringWithName(lastRunVersionKey, value); + } + + //////////////// + // Auth + + static const String authTokenKey = '_auth_token'; + + AuthToken get authToken { + try { + String jsonString = _getStringWithName(authTokenKey); + dynamic jsonData = AppJson.decode(jsonString); + return (jsonData != null) ? AuthToken.fromJson(jsonData) : null; + } on Exception catch (e) { print(e.toString()); } + return null; + } + + set authToken(AuthToken value) { + _setStringWithName(authTokenKey, value != null ? json.encode(value.toJson()) : null); + } + + static const String authInfoKey = '_auth_info'; + + AuthInfo get authInfo { + final String authInfoToString = _getStringWithName(authInfoKey); + AuthInfo authInfo = AuthInfo.fromJson(AppJson.decode(authInfoToString)); + return authInfo; + } + + set authInfo(AuthInfo value) { + _setStringWithName(authInfoKey, value != null ? json.encode(value.toJson()) : null); + } + + static const String authCardTimeKey = '_auth_card_time'; + + int get authCardTime { + return _getIntWithName(authCardTimeKey); + } + + set authCardTime(int value) { + _setIntWithName(authCardTimeKey, value); + } + + ///////////////////// + // Date offset + + static const String offsetDateKey = 'settings_offset_date'; + + set offsetDate(DateTime value) { + _setStringWithName(offsetDateKey, AppDateTime().formatDateTime(value, ignoreTimeZone: true)); + } + + DateTime get offsetDate { + String dateString = _getStringWithName(offsetDateKey); + return AppString.isStringNotEmpty(dateString) ? AppDateTime() + .dateTimeFromString(dateString) : null; + } + + ///////////////// + // Language + + static const String currentLanguageKey = 'current_language'; + + String get currentLanguage { + return _getStringWithName(currentLanguageKey); + } + + set currentLanguage(String value) { + _setStringWithName(currentLanguageKey, value); + } + + ////////////////// + // Location Services + + static const String locationServicesPermisionRequestedKey = 'location_services_permision_requested'; + + bool get locationServicesPermisionRequested { + return _getBoolWithName(locationServicesPermisionRequestedKey); + } + + set locationServicesPermisionRequested(bool value) { + _setBoolWithName(locationServicesPermisionRequestedKey, value); + } + + ////////////////// + // Favorites + + static const String favoritesKey = 'user_favorites_list'; + + List get favorites{ + List storedValue = _sharedPreferences.getStringList(favoritesKey); + return storedValue?? List(); + } + + set favorites(List favorites){ + List storeValue = favorites.map((Object e){return e.toString();}).toList(); + _sharedPreferences.setStringList(favoritesKey, storeValue); + } + + static const String favoritesDialogWasVisibleKey = 'favorites_dialog_was_visible'; + + bool get favoritesDialogWasVisible { + return _getBoolWithName(favoritesDialogWasVisibleKey); + } + + set favoritesDialogWasVisible(bool value) { + _setBoolWithName(favoritesDialogWasVisibleKey, value); + } + + ////////////// + // Debug + + static const String debugUseDeviceLocalTimeZoneKey = 'debug_use_device_local_time_zone'; + + bool get debugUseDeviceLocalTimeZone { + return _getBoolWithName(debugUseDeviceLocalTimeZoneKey, defaultValue: true); + } + + set debugUseDeviceLocalTimeZone(bool value) { + _setBoolWithName(debugUseDeviceLocalTimeZoneKey, value); + } + + static const String debugMapThresholdDistanceKey = 'debug_map_threshold_distance'; + + int get debugMapThresholdDistance { + return _getIntWithName(debugMapThresholdDistanceKey, defaultValue: 200); + } + + set debugMapThresholdDistance(int value) { + _setIntWithName(debugMapThresholdDistanceKey, value); + } + + static const String debugMapLocationProviderKey = 'debug_map_location_provider'; + + bool get debugMapLocationProvider { + return _getBoolWithName(debugMapLocationProviderKey, defaultValue: false); + } + + set debugMapLocationProvider(bool value) { + _setBoolWithName(debugMapLocationProviderKey, value); + } + + static const String debugMapHideLevelsKey = 'debug_map_hide_levels'; + + bool get debugMapHideLevels { + return _getBoolWithName(debugMapHideLevelsKey, defaultValue: false); + } + + set debugMapHideLevels(bool value) { + _setBoolWithName(debugMapHideLevelsKey, value); + } + + ////////////// + // Permanent subscription + + static const String firebaseSubscriptionTopisKey = 'firebase_subscription_topis'; + + Set get firebaseSubscriptionTopis { + List topicsList = _getStringListWithName(firebaseSubscriptionTopisKey); + return (topicsList != null) ? Set.from(topicsList) : null; + } + + set firebaseSubscriptionTopis(Set value) { + List topicsList = (value != null) ? List.from(value) : null; + _setStringListWithName(firebaseSubscriptionTopisKey, topicsList); + } + + void addFirebaseSubscriptionTopic(String value) { + Set topis = firebaseSubscriptionTopis ?? Set(); + topis.add(value); + firebaseSubscriptionTopis = topis; + } + + void removeFirebaseSubscriptionTopic(String value) { + Set topis = firebaseSubscriptionTopis; + if (topis != null) { + topis.remove(value); + firebaseSubscriptionTopis = topis; + } + } + + ///////////// + // Config + + static const String _configEnvKey = 'config_environment'; + + String get configEnvironment { + return _getStringWithName(_configEnvKey); + } + + set configEnvironment(String value) { + _setStringWithName(_configEnvKey, value); + } + + ///////////// + // Health + + static const String _currentHealthCountyIdKey = 'health_current_county_id'; + + String get currentHealthCountyId { + return _getStringWithName(_currentHealthCountyIdKey); + } + + set currentHealthCountyId(String value) { + _setStringWithName(_currentHealthCountyIdKey, value); + } + + static const String _lastHealthProviderKey = 'health_last_provider'; + + HealthServiceProvider get lastHealthProvider { + String storedProviderJson = _sharedPreferences.getString(_lastHealthProviderKey); + return HealthServiceProvider.fromJson(storedProviderJson!=null ? json.decode(storedProviderJson) : null); + } + + set lastHealthProvider(HealthServiceProvider value) { + _sharedPreferences.setString(_lastHealthProviderKey, value!=null? json.encode(value.toJson()) :value); + } + + static const String lastHealthCovid19StatusKey = 'health_last_covid19_status'; + + String get lastHealthCovid19Status { + return _getStringWithName(lastHealthCovid19StatusKey); + } + + set lastHealthCovid19Status(String value) { + _setStringWithName(lastHealthCovid19StatusKey, value); + } + + static const String healthUserKey = 'health_user'; + + String get healthUser { + return _getStringWithName(healthUserKey); + } + + set healthUser(String value) { + _setStringWithName(healthUserKey, value); + } + + static const String lastHealthCovid19OsfTestDateKey = 'health_last_covid19_osf_test_date'; + + DateTime get lastHealthCovid19OsfTestDate { + String dateString = _getStringWithName(lastHealthCovid19OsfTestDateKey); + return AppString.isStringNotEmpty(dateString) ? AppDateTime() + .dateTimeFromString(dateString) : null; + } + + set lastHealthCovid19OsfTestDate(DateTime value) { + _setStringWithName(lastHealthCovid19OsfTestDateKey, AppDateTime().formatDateTime(value, ignoreTimeZone: true)); + } + + static const String lastHealthStatusEvalKey = '_health_last_status_eval'; + + int get lastHealthStatusEval { + return _getIntWithName(lastHealthStatusEvalKey, defaultValue: null); + } + + set lastHealthStatusEval(int value) { + _setIntWithName(lastHealthStatusEvalKey, value); + } + + + ///////////// + // Exposure + + static const String _exposureStartedKey = 'exposure_started'; + + bool get exposureStarted { + return _getBoolWithName(_exposureStartedKey, defaultValue: true); + } + + set exposureStarted(bool value) { + _setBoolWithName(_exposureStartedKey, value); + } + + static const String _exposureReportTargetTimestampKey = 'exposure_report_target_timestamp'; + + int get exposureReportTargetTimestamp { + return _getIntWithName(_exposureReportTargetTimestampKey, defaultValue: null); + } + + set exposureReportTargetTimestamp(int value) { + _setIntWithName(_exposureReportTargetTimestampKey, value); + } + + static const String _exposureLastReportedTimestampKey = 'exposure_last_reported_timestamp'; + + int get exposureLastReportedTimestamp { + return _getIntWithName(_exposureLastReportedTimestampKey, defaultValue: null); + } + + set exposureLastReportedTimestamp(int value) { + _setIntWithName(_exposureLastReportedTimestampKey, value); + } + + ///////////// +} diff --git a/lib/service/Styles.dart b/lib/service/Styles.dart new file mode 100644 index 00000000..86988eb9 --- /dev/null +++ b/lib/service/Styles.dart @@ -0,0 +1,351 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:io'; +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/services.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:path/path.dart'; +import 'package:http/http.dart' as http; + +class Styles extends Service implements NotificationsListener{ + static const String notifyChanged = "edu.illinois.rokwire.styles.changed"; + static const String _assetsName = "styles.json"; + + File _cacheFile; + DateTime _pausedDateTime; + + Map _stylesData; + + UiColors _colors; + UiColors get colors => _colors; + + UiFontFamilies _fontFamilies; + UiFontFamilies get fontFamilies => _fontFamilies; + + Map _textStylesMap; + UiStyles _uiStyles; + UiStyles get uiStyles => _uiStyles; + + static final Styles _logic = Styles._internal(); + + factory Styles() { + return _logic; + } + + Styles._internal(); + + // Initialization + + @override + void createService() { + NotificationService().subscribe(this, AppLivecycle.notifyStateChanged); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + await _getCacheFile(); + await _loadFromCache(); + if (_stylesData == null) { + await _loadFromAssets(); + } + _loadFromNet(); + } + + @override + Set get serviceDependsOn { + return Set.from([Config()]); + } + + // Public + + TextStyle getTextStyle(String key){ + dynamic style = _textStylesMap[key]; + return (style is TextStyle) ? style : null; + } + + // Private + + Future _getCacheFile() async { + Directory assetsDir = Config().assetsCacheDir; + if ((assetsDir != null) && !await assetsDir.exists()) { + await assetsDir.create(recursive: true); + } + String cacheFilePath = (assetsDir != null) ? join(assetsDir.path, _assetsName) : null; + _cacheFile = (cacheFilePath != null) ? File(cacheFilePath) : null; + } + + Future _loadFromCache() async { + try { + String stylesContent = ((_cacheFile != null) && await _cacheFile.exists()) ? await _cacheFile.readAsString() : null; + await _applyContent(stylesContent); + } catch (e) { + print(e.toString()); + } + } + + Future _loadFromAssets() async { + try { + String stylesContent = await rootBundle.loadString('assets/$_assetsName'); + await _applyContent(stylesContent); + } catch (e) { + print(e.toString()); + } + } + + Future _loadFromNet() async { + try { + http.Response response = (Config().assetsUrl != null) ? await Network().get("${Config().assetsUrl}/$_assetsName") : null; + String stylesContent = ((response != null) && (response.statusCode == 200)) ? response.body : null; + if(stylesContent != null) { + await _applyContent(stylesContent, cacheContent: true, notifyUpdate: true); + } + } catch (e) { + print(e.toString()); + } + } + + Future _applyContent(String stylesContent, {bool cacheContent = false, bool notifyUpdate = false}) async { + try { + Map styles = (stylesContent != null) ? AppJson.decode(stylesContent) : null; + if ((styles != null) && styles.isNotEmpty) { + if ((_stylesData == null) || !DeepCollectionEquality().equals(_stylesData, styles)) { + _stylesData = styles; + if ((_cacheFile != null) && cacheContent) { + await _cacheFile.writeAsString(stylesContent, flush: true); + } + } + } + _buildData(); + if (notifyUpdate) { + NotificationService().notify(notifyChanged, null); + } + } catch (e) { + print(e.toString()); + } + } + + void _buildData(){ + _buildColorsData(); + _buildFontFamiliesData(); + _buildStylesData(); + } + + void _buildColorsData(){ + if(_stylesData != null) { + dynamic colorsData = _stylesData["color"]; + Map colors = Map(); + if(colorsData is Map){ + colorsData.forEach((dynamic key, dynamic value){ + if(key is String && value is String){ + if(value.startsWith("#")){ + colors[key] = UiColors.fromHex(value); + } else if(value.contains(".")){ + colors[key] = UiColors.fromHex(AppMapPathKey.entry(_stylesData, value)); + } + } + }); + } + _colors = UiColors(colors); + } + } + + void _buildFontFamiliesData(){ + if(_stylesData != null) { + dynamic familyData = _stylesData["font_family"]; + if(familyData is Map) { + Map castedData = familyData.cast(); + _fontFamilies = UiFontFamilies(castedData); + } + } + } + + void _buildStylesData(){ + if(_stylesData != null) { + dynamic stylesData = _stylesData["text_style"]; + Map styles = Map(); + if(stylesData is Map){ + stylesData.forEach((dynamic key, dynamic value){ + if(key is String && value is Map){ + double fontSize = value['size']; + String fontFamily = value['font_family']; + String rawColor = value['color']; + Color color = rawColor != null ? (rawColor.startsWith("#") ? UiColors.fromHex(rawColor) : colors.getColor(rawColor)) : null; + double letterSpacing = value['letter_spacing']; // Not mandatory + styles[key] = TextStyle(fontFamily: fontFamily, fontSize: fontSize, color: color, letterSpacing: letterSpacing, ); + } + }); + } + _textStylesMap = styles; + } + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == AppLivecycle.notifyStateChanged) { + _onAppLivecycleStateChanged(param); + } + } + + void _onAppLivecycleStateChanged(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _pausedDateTime = DateTime.now(); + } + else if (state == AppLifecycleState.resumed) { + if (_pausedDateTime != null) { + Duration pausedDuration = DateTime.now().difference(_pausedDateTime); + if (Config().refreshTimeout < pausedDuration.inSeconds) { + _loadFromNet(); + } + } + } + } +} + +class UiColors { + + final Map _colorMap; + + UiColors(this._colorMap); + + Color get fillColorPrimary => _colorMap['fillColorPrimary']; + Color get fillColorPrimaryTransparent03 => _colorMap['fillColorPrimaryTransparent03']; + Color get fillColorPrimaryTransparent05 => _colorMap['fillColorPrimaryTransparent05']; + Color get fillColorPrimaryTransparent09 => _colorMap['fillColorPrimaryTransparent09']; + Color get fillColorPrimaryTransparent015 => _colorMap['fillColorPrimaryTransparent015']; + Color get fillColorPrimaryTransparent80 => _colorMap['fillColorPrimaryTransparent80']; + Color get textColorPrimary => _colorMap['textColorPrimary']; + Color get fillColorPrimaryVariant => _colorMap['fillColorPrimaryVariant']; + Color get textColorPrimaryVariant => _colorMap['textColorPrimaryVariant']; + Color get fillColorSecondary => _colorMap['fillColorSecondary']; + Color get fillColorSecondaryTransparent05 => _colorMap['fillColorSecondaryTransparent05']; + Color get textColorSecondary => _colorMap['textColorSecondary']; + Color get fillColorSecondaryVariant => _colorMap['fillColorSecondaryVariant']; + Color get textColorSecondaryVariant => _colorMap['textColorSecondaryVariant']; + + Color get surface => _colorMap['surface']; + Color get textSurface => _colorMap['textSurface']; + Color get surfaceAccent => _colorMap['surfaceAccent']; + Color get textSurfaceAccent => _colorMap['textSurfaceAccent']; + Color get background => _colorMap['background']; + Color get textBackground => _colorMap['textBackground']; + Color get backgroundVariant => _colorMap['backgroundVariant']; + Color get textBackgroundVariant => _colorMap['textBackgroundVariant']; + + Color get accentColor1 => _colorMap['accentColor1']; + Color get accentColor2 => _colorMap['accentColor2']; + Color get accentColor3 => _colorMap['accentColor3']; + + Color get iconColor => _colorMap['iconColor']; + + Color get eventColor => _colorMap['eventColor']; + Color get diningColor => _colorMap['diningColor']; + Color get placeColor => _colorMap['placeColor']; + + Color get white => _colorMap['white']; + Color get whiteTransparent01 => _colorMap['whiteTransparent01']; + Color get whiteTransparent06 => _colorMap['whiteTransparent06']; + Color get blackTransparent06 => _colorMap['blackTransparent06']; + Color get blackTransparent018 => _colorMap['blackTransparent018']; + + Color get mediumGray => _colorMap['mediumGray']; + Color get mediumGray1 => _colorMap['mediumGray1']; + Color get mediumGray2 => _colorMap['mediumGray2']; + Color get lightGray => _colorMap['lightGray']; + Color get disabledTextColor => _colorMap['disabledTextColor']; + Color get disabledTextColorTwo => _colorMap['disabledTextColorTwo']; + + Color get healthStatusGreen => _colorMap['healthStatusGreen']; + Color get healthStatusYellow => _colorMap['healthStatusYellow']; + Color get healthStatusOrange => _colorMap['healthStatusOrange']; + Color get healthStatusRed => _colorMap['healthStatusRed']; + + Color get lightBlue => _colorMap['lightBlue']; + + Color getColor(String key){ + dynamic color = _colorMap[key]; + return (color is Color) ? color : null; + } + + static Color fromHex(String value) { + if (value != null) { + final buffer = StringBuffer(); + if (value.length == 6 || value.length == 7) { + buffer.write('ff'); + } + buffer.write(value.replaceFirst('#', '')); + + try { return Color(int.parse(buffer.toString(), radix: 16)); } + on Exception catch (e) { print(e.toString()); } + } + return null; + } + + static String toHex(Color value, {bool leadingHashSign = true}) { + if (value != null) { + return "${leadingHashSign ? '#' : ''}" + + "${value.alpha.toRadixString(16)}" + + "${value.red.toRadixString(16)}" + + "${value.green.toRadixString(16)}" + + "${value.blue.toRadixString(16)}"; + } + return null; + } +} + +class UiFontFamilies{ + final Map _familyMap; + UiFontFamilies(this._familyMap); + + String get black => _familyMap["black"]; + String get blackIt => _familyMap["black_italic"]; + String get bold => _familyMap["bold"]; + String get boldIt => _familyMap["bold_italic"]; + String get extraBold => _familyMap["extra_bold"]; + String get extraBoldIt => _familyMap["extra_bold_italic"]; + String get light => _familyMap["light"]; + String get lightIt => _familyMap["light_italic"]; + String get medium => _familyMap["medium"]; + String get mediumIt => _familyMap["medium_italic"]; + String get regular => _familyMap["regular"]; + String get regularIt => _familyMap["regular_italic"]; + String get semiBold => _familyMap["semi_bold"]; + String get semiBoldIt => _familyMap["semi_bold_italic"]; + String get thin => _familyMap["thin"]; + String get thinIt => _familyMap["thin_italic"]; +} + +class UiStyles { + + final Map _styleMap; + UiStyles(this._styleMap); + + TextStyle get headerBar => _styleMap['header_bar']; +} \ No newline at end of file diff --git a/lib/service/TransportationService.dart b/lib/service/TransportationService.dart new file mode 100644 index 00000000..3ca05c25 --- /dev/null +++ b/lib/service/TransportationService.dart @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:ui'; + +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class TransportationService /* with Service */ { + + static final TransportationService _logic = TransportationService._internal(); + + factory TransportationService() { + return _logic; + } + + TransportationService._internal(); + + Future loadBussColor({String userId, String deviceId}) async { + String transportationUrl =Config().transportationUrl; + String url = "$transportationUrl/bus/color"; + Map data = { + 'user_id': userId, + 'device_id': deviceId, + }; + + try { + String body = json.encode(data); + final response = await Network().get(url, auth: NetworkAuth.App, body:body); + + String responseBody = response?.body; + if ((response != null) && (response.statusCode == 200)) { + Map jsonData = AppJson.decode(responseBody); + String colorHex = jsonData["color"]; + return AppString.isStringNotEmpty(colorHex) ? UiColors.fromHex(colorHex) : null; + } else { + Log.e('Failed to load buss color'); + Log.e(responseBody); + } + } catch(e){} + return null; + } + + Future loadBussPass({String userId, String deviceId, Map iBeaconData}) async { + try { + String url = "${Config().transportationUrl}/bus/pass"; + Map data = { + 'user_id': userId, + 'device_id': deviceId, + 'ibeacon_data': iBeaconData, + }; + String body = json.encode(data); + final response = await Network().get(url, auth: NetworkAuth.App, body:body); + if (response != null) { + if (response.statusCode == 200) { + String responseBody = response.body; + return AppJson.decode(responseBody); + } else { + return response.statusCode; + } + } + } + catch(e) { + Log.e(e.toString()); + } + return null; + } +} \ No newline at end of file diff --git a/lib/service/User.dart b/lib/service/User.dart new file mode 100644 index 00000000..27f99bb5 --- /dev/null +++ b/lib/service/User.dart @@ -0,0 +1,598 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'dart:convert'; + +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/AppLivecycle.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:http/http.dart' as http; + +class User with Service implements NotificationsListener { + + static const String notifyUserUpdated = "edu.illinois.rokwire.user.updated"; + static const String notifyUserDeleted = "edu.illinois.rokwire.user.deleted"; + static const String notifyTagsUpdated = "edu.illinois.rokwire.user.tags.updated"; + static const String notifyRolesUpdated = "edu.illinois.rokwire.user.roles.updated"; + static const String notifyFavoritesUpdated = "edu.illinois.rokwire.user.favorites.updated"; + static const String notifyInterestsUpdated = "edu.illinois.rokwire.user.interests.updated"; + + static final String sportsInterestCategory = "sports"; + + UserData _userData; + + http.Client _client = http.Client(); + + static final User _service = new User._internal(); + + factory User() { + return _service; + } + + User._internal(); + + @override + void createService() { + NotificationService().subscribe(this, [ + AppLivecycle.notifyStateChanged, + FirebaseMessaging.notifyToken, + ]); + } + + @override + void destroyService() { + NotificationService().unsubscribe(this); + } + + @override + Future initService() async { + + _userData = Storage().userData; + + if (_userData == null) { + await _createUser(); + } else if (_userData.uuid != null) { + await _loadUser(); + } + } + + Set get serviceDependsOn { + return Set.from([Storage(), Config()]); + } + + // NotificationsListener + @override + void onNotification(String name, dynamic param) { + if (name == FirebaseMessaging.notifyToken) { + _updateFCMToken(); + } + else if(name == AppLivecycle.notifyStateChanged && param == AppLifecycleState.resumed){ + //_loadUser(); + } + } + + // User + + String get uuid { + return _userData?.uuid; + } + + UserData get data { + return _userData; + } + + static String get analyticsUuid { + return UserData.analyticsUuid; + } + + Future _createUser() async { + UserData userData = await _requestCreateUser(); + applyUserData(userData); + Storage().localUserUuid = userData?.uuid; + } + + Future _loadUser() async { + // silently refresh user profile + requestUser(_userData.uuid).then((UserData userData) { + if (userData != null) { + applyUserData(userData, applyCachedSettings: true); + } + }) + .catchError((_){ + _clearStoredUserData(); + }, test: (error){return error is UserNotFoundException;}); + } + + Future _updateUser() async { + + if (_userData == null) { + return; + } + + // Stop previous request + if (_client != null) { + _client.close(); + } + + http.Client client; + _client = client = http.Client(); + + String userUuid = _userData.uuid; + String url = (Config().userProfileUrl != null) ? "${Config().userProfileUrl}/$userUuid" : null; + Map headers = {"Accept": "application/json","content-type":"application/json"}; + final response = await Network().put(url, body: json.encode(_userData.toJson()), headers: headers, client: _client, auth: NetworkAuth.App); + String responseBody = response?.body; + bool success = ((response != null) && (responseBody != null) && (response.statusCode == 200)); + + if (!success) { + //error + String message = "Error on updating user - " + (response != null ? response.statusCode.toString() : "null"); + Crashlytics().log(message); + } + else if (_client == client) { + _client = null; + Map jsonData = AppJson.decode(responseBody); + UserData update = UserData.fromJson(jsonData); + if (update != null) { + Storage().userData = _userData = update; + //_notifyUserUpdated(); + } + } + else { + Log.d("Updating user canceled"); + } + + } + + Future requestUser(String uuid) async { + String url = ((Config().userProfileUrl != null) && (uuid != null) && (0 < uuid.length)) ? '${Config().userProfileUrl}/$uuid' : null; + + final response = await Network().get(url, auth: NetworkAuth.App); + + if(response != null) { + if (response?.statusCode == 404) { + throw UserNotFoundException(); + } + + String responseBody = ((response != null) && (response?.statusCode == 200)) ? response?.body : null; + Map jsonData = AppJson.decode(responseBody); + if (jsonData != null) { + return UserData.fromJson(jsonData); + } + } + + return null; + } + + Future _requestCreateUser() async { + try { + final response = await Network().post(Config().userProfileUrl, auth: NetworkAuth.App, timeout: 10); + if ((response != null) && (response.statusCode == 200)) { + String responseBody = response.body; + Map jsonData = AppJson.decode(responseBody); + return UserData.fromJson(jsonData); + } else { + return null; + } + } catch(e){ + Log.e('Failed to create user'); + Log.e(e.toString()); + return null; + } + } + + Future deleteUser() async{ + String userUuid = _userData?.uuid; + if((Config().userProfileUrl != null) && (userUuid != null)) { + await Network().delete("${Config().userProfileUrl}/$userUuid", headers: {"Accept": "application/json", "content-type": "application/json"}, auth: NetworkAuth.App); + + _clearStoredUserData(); + _notifyUserDeleted(); + + try { + _userData = await requestUser(Storage().localUserUuid); + } on UserNotFoundException catch (_) { + _userData = await _requestCreateUser(); + if (_userData?.uuid != null) { + Storage().localUserUuid = _userData?.uuid; + } + } + if (_userData != null) { + Storage().userData = _userData; + _notifyUserUpdated(); + } + } + + } + + void initLocalUser() { + String localUserUuid = Storage().localUserUuid; + String currentUserUuid = _userData?.uuid; + if ((localUserUuid != null) && (currentUserUuid == null) || (currentUserUuid != localUserUuid)) { + requestUser(localUserUuid).then((UserData userData){ + if (userData != null) { + applyUserData(userData); + } + //clearStoredPiiAccount(); + }).catchError((_){ + }); + } + } + + void applyUserData(UserData userData, { bool applyCachedSettings = false }) { + + // 1. We might need to remove FCM token from current user + String applyUserUuid = userData?.uuid; + String currentUserUuid = _userData?.uuid; + bool userSwitched = (currentUserUuid != null) && (currentUserUuid != applyUserUuid); + if (userSwitched && _removeFCMToken(_userData)) { + String url = "${Config().userProfileUrl}/${_userData.uuid}"; + Map headers = {"Accept": "application/json","content-type":"application/json"}; + String post = json.encode(_userData.toJson()); + Network().put(url, body: post, headers: headers, auth: NetworkAuth.App); + } + + // 2. We might need to add FCM token and user roles from Storage to new user + bool applyUserUpdated = _applyFCMToken(userData); + if (applyCachedSettings) { + applyUserUpdated = _updateUserSettingsFromStorage(userData) || applyUserUpdated; + } + + _userData = userData; + Storage().userData = _userData; + Storage().userRoles = userData?.roles; + + if (userSwitched) { + _notifyUserUpdated(); + } + + if (applyUserUpdated) { + _updateUser(); + } + } + + void _clearStoredUserData(){ + _userData = null; + Storage().userData = null; + Auth().logout(); + Storage().onBoardingPassed = false; + } + + // FCM Tokens + + void _updateFCMToken() { + if (_applyFCMToken(_userData)) { + _updateUser(); + } + } + + static bool _applyFCMToken(UserData userData) { + String fcmToken = FirebaseMessaging().token; + if ((userData != null) && (fcmToken != null)) { + if (userData.fcmTokens == null) { + userData.fcmTokens = Set.from([fcmToken]); + return true; + } + else if (!userData.fcmTokens.contains(fcmToken)) { + userData.fcmTokens.add(fcmToken); + return true; + } + } + return false; + } + + static bool _removeFCMToken(UserData userData) { + String fcmToken = FirebaseMessaging().token; + if ((userData != null) && (userData.fcmTokens != null) && (fcmToken != null) && userData.fcmTokens.contains(fcmToken)) { + userData.fcmTokens.remove(fcmToken); + return true; + } + return false; + } + + // Backward compatability + stability (use last stored roles & privacy if they are missing) + static bool _updateUserSettingsFromStorage(UserData userData) { + bool userUpdated = false; + + if (userData != null) { + if (userData.roles == null) { + userData.roles = Storage().userRoles; + userUpdated = userUpdated || (userData.roles != null); + } + } + + return userUpdated; + } + + // Privacy + + int get privacyLevel { + return _userData?.privacyLevel ?? UserData.PrivacyLevel; + } + + bool privacyMatch(int requredPrivacyLevel) { + return (this.privacyLevel >= requredPrivacyLevel); + } + + bool get favoritesStarVisible { + return privacyMatch(2); + } + + bool get showTicketsConfirmationModal { + return !privacyMatch(4); + } + + //Favorites + void switchFavorite(Favorite favorite) { + if(isFavorite(favorite)) + removeFavorite(favorite); + else + addFavorite(favorite); + } + + void addFavorite(Favorite favorite) { + if(favorite==null || _userData==null) + return; + + if(AppString.isStringNotEmpty(favorite.favoriteId)) { + _userData.addFavorite(favorite.favoriteKey,favorite.favoriteId); + _notifyUserFavoritesUpdated(); + _updateUser().then((_) { + _notifyUserFavoritesUpdated(); + }); + } + } + + void addAllFavorites(List favorites) { + if ((_userData == null) || AppCollection.isCollectionEmpty(favorites)) { + return; + } + String favoriteKey = favorites.first?.favoriteKey; + Set uiuds = favorites.map(((value) => value.favoriteId)).toSet(); + _userData.addAllFavorites(favoriteKey, uiuds); + _notifyUserFavoritesUpdated(); + _updateUser().then((_) { + _notifyUserFavoritesUpdated(); + }); + } + + void removeFavorite(Favorite favorite) { + if(favorite==null || _userData==null) + return; + + if(AppString.isStringNotEmpty(favorite.favoriteId)) { + _userData.removeFavorite(favorite.favoriteKey,favorite.favoriteId); + _notifyUserFavoritesUpdated(); + _updateUser().then((_) { + _notifyUserFavoritesUpdated(); + }); + } + } + + void removeAllFavorites(List favorites) { + if ((_userData == null) || AppCollection.isCollectionEmpty(favorites)) { + return; + } + String favoriteKey = favorites.first?.favoriteKey; + Set uiuds = favorites.map(((value) => value.favoriteId)).toSet(); + _userData.removeAllFavorites(favoriteKey, uiuds); + _notifyUserFavoritesUpdated(); + _updateUser().then((_) { + _notifyUserFavoritesUpdated(); + }); + } + + + bool isFavorite(Favorite favorite) { + return _userData?.isFavorite(favorite) ?? false; + } + + Set getFavorites(String favoriteKey) { + return _userData?.getFavorites(favoriteKey); + } + + //Sport categories (Interest) + switchInterestCategory(String categoryName) async{ + _userData?.switchCategory(categoryName); + + _updateUser().then((_){ + _notifyUserInterestsUpdated(); + }); + } + + switchSportSubCategory(String sportSubCategory) async { + if(_userData!=null){ + _userData.switchInterestSubCategory(sportsInterestCategory, sportSubCategory); + // the ui should be updated immediately + _notifyUserInterestsUpdated(); + } + + _updateUser().then((_){ + _notifyUserInterestsUpdated(); + }); + } + + switchSportSubCategories(List sportSubCategories) async { + if (sportSubCategories == null || sportSubCategories.isEmpty) { + return; + } + if (_userData != null) { + for (String category in sportSubCategories) { + _userData.switchInterestSubCategory(sportsInterestCategory, category); + } + _notifyUserInterestsUpdated(); + _updateUser().then((_) { + _notifyUserInterestsUpdated(); + }); + } + } + + List getSportsInterestSubCategories() { + return _userData?.interests!=null?_userData?.interests[sportsInterestCategory] : null; + } + + Map> getInterests() { + return _userData?.interests; + } + + List getInterestsCategories() { + if(_userData!=null && _userData.interests!=null){ + return _userData.interests.keys.toList(); + } else { + return null; + } + } + + /////// + //Tags + List getTags() { + return _userData?.positiveTags; + } + + switchTag(String tag,{bool fastRefresh=true}) { + bool positiveInterest = true; + if(isTagged(tag,positiveInterest)){ + removeTag(tag,fastRefresh); + } else { + addTag(tag, positiveInterest,fastRefresh); + } + } + + addTag(String tag, bool positiveInterest, bool fastRefresh) { + if(tag==null || _userData==null) + return; + + if(AppString.isStringNotEmpty(tag)) { + if(positiveInterest){ + _userData.addPositiveTag(tag); + } else { + _userData.addNegativeTag(tag); + } + if(fastRefresh) + _notifyUserTagsUpdated(); + _updateUser().then((_) { + _notifyUserTagsUpdated(); + }); + } + } + + removeTag(String tag,bool fastRefresh) { + if(tag==null || _userData==null) + return; + + if(AppString.isStringNotEmpty(tag)) { + _userData.removeTag(tag); + if(fastRefresh) + _notifyUserTagsUpdated(); + _updateUser().then((_) { + _notifyUserTagsUpdated(); + }); + } + } + + bool isTagged(String tag, bool positiveInterest) { + return _userData?.containsTag(tag) ?? false; + } + + //UserRoles + Set get roles { + return _userData?.roles; + } + + set roles(Set userRoles) { + if (_userData != null) { + _userData.roles = userRoles; + Storage().userData = _userData; + Storage().userRoles = userRoles; + _updateUser().then((_){ + _notifyUserRolesUpdated(); + }); + } + } + + bool rolesMatch(List permittedRoles) { + Set currentUserRoles = roles; + if (!AppCollection.isCollectionNotEmpty(permittedRoles) || (currentUserRoles == null)) { + return true; //default + } + + for (UserRole role in permittedRoles) { + if (currentUserRoles?.contains(role) ?? false) { + return true; + } + } + + return false; + } + + bool get isResident{ + return AppCollection.isCollectionNotEmpty(roles) ? roles.contains(UserRole.resident) : false; + } + + bool get isStudentOrEmployee { + if (AppCollection.isCollectionEmpty(roles)) { + return false; + } + return roles.contains(UserRole.student) || roles.contains(UserRole.employee); + } + + // Notifications + + void _notifyUserUpdated() { + NotificationService().notify(notifyUserUpdated, null); + } + + void _notifyUserDeleted() { + NotificationService().notify(notifyUserDeleted, null); + } + + void _notifyUserRolesUpdated() { + NotificationService().notify(notifyRolesUpdated, null); + } + + void _notifyUserInterestsUpdated() { + NotificationService().notify(notifyInterestsUpdated, null); + } + + void _notifyUserFavoritesUpdated(){ + NotificationService().notify(notifyFavoritesUpdated, null); + } + + void _notifyUserTagsUpdated() { + NotificationService().notify(notifyTagsUpdated, null); + } +} + +class UserNotFoundException implements Exception{ + final String message; + UserNotFoundException({this.message}); + + @override + String toString() { + return message; + } +} \ No newline at end of file diff --git a/lib/ui/RootPanel.dart b/lib/ui/RootPanel.dart new file mode 100644 index 00000000..56cd7dfc --- /dev/null +++ b/lib/ui/RootPanel.dart @@ -0,0 +1,250 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/DeepLink.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Service.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/ui/health/Covid19HistoryPanel.dart'; +import 'package:illinois/ui/health/Covid19InfoCenterPanel.dart'; +import 'package:illinois/ui/health/Covid19StatusPanel.dart'; +import 'package:illinois/ui/health/Covid19StatusUpdatePanel.dart'; +import 'package:illinois/ui/health/Covid19UpdatesPanel.dart'; +import 'package:illinois/ui/widgets/PopupDialog.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/utils/Utils.dart'; + +class RootPanel extends StatefulWidget with AnalyticsPageAnonymous { + + @override + _RootPanelState createState() => _RootPanelState(); + + @override + bool get analyticsPageAnonymous { + return false; + } + +} + +class _RootPanelState extends State with SingleTickerProviderStateMixin implements NotificationsListener { + + static const String HEALTH_STATUS_URI = 'edu.illinois.covid://covid.illinois.edu/health/status'; + + @override + void initState() { + super.initState(); + NotificationService().subscribe(this, [ + FirebaseMessaging.notifyPopupMessage, + FirebaseMessaging.notifyCovid19Message, + FirebaseMessaging.notifyCovid19Notification, + Localization.notifyStringsUpdated, + User.notifyFavoritesUpdated, + FlexUI.notifyChanged, + Health.notifyStatusUpdated, + DeepLink.notifyUri + ]); + + Services().initUI(); + } + + @override + void dispose() { + super.dispose(); + NotificationService().unsubscribe(this); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == FirebaseMessaging.notifyPopupMessage) { + _onFirebasePopupMessage(param); + } + else if (name == FirebaseMessaging.notifyCovid19Message) { + _onFirebaseCovid19Message(param); + } + else if (name == Localization.notifyStringsUpdated) { + setState(() { }); + } + else if (name == FlexUI.notifyChanged) { + setState(() { }); + } + else if (name == Health.notifyStatusUpdated) { + _presentHealthStatusUpdate(param); + } + else if (name == FirebaseMessaging.notifyCovid19Notification) { + _onFirebaseCovid19Notification(param); + } + else if(name == DeepLink.notifyUri) { + _onDeeplinkUri(param); + } + } + + @override + Widget build(BuildContext context) { + Analytics().accessibilityState = MediaQuery.of(context).accessibleNavigation; + + return WillPopScope( + child: Covid19InfoCenterPanel(), + onWillPop: _onWillPop); + } + + + + Future _onWillPop() async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return _buildExitDialog(context); + }, + ); + } + + Widget _buildExitDialog(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + color: Styles().colors.fillColorPrimary, + child: Padding( + padding: EdgeInsets.all(8), + child: Center( + child: Text( + Localization().getStringEx("app.title", "Safer Illinois"), + style: TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + ), + ), + ], + ), + Container(height: 26,), + Text( + Localization().getStringEx( + "app.exit_dialog.message", "Are you sure you want to exit?"), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Colors.black), + ), + Container(height: 26,), + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RoundedButton( + onTap: () { + Analytics.instance.logAlert( + text: "Exit", selection: "Yes"); + Navigator.of(context).pop(true); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("dialog.yes.title", 'Yes')), + Container(height: 10,), + RoundedButton( + onTap: () { + Analytics.instance.logAlert( + text: "Exit", selection: "No"); + Navigator.of(context).pop(false); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("dialog.no.title", 'No')) + ], + ), + ), + ], + ), + ), + ); + } + + void _onFirebasePopupMessage(Map content) { + String displayText = content["display_text"]; + String positiveButtonText = content["positive_button_text"]; + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return PopupDialog(displayText: displayText, positiveButtonText: positiveButtonText); + }, + ); + } + + Future _onFirebaseCovid19Message(Map content) async { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19UpdatesPanel())); + } + + Future _onFirebaseCovid19Notification(Map notification) async { + /*notification = { + "type": "health.covid19.notification", + "health.covid19.notification.type": "process-pending-tests", + }*/ + + String notificationType = AppJson.stringValue(notification['health.covid19.notification.type']); + if (notificationType == 'process-pending-tests') { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19HistoryPanel())); + } else if(notificationType == 'status-changed'){ + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19StatusPanel())); + } + } + + void _presentHealthStatusUpdate(Map params) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19StatusUpdatePanel(status: params['status'], previousHealthStatus: params['lastHealthStatus'],))); + } + + void _onDeeplinkUri(Uri uri) { + if (uri != null) { + Uri healthStatusUri; + try { healthStatusUri = Uri.parse(HEALTH_STATUS_URI); } + catch(e) { print(e?.toString()); } + + if ((healthStatusUri != null) && + (healthStatusUri.scheme == uri.scheme) && + (healthStatusUri.authority == uri.authority) && + (healthStatusUri.path == uri.path)) + { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19StatusPanel())); + } + } + } +} diff --git a/lib/ui/WebPanel.dart b/lib/ui/WebPanel.dart new file mode 100644 index 00000000..2bfe166f --- /dev/null +++ b/lib/ui/WebPanel.dart @@ -0,0 +1,154 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'dart:io'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class WebPanel extends StatefulWidget implements AnalyticsPageName, AnalyticsPageAttributes { + final String url; + final String analyticsName; + final String title; + + WebPanel({@required this.url, this.analyticsName, this.title = ""}); + + @override + _WebPanelState createState() => _WebPanelState(); + + @override + String get analyticsPageName { + return analyticsName; + } + + @override + Map get analyticsPageAttributes { + return { Analytics.LogAttributeUrl : url }; + } +} + +class _WebPanelState extends State { + + _OnlineStatus _onlineStatus; + bool _pageLoaded = false; + + @override + void initState() { + super.initState(); + _checkOnlineStatus(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _getHeaderBar(), + backgroundColor: Styles().colors.background, + body: Column( + children: [ + Expanded( + child: (_onlineStatus == _OnlineStatus.offline) + ? _buildError() + : Stack( + children: _buildWebView(), + ) + ), + ], + )); + } + + List _buildWebView() { + List list = List(); + list.add(WebView( + initialUrl: widget.url, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: _processNavigation, + onPageFinished: (url) { + setState(() { + _pageLoaded = true; + }); + }, + )); + + if (!_pageLoaded) { + list.add(Center(child: CircularProgressIndicator())); + } + + return list; + } + + FutureOr _processNavigation(NavigationRequest navigation) { + String url = navigation.url; + if (AppUrl.launchInternal(url)) { + return NavigationDecision.navigate; + } + else { + launch(url); + return NavigationDecision.prevent; + } + } + + Widget _buildError(){ + return Center( + child: Container( + width: 280, + child: Text( + Localization().getStringEx( + 'panel.web.offline.message', 'You need to be online in order to perform this operation. Please check your Internet connection.'), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: Styles().colors.fillColorPrimary, + ), + )), + ); + } + + + void _checkOnlineStatus() async { + try { + Uri uri = Uri.parse(widget.url); + final result = await InternetAddress.lookup(uri.host); + setState(() { + _onlineStatus = (result.isNotEmpty && result[0].rawAddress.isNotEmpty) + ? _OnlineStatus.online + : _OnlineStatus.offline; + }); + } on SocketException catch (_) { + setState(() { + _onlineStatus = _OnlineStatus.offline; + }); + } + } + + Widget _getHeaderBar() { + return SimpleHeaderBarWithBack(context: context, + titleWidget: Text(widget.title, style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w900, letterSpacing: 1.0),),); + } + +} + +enum _OnlineStatus { online, offline } diff --git a/lib/ui/health/Covid19AboutPanel.dart b/lib/ui/health/Covid19AboutPanel.dart new file mode 100644 index 00000000..ad119b2c --- /dev/null +++ b/lib/ui/health/Covid19AboutPanel.dart @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; + +class Covid19AboutPanel extends StatefulWidget { + + Covid19AboutPanel(); + + @override + _Covid19AboutPanelState createState() => _Covid19AboutPanelState(); +} + +class _Covid19AboutPanelState extends State { + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.health.covid19.about.heading.title","About"), style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + backgroundColor: Styles().colors.white, + body: SingleChildScrollView( + child:Container( + padding: EdgeInsets.symmetric(horizontal: 24), + child:Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 16,), + Container( + child: Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.heading.title", "How it works"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color:Styles().colors.fillColorPrimary), + ) + ), + Container(height: 16,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line1.title", "Testing and limiting exposure are key to slowing the spread of COVID-19."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 16,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line2.title", "You can use this app to:"), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 16,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line3.title", "Provide any COVID-19 symptoms you experience"), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 16,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line4.title", "Automatically receive or enter test results from your healthcare provider"), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 16,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line5.title", "Allow your phone to send exposure notifications to you and the people you’ve come in contact with during the last 14 days"), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + ],)) + )); + } +} \ No newline at end of file diff --git a/lib/ui/health/Covid19AddTestResultPanel.dart b/lib/ui/health/Covid19AddTestResultPanel.dart new file mode 100644 index 00000000..4d59bc70 --- /dev/null +++ b/lib/ui/health/Covid19AddTestResultPanel.dart @@ -0,0 +1,404 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/OSFHealth.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; + + +import 'Covid19ReportTestPanel.dart'; + +class Covid19AddTestResultPanel extends StatefulWidget { + + Covid19AddTestResultPanel(); + + @override + _Covid19AddTestResultPanelState createState() => _Covid19AddTestResultPanelState(); +} + +class _Covid19AddTestResultPanelState extends State implements NotificationsListener { + + bool __loading = false; + bool get _loading => __loading; + set _loading(bool value){ + setState(() { + __loading = value; + }); + } + + + bool __retrieving = false; + bool get _retrieving => __retrieving; + set _retrieving(bool value){ + setState(() { + __retrieving = value; + }); + } + + + + List_providerItems; + ProviderDropDownItem _selectedProviderItem; + HealthServiceProvider _initialProvider; + + @override + void initState() { + _initialProvider = Storage().lastHealthProvider; + NotificationService().subscribe(this, [ + OSFHealth.notifyOnFetchBegin, OSFHealth.notifyOnFetchFinished + ]); + _loadProviders(); + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == OSFHealth.notifyOnFetchBegin) { + _retrieving = true; + } else if (name == OSFHealth.notifyOnFetchFinished) { + _retrieving = false; + Navigator.pop(context); + } + } + + void _loadProviders() { + _loading = true; + Health().loadHealthServiceProviders().then((List providers){ + _providerItems = List(); + if(providers?.isNotEmpty?? false) { + _providerItems.addAll(providers?.map((HealthServiceProvider provider) { + ProviderDropDownItem item = ProviderDropDownItem(type: ProviderDropDownItemType.provider, item: provider); + //Initial selection + if (_selectedProviderItem == null && _initialProvider != null) { + // If we don't have selection but have previously selected providerId + if (provider?.id == _initialProvider?.id) { + _selectedProviderItem = item; + } + } + return item; + })?.toList()); + } + // "Hide 'Other' as a provider in manual test panel" + // _providerItems.add(ProviderDropDownItem(type: ProviderDropDownItemType.other, item: null)); + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.health.covid19.add_test.heading.title","Add Test Result"), style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + backgroundColor: Styles().colors.white, + body: _loading? _buildLoading() : _buildContent()); + } + + Widget _buildContent() { + bool manualTestsDisabledVisible = (_selectedProviderItem != null) && (!_canManuallyEnterResult && !_canRetrieve); + return Container( + child: Column( + children: [ + Row(children: [ + Expanded(child: + Container( + color: Styles().colors.background, + padding: EdgeInsets.only(left: 24, right: 24,top: 23, bottom: 2), + child: Text(Localization().getStringEx("panel.health.covid19.add_test.label.where_question","Where was the test taken?"), textAlign:TextAlign.left,style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20, fontFamily: Styles().fontFamilies.bold),), + )), + ],), + Container( + color: Styles().colors.background, + child:Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: EdgeInsets.only(left: 24, right: 5, bottom: 19, top: 5), + child: Text(Localization().getStringEx("panel.health.covid19.add_test.label.information","Why is this information needed?"), textAlign:TextAlign.left,style: TextStyle(color: Styles().colors.textSurface, fontSize: 14, fontFamily: Styles().fontFamilies.regular),), + ), + Container( + child: Semantics( + label: Localization().getStringEx( "panel.health.covid19.history.label.more_info.title","More Info"), + child: InkWell( + onTap: _onTapMoreInfo, + child:Container( + padding: EdgeInsets.all(5), + child: Image.asset("images/icon-more-info.png")), + )) + ), + ],)), + Expanded(child: + Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column(children: [ + Container(height: 29,), + Container( + alignment: Alignment.centerLeft, + child: Text( + Localization().getStringEx("panel.health.covid19.add_test.label.provider.title","Healthcare Provider"), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold,letterSpacing: 0.86), + ), + ), + Container(height: 8,), + Container( + decoration: BoxDecoration( + border: Border.all( + color: Styles().colors.surfaceAccent, + width: 1), + borderRadius: + BorderRadius.all(Radius.circular(4))), + child: Padding( + padding: EdgeInsets.only(left: 12, right: 16), + child: DropdownButtonHideUnderline( + child: DropdownButton( + icon: Image.asset( + 'images/icon-down-orange.png'), + isExpanded: true, + style: TextStyle( + color: Styles().colors.mediumGray, + fontSize: 16, + fontFamily: + Styles().fontFamilies.regular), + hint: Text(_selectedProviderItem?.title ?? Localization().getStringEx("panel.health.covid19.add_test.label.provider.empty_hint","Select a provider")), + items: _buildProviderDropDownItems(), + onChanged: (ProviderDropDownItem value)=>setState((){ + Analytics.instance.logSelect(target: "Selected provider: "+value?.title); + _selectedProviderItem = value; + if(value!= null && ProviderDropDownItemType.provider == value.type ){ + Storage().lastHealthProvider= value.item; + } + }), + )), + ), + ), + Expanded(child: Container( alignment: Alignment.center, + child: Visibility( + visible: manualTestsDisabledVisible, + child: Text(Localization().getStringEx( "panel.health.covid19.add_test.label.manual_tests_disabled","Test results from this health care provider will automatically appear if you have consented to Health Provider Test Results in settings and you are connected with your NetID."), + textAlign: TextAlign.center, style: TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textSurface),)) + ),), + _canRetrieve ? Stack( + children: [ + RoundedButton( + label: Localization().getStringEx("panel.health.covid19.add_test.button.retreive.title","Retrieve Results"), + onTap: _canRetrieve?_onTapRetrieveResult: (){}, + enabled: _canRetrieve, + backgroundColor: Styles().colors.white, + borderColor: _canRetrieve? Styles().colors.fillColorSecondary : Styles().colors.surfaceAccent , + textColor: _canRetrieve? Styles().colors.fillColorSecondary : Styles().colors.surfaceAccent ,), + _retrieving ? Center(child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary),)) : Container(), + ], + ) : Container(), + Container(height: (_canRetrieve && _canManuallyEnterResult) ? 12 : 0,), + _canManuallyEnterResult ? RoundedButton( + label: Localization().getStringEx("panel.health.covid19.add_test.button.enter_manually.title","Manually Enter"), + onTap: _canManuallyEnterResult?_onTapEnterManualTest : (){}, + enabled: _canManuallyEnterResult, + backgroundColor: Styles().colors.white, + borderColor:_canManuallyEnterResult? Styles().colors.fillColorSecondary : Styles().colors.surfaceAccent , + textColor: _canManuallyEnterResult? Styles().colors.fillColorSecondary : Styles().colors.surfaceAccent ) : Container(), + Container(height: 26,) + ]))) + ], + ), + ); + } + + List> _buildProviderDropDownItems() { + int itemsCount = _providerItems?.length ?? 0; + if (itemsCount == 0) { + return null; + } + List> items = List>(); + try{ + items.addAll(_providerItems.map((ProviderDropDownItem item) { + return DropdownMenuItem( + value: item, + child: Text(item?.title), + ); + }).toList()); + } catch(e){ + print(e); + } + + return items; + } + + Widget _buildLoading(){ + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,),), + ) + ]); + } + + void _onTapRetrieveResult() { + if(_selectedProviderItem?.item?.name == "OSF Healthcare") { + Analytics.instance.logSelect(target: "Retrieve Results"); + OSFHealth().authenticate(); + } + } + + + + //Future _loadData() async{} + + void _onTapEnterManualTest() { + Analytics.instance.logSelect(target: "Manually Enter"); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19ReportTestPanel(provider: _selectedProviderItem.item,))).then((success){ + if(success!= null) + Navigator.pop(context); + }); + } + + void _onTapMoreInfo(){ + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Container( + color: Styles().colors.fillColorPrimary, + padding: EdgeInsets.all(18), + child:Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: + Container(child: + Semantics(container: true,child: + Column(children: [ + RichText( + textScaleFactor: MediaQuery.textScaleFactorOf(context), + maxLines: 50, + text: new TextSpan( + // Note: Styles for TextSpans must be explicitly defined. + // Child text spans will inherit styles from parent + style: new TextStyle( + fontSize: 16.0, + color: Styles().colors.white, + ), + children: [ + new TextSpan(text: Localization().getStringEx("panel.health.covid19.add_test.label.info.retrieved.text1", "Results ")), + new TextSpan(text: Localization().getStringEx("panel.health.covid19.add_test.label.info.retrieved.text2", "retrieved "), style: new TextStyle(fontWeight: FontWeight.bold)), + new TextSpan(text: Localization().getStringEx("panel.health.covid19.add_test.label.info.retrieved.text3", "from your healthcare provider are instantly verified. Any changes to your health status will be reflected instantly.")), + ], + )), + Container(height: 20,), + RichText( + textScaleFactor: MediaQuery.textScaleFactorOf(context), + maxLines: 50, + text: new TextSpan( + // Note: Styles for TextSpans must be explicitly defined. + // Child text spans will inherit styles from parent + style: new TextStyle( + fontSize: 16.0, + color: Styles().colors.white, + ), + children: [ + new TextSpan(text: Localization().getStringEx("panel.health.covid19.add_test.label.info.manually.text1", "Results ")), + new TextSpan(text: Localization().getStringEx("panel.health.covid19.add_test.label.info.manually.text2", "entered manually "), style: new TextStyle(fontWeight: FontWeight.bold)), + new TextSpan(text: Localization().getStringEx("panel.health.covid19.add_test.label.info.manually.text3", "will be reviewed and verified by a public healthcare provider. Once verified, status changes may occur.")), + ] + ) + )])))), + Container(width: 8,), + Container(child: + Semantics(label: Localization().getStringEx("dialog.close.title", "Close"), button: true,child: + GestureDetector( + onTap: (){Navigator.pop(context);}, + child: Container(child:Image.asset("images/close-orange.png", excludeFromSemantics: true,)), + ) )) + ],)))]); + }, + ); + } + + bool get _canRetrieve { + if (_retrieving) { + return false; + } + if (_selectedProviderItem?.item == null) { //'Other' provider + return false; + } + bool allowManualTest = (_selectedProviderItem?.item?.allowManualTest ?? false); + bool isEpicMechanism = (_selectedProviderItem?.item?.availableMechanisms?.contains(HealthServiceMechanism.epic) ?? false); + if (isEpicMechanism && !allowManualTest) { + return true; + } + if (!allowManualTest) { + return false; + } + return true; + } + + bool get _canManuallyEnterResult { + if (_selectedProviderItem == null) { + return false; + } else if (_selectedProviderItem.type == ProviderDropDownItemType.other) { + return true; + } + return (_selectedProviderItem?.item?.allowManualTest ?? false); + } +} + +enum ProviderDropDownItemType{ + provider, other +} + +class ProviderDropDownItem{ + final ProviderDropDownItemType type; + final HealthServiceProvider item; + + ProviderDropDownItem({this.type, this.item}); + + String get title{ + if(type == ProviderDropDownItemType.other){ + return Localization().getStringEx("app.common.label.other", "Other"); + } else { + return item?.name ?? ""; + } + } + + String get providerId{ + if(type == ProviderDropDownItemType.other){ + return null; + } else { + return item?.id; + } + } +} \ No newline at end of file diff --git a/lib/ui/health/Covid19CampusUpdatesPanel.dart b/lib/ui/health/Covid19CampusUpdatesPanel.dart new file mode 100644 index 00000000..cdde059e --- /dev/null +++ b/lib/ui/health/Covid19CampusUpdatesPanel.dart @@ -0,0 +1,99 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/ui/health/Covid19NewsPanel.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class Covid19CampusUpdatesPanel extends StatefulWidget { + @override + _Covid19CampusUpdatesPanelState createState() => _Covid19CampusUpdatesPanelState(); +} + +class _Covid19CampusUpdatesPanelState extends State { + List _covid19News; + bool _loading = false; + + @override + void initState() { + super.initState(); + _loadNews(); + } + + @override + void dispose() { + super.dispose(); + } + + void _loadNews() { + _setLoading(true); + Health().loadCovid19News().then((covid19News) { + _covid19News = covid19News; + _setLoading(false); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.covid19_campus_updates.header.title", "Campus updates"), + style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold), + ), + ), + backgroundColor: Styles().colors.background, + body: (_loading ? Center(child: CircularProgressIndicator(),) : SingleChildScrollView( + child: Padding(padding: EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column(crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding(padding: EdgeInsets.only(bottom: 24), + child: Text(Localization().getStringEx("panel.covid19_campus_updates.sub_title.title", "University of Illinois COVID-19 updates"), + textAlign: TextAlign.center, + style: TextStyle(color: Styles().colors.textSurface, fontSize: 16, fontFamily: Styles().fontFamilies.regular),),), + _newsItems() + ],),), + )), + ); + } + + Widget _newsItems() { + if (AppCollection.isCollectionEmpty(_covid19News)) { + return Container(); + } + return ListView.separated(physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + separatorBuilder: (context, index) => Container(height: 24), + itemCount: _covid19News.length, + itemBuilder: (BuildContext context, int index) { + Covid19News news = _covid19News[index]; + return Covid19NewsCard(news: news,); + }); + } + + void _setLoading(bool loading) { + if (mounted) { + setState(() { + _loading = loading; + }); + } + } +} diff --git a/lib/ui/health/Covid19CareTeamPanel.dart b/lib/ui/health/Covid19CareTeamPanel.dart new file mode 100644 index 00000000..8846b78c --- /dev/null +++ b/lib/ui/health/Covid19CareTeamPanel.dart @@ -0,0 +1,249 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/ui/WebPanel.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:url_launcher/url_launcher.dart' as url_launcher; + +class Covid19CareTeamPanel extends StatefulWidget { + final Covid19Status status; + + Covid19CareTeamPanel({Key key, this.status}) : super(key: key); + + @override + State createState() { + return _Covid19CareTeamPanelState(); + } +} + +class _Covid19CareTeamPanelState extends State with TickerProviderStateMixin{ + List _animationControllers = List(); + //bool _moreInfoExpanded = false; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + if(_animationControllers!=null && _animationControllers.isNotEmpty){ + _animationControllers.forEach((controller){ + controller.dispose(); + }); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.health.covid19.care_team.heading.title", "Your Care Team"), style: TextStyle(color: Styles().colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + backgroundColor: Styles().colors.background, + body: Container( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( container: true, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 24), + color: Styles().colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 30,), + Text(Localization().getStringEx("panel.health.covid19.care_team.label.question", "We’re here to help."),textAlign: TextAlign.left, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20, fontFamily: Styles().fontFamilies.bold),), + Container(height: 8,), + Text(Localization().getStringEx("panel.health.covid19.care_team.label.description", "Reach out to someone on your COVID-19 care team - we're here to help."), style: TextStyle(color: Styles().colors.textSurface, fontSize: 16, fontFamily: Styles().fontFamilies.regular),), + Container(height: 23,), + ],),)), + Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 23,), + Semantics( container: true, + child: Container( + child:Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(Localization().getStringEx("panel.health.covid19.care_team.label.emergency.text1", "In case of an emergency, "),textAlign: TextAlign.left, style: TextStyle(color: Styles().colors.textSurface, fontSize: 16, fontFamily: Styles().fontFamilies.regular),), + Text(Localization().getStringEx("panel.health.covid19.care_team.label.emergency.text2", "always call 911."),textAlign: TextAlign.left, style: TextStyle(color: Styles().colors.textSurface, fontSize: 16, fontFamily: Styles().fontFamilies.bold),), + ],),),), + Container(height: 16,), + _buildActionsLayout(), + Container(height: 26,), + _buildMoreInfoLayout(), + Container(height: 26,) + ],),), + + ],),), + ), + ); + } + + Widget _buildActionsLayout() { + return Container( + child: Column(children: [ + !_canMcKinley? Container(): + _buildAction( + title: Localization().getStringEx("panel.health.covid19.care_team.team.title.mc_kinley", "Call McKinley Health"), + description: Localization().getStringEx("panel.health.covid19.care_team.team.description.mc_kinley", "Reach out to someone on the “Dial a Nurse Line” to discuss your symptoms and options for clinical care."), + contact: Localization().getStringEx("panel.health.covid19.care_team.team.contact.mc_kinley", "1-217-333-2700"), + semanticContact: Localization().getStringEx("panel.health.covid19.care_team.team.semantic_contact.mc_kinley", "12173332700"), + imageRes: "mc-kinley-gray.png" + ), + Container(height: 12,), + _buildAction( + description: Localization().getStringEx("panel.health.covid19.care_team.team.description.osf", "We’ve partnered with OSF HealthCare and its OSF OnCall Connect program and the Illinois Department of Healthcare and Family Services to support you getting through COVID-19. Call the Nurse Hotline at 1-833-OSF-KNOW (833-673-5669) to learn more about the program, which includes delivery of a care kit and digital visits to monitor you over a 16-day period."), + contact: Localization().getStringEx("panel.health.covid19.care_team.team.contact.osf", "1-833-673-5669"), + semanticContact: Localization().getStringEx("panel.health.covid19.care_team.team.semantic_contact.osf", "18336735669"), + imageRes: "osf-logo-gray.png" + ) + ],) + ); + } + + Widget _buildAction({String title, String description, String imageRes, String contact, String semanticContact = ''}){ + bool _hasTitle = AppString.isStringNotEmpty(title); + return Semantics( container: true, + child: Container( + decoration: BoxDecoration(color: Styles().colors.white, + borderRadius: BorderRadius.all(Radius.circular(4)), border: Border.all(color: Styles().colors.surfaceAccent,)), + child: Column(children: [ + Container( + alignment: Alignment.centerLeft, + padding: EdgeInsets.symmetric(horizontal: 16) , + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 10,), + imageRes!=null ? Image.asset("images/"+imageRes, excludeFromSemantics: true,) : Container(), + Visibility(visible:_hasTitle, + child: Container(height: 6,) + ), + Visibility(visible:_hasTitle, + child: Text(title??"",textAlign: TextAlign.left, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold),) + ), + Container(height: 6,), + Text(description,textAlign: TextAlign.left, style: TextStyle(color: Styles().colors.textSurface, fontSize: 14, fontFamily: Styles().fontFamilies.regular),), + Container(height: 14,), + Container(color: Styles().colors.surfaceAccent, height: 1,), + ],),), + Semantics(explicitChildNodes: true, + child: Container(child: + Semantics(label: Localization().getStringEx("panel.health.covid19.care_team.label.call.hint","Call ") + semanticContact, button: true, excludeSemantics: true, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 22,vertical: 18), + color: Styles().colors.surfaceAccent, + child: InkWell( + onTap:(){ _onTapContact(contact);}, + child:Row(children: [ + Image.asset("images/icon-phone.png", excludeFromSemantics: true,), + Container(width: 8,), + Text(contact,textAlign: TextAlign.left, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold),), + Expanded(child: Container()), + Image.asset("images/chevron-right.png", excludeFromSemantics: true,), + ],)) + )))), + ],) + )); + } + + void _onTapContact(String contact) async{ + await url_launcher.launch("tel:"+contact); + } + + Widget _buildMoreInfoLayout(){ + final Animatable _halfTween = Tween(begin: 0.0, end: 0.5); + final Animatable _easeInTween = CurveTween(curve: Curves.easeIn); + AnimationController _controller = AnimationController(duration: Duration(milliseconds: 200), vsync: this); + _animationControllers.add(_controller); + Animation _iconTurns = _controller.drive(_halfTween.chain(_easeInTween)); + + return Container( + decoration: BoxDecoration(color: Styles().colors.fillColorPrimary, borderRadius: BorderRadius.circular(4), border: Border.all(color: Styles().colors.surfaceAccent, width: 1)), + child: Theme(data: ThemeData(accentColor: Styles().colors.white, + dividerColor: Colors.white, + backgroundColor: Styles().colors.white, + textTheme: TextTheme(subtitle1: TextStyle(color: Styles().colors.white, fontFamily: Styles().fontFamilies.bold, fontSize: 16))), + child: ExpansionTile( + title: + Semantics(label: Localization().getStringEx("panel.health.covid19.care_team.label.more_info.title", "More about the OSF OnCall Connect program"), + hint: Localization().getStringEx("panel.health.covid19.care_team.label.more_info.hint", "Double tap to show more info"),/*+(expanded?"Hide" : "Show ")+" questions",*/ + excludeSemantics:true,child: + Container(child: Text(Localization().getStringEx("panel.health.covid19.care_team.label.more_info.title", "More about the OSF OnCall Connect program"), style: TextStyle(color: Styles().colors.white, fontFamily: Styles().fontFamilies.bold, fontSize: 16),))), + backgroundColor: Styles().colors.fillColorPrimary, + trailing: RotationTransition( + turns: _iconTurns, + child: Icon(Icons.arrow_drop_down, color: Styles().colors.white,)), + children: [ + Container( + color: Styles().colors.white, + padding: EdgeInsets.only(left: 13, right: 13, top: 20, bottom: 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(Localization().getStringEx("panel.health.covid19.care_team.label.more_info.description", "Members of the OSF OnCall Connect care team are trained OSF HealthCare Mission Partners who connect with you to provide support and work with you and health care providers as you recover from COVID-19, decreasing the risk of further exposure. OSF OnCall Connect team members check on you daily and should your condition worsen, you will be referred to the Acute COVID@Home program where you will receive monitoring equipment that allows us to evaluate your blood pressure, heart rate and pulse ox."), + style: TextStyle(color: Styles().colors.textSurface, fontSize: 14, fontFamily: Styles().fontFamilies.regular),), + GestureDetector( + onTap: _onLearnMoreTapped, + child: Padding( + padding: const EdgeInsets.only(left: 0, right: 20, top: 20, bottom: 20), + child: Text(Localization().getStringEx("panel.health.covid19.care_team.label.more_info.link", "Learn more"), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 14, fontFamily: Styles().fontFamilies.bold, decoration: TextDecoration.underline),), + ), + ), + ], + ), + ), + ], + onExpansionChanged: (bool expand) { + Analytics.instance.logSelect(target: "More Info"); + if (expand) { + _controller.forward(); + } else { + _controller.reverse(); + } + }, + ))); + } + + bool get _canMcKinley{ + return User().rolesMatch([UserRole.student]); + } + + void _onLearnMoreTapped(){ + Navigator.push(context, CupertinoPageRoute(builder: (context)=>WebPanel( + url: 'https://www.osfhealthcare.org/c/oncall-connect-uofi/?utm_source=student-app&utm_medium=app&utm_campaign=m-access-osf-oncall-connect-uofi', + ))); + } +} \ No newline at end of file diff --git a/lib/ui/health/Covid19GuidelinesPanel.dart b/lib/ui/health/Covid19GuidelinesPanel.dart new file mode 100644 index 00000000..bd27d130 --- /dev/null +++ b/lib/ui/health/Covid19GuidelinesPanel.dart @@ -0,0 +1,283 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Assets.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:sprintf/sprintf.dart'; + +class Covid19GuidelinesPanel extends StatefulWidget { + final Covid19Status status; + + Covid19GuidelinesPanel({Key key, this.status}) : super(key: key); + + @override + _Covid19GuidelinesPanelState createState() => _Covid19GuidelinesPanelState(); +} + +class _Covid19GuidelinesPanelState extends State implements NotificationsListener { + + Covid19Status _covid19Status; + List _statusGuidelines; + + + LinkedHashMap _counties; + + Map _guidelineImages; + + int _loadingProgress = 0; + + @override + void initState() { + super.initState(); + + NotificationService().subscribe(this, [ + Assets.notifyChanged + ]); + + _guidelineImages = Assets()['covid19_guidelines.icons']; + + _loadCovidCounties(); + + if (widget.status != null) { + _covid19Status = widget.status; + _statusGuidelines = _loadGuidelines(); + } + else { + _loadCovid19Status(); + } + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + void _loadCovidCounties(){ + setState(() { + _loadingProgress++; + }); + + Health().loadCounties().then((List counties) { + if (mounted) { + setState(() { + _loadingProgress--; + _counties = HealthCounty.listToMap(counties); + if (_loadingProgress == 0) { + _statusGuidelines = _loadGuidelines(); + } + }); + } + }); + } + + void _loadCovid19Status() { + + setState(() { + _loadingProgress++; + }); + + Health().loadCovid19Status().then((Covid19Status status) { + if (mounted) { + setState(() { + _loadingProgress--; + _covid19Status = status; + if (_loadingProgress == 0) { + _statusGuidelines = _loadGuidelines(); + } + }); + } + }); + } + + List _loadGuidelines() { + HealthGuideline statusGuideline; + String statusName = this._currentHealthStatus; + List guidelines = _selectedCounty?.guidelines; + if ((guidelines != null) && guidelines.isNotEmpty && (statusName != null) && statusName.isNotEmpty) { + for (HealthGuideline guideline in guidelines) { + if (guideline.name?.toLowerCase() == statusName) { + statusGuideline = guideline; + break; + } + } + } + return statusGuideline?.items ?? [ + HealthGuidelineItem(icon: 'home', description: Localization().getStringEx('panel.covid19_guidelines.no.status', 'There are no specific guidelines for your status in this county.')) + ]; + } + + String get _currentHealthStatus { + return _covid19Status?.blob?.healthStatus?.toLowerCase() ?? kCovid19HealthStatusYellow; + } + + @override + Widget build(BuildContext context) { + int guidelinesCount = _statusGuidelines?.length ?? 0; + return Scaffold( + backgroundColor: Styles().colors.background, + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + Localization().getStringEx('panel.covid19_guidelines.header.title', 'County Guidelines'), + style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold), + ), + ), + body: ((0 < _loadingProgress) + ? Align(alignment: Alignment.center, child: CircularProgressIndicator(),) + : SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.only(left: 10, right: 10, bottom: 0), + child: Text( + Localization() + .getStringEx('panel.covid19_guidelines.description.title', 'Help stop the spread of COVID-19 by following these current guidelines.'), + textAlign: TextAlign.center, + style: TextStyle(color: Styles().colors.textSurface, fontSize: 16, fontFamily: Styles().fontFamilies.regular), + ), + ), + Padding( + padding: EdgeInsets.only(left: 10, right: 10, bottom: 10), + child: Text( + sprintf(Localization().getStringEx('panel.covid19_guidelines.status.title', 'These are based on your %s status in the following county:'), + [_covid19Status?.blob?.localizedHealthStatus??"unknown"]), + textAlign: TextAlign.center, + style: TextStyle(color: Styles().colors.textSurface, fontSize: 16, fontFamily: Styles().fontFamilies.regular), + ), + ), + _buildCountyDropdown(), + Container(padding: EdgeInsets.all(8), decoration: BoxDecoration(color: (covid19HealthStatusColor(this._currentHealthStatus) ?? Styles().colors.background), borderRadius: BorderRadius.circular(4), border: Border.all(color: Styles().colors.surfaceAccent, width: 1)), child: + (guidelinesCount == 0 ? Container() : ListView.separated( + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + separatorBuilder: (context, index) => Divider(height: 1, color: Styles().colors.surfaceAccent,), + itemCount: guidelinesCount, + itemBuilder: (BuildContext context, int index) { + HealthGuidelineItem guideline = _statusGuidelines[index]; + String guidelineImage = _getGuidelineItemImageRes(guideline); + return Container( + padding: EdgeInsets.only(left: 24, right: 5), + color: Styles().colors.white, + height: 104, + child: Center(child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding(padding: EdgeInsets.only(right: 24), child:guidelineImage!=null? Image.asset(guidelineImage, excludeFromSemantics: true,) : Container(width: 72,),), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ + Text(guideline.description ?? '', style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.regular),), //TBD : Localization + Text(guideline.type ?? '', style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), //TBD : Localization + ],),) + ],),), + ); + }))) + ], + ), + ))), + ); + } + + //County + Widget _buildCountyDropdown(){ + String countyName = _selectedCounty?.nameDisplayText; + return Padding(padding: EdgeInsets.symmetric(horizontal: 32, vertical: 0), + child: Column(crossAxisAlignment:CrossAxisAlignment.center, children: [ + Semantics(container: true, child: + Padding(padding: EdgeInsets.only(bottom: 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, children: [ + Container( + child: Padding(padding: EdgeInsets.only(left: 12, right: 16), + child: DropdownButtonHideUnderline( + child:DropdownButton( + icon: Icon(Icons.arrow_drop_down, color:Styles().colors.fillColorPrimary, semanticLabel: null,), + isExpanded: false, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary,), + hint: Text(countyName != null ? "$countyName ${Localization().getStringEx("app.common.label.county", "County")}": Localization().getStringEx("panel.covid19_guidelines.label.county.empty","Select a county..."), + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary,),), + items: _buildCountyDropdownItems(), + onChanged: (value) { _switchCounty(value); }, + ) + ) + ), + ) + ],), + ), + ), + Container(height: 12,) + ])); + } + + List _buildCountyDropdownItems(){ + List result; + if (_counties?.isNotEmpty ?? false) { + result = List (); + for (HealthCounty county in _counties.values) { + result.add(DropdownMenuItem( + value: county.id, + child: Text(county.nameDisplayText), + )); + } + } + return result; + } + + //Guidelines Image res mapping + String _getGuidelineItemImageRes(HealthGuidelineItem guideline){ + String iconRes = _guidelineImages[guideline.icon]; + return iconRes!=null?"images/$iconRes" : null; + } + + HealthCounty get _selectedCounty { + return (_counties != null) ? _counties[Health().currentCountyId] : null; + } + + void _switchCounty(String countyId) { + setState(() { + _loadingProgress++; + }); + Health().switchCounty(countyId).then((Covid19Status status) { + if (mounted) { + setState(() { + _loadingProgress--; + _covid19Status = status; + _statusGuidelines = _loadGuidelines(); + }); + } + }); + } + + @override + void onNotification(String name, param) { + if (name == Assets.notifyChanged) { + if (mounted) { + setState(() { + _statusGuidelines = _loadGuidelines(); + }); + } + } + } +} + diff --git a/lib/ui/health/Covid19HistoryPanel.dart b/lib/ui/health/Covid19HistoryPanel.dart new file mode 100644 index 00000000..8f42d8b2 --- /dev/null +++ b/lib/ui/health/Covid19HistoryPanel.dart @@ -0,0 +1,703 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/PopupDialog.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:sprintf/sprintf.dart'; + +class Covid19HistoryPanel extends StatefulWidget { + @override + _Covid19HistoryPanelState createState() => _Covid19HistoryPanelState(); + + static show(BuildContext context, {Function onContinueTap}){ + showDialog( + context: context, + builder: (_) => Material( + type: MaterialType.transparency, + child: Covid19HistoryPanel(), + ) + ); + } +} + +class _Covid19HistoryPanelState extends State implements NotificationsListener { + + List _statusHistory = List(); + bool _isLoading = false; + bool _isDeleting = false; + bool _isReposting = false; + + + @override + void initState() { + super.initState(); + + NotificationService().subscribe(this, [ + Health.notifyUserUpdated, + Health.notifyHistoryUpdated, + Health.notifyUpdatedHistoryAvailable, + ]); + + _loadHistory(); + } + + @override + void dispose() { + super.dispose(); + NotificationService().unsubscribe(this); + } + + @override + void onNotification(String name, param) { + if(name == Health.notifyUserUpdated){ + setState(() {}); + } + else if (name == Health.notifyHistoryUpdated) { + _loadHistory(); + } + else if (name == Health.notifyUpdatedHistoryAvailable) { + if ((param != null) && mounted) { + setState(() { + _statusHistory = param; + _isLoading = false; + }); + } + } + } + + void _loadHistory() { + + setState(() { _isLoading = true; }); + + Health().loadUpdatedHistory().then((List history) { + if (mounted) { + setState(() { + if (history != null) { + _statusHistory = Covid19History.pastList(history); + } + _isLoading = Health().loadingUpdatedHistory; + }); + } + }); + } + + void _repostHistory(){ + + if(!_isReposting) { + setState(() { + _isReposting = true; + }); + Health().repostHealthHistory().whenComplete((){ + setState(() { + _isReposting = false; + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return PopupDialog(displayText: Localization().getStringEx("panel.health.covid19.history.message.request_tests","Your request has been submitted. You should receive your latest test within an hour"), positiveButtonText: Localization().getStringEx("dialog.ok.title","OK")); + }, + ); + }); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.health.covid19.history.header.title","Your COVID-19 Event History"), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: Styles().fontFamilies.extraBold, + letterSpacing: 1.0), + ), + ), + backgroundColor: Styles().colors.background, + body: Container( +// padding: EdgeInsets.symmetric(horizontal: 5, vertical: 75), + child:Container( + padding: EdgeInsets.symmetric(horizontal: 16), + color: Styles().colors.background, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + (_isLoading == true) ? + Expanded(child:_buildProgress()): + + Expanded( + child: + _statusHistory==null || _statusHistory.isEmpty? _buildEmpty(): _buildHistoryList() + ) + ]), + ) + )); + } + + Widget _buildProgress(){ + return Center(child: CircularProgressIndicator(),); + } + + Widget _buildEmpty(){ + return Container( + padding: EdgeInsets.all(16), + child:Center( + child: + Column( + children: [ + Expanded(child: Container(),), + Text(Localization().getStringEx("panel.health.covid19.history.label.empty.title","No History"), + style: TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textSurface)), + Expanded(child: Container(),), + _buildRepostButton(), + Container(height: 10,), + ], + ))); + } + + Widget _buildHistoryList(){ + return Container(child: + Column(children: [ + Container( + padding: EdgeInsets.symmetric(vertical: 24), + child: Text(Localization().getStringEx("panel.health.covid19.history.label.description","View your COVID-19 event history.",), + style: TextStyle(color: Styles().colors.textSurface, fontSize: 16, fontFamily: Styles().fontFamilies.regular), + ), + ), + Expanded(child: + new ListView.builder( + itemCount: (kReleaseMode && (Config().configEnvironment != ConfigEnvironment.dev)) ? + _statusHistory.length + 1 : (_statusHistory.length + 2), + itemBuilder: (BuildContext ctxt, int index) { + if (index < _statusHistory.length) { + return _Covid19HistoryEntry(history: _statusHistory[index]); + } + else if (index == _statusHistory.length) { + return _buildRepostButton(); + } + else { + return _buildRemoveMyInfoButton(); + } + } + )) + ],) + ); + } + + Widget _buildRepostButton(){ + return Stack( + alignment: Alignment.center, + children: [ + RoundedButton( + label: Localization().getStringEx("panel.health.covid19.history.button.repost_history.title", "Request my latest test again"), + hint: Localization().getStringEx("panel.health.covid19.history.button.repost_history.hint", ""), + height: 48, + backgroundColor: Styles().colors.surface, + fontSize: 16.0, + textColor: Styles().colors.fillColorSecondary, + borderColor: Styles().colors.surfaceAccent, + onTap: _onRepostClicked, + ), + Visibility( + visible: _isReposting, + child: Row( + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only(top: 12), + child: Center( + child: Container( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary)), + ), + ), + ), + ), + ], + )), + ], + ); + } + + Widget _buildRemoveMyInfoButton() { + return Stack(children: [ + RoundedButton( + label: 'Delete my COVID-19 Events History', + hint: '', + height: 48, + backgroundColor: Styles().colors.surface, + fontSize: 16.0, + textColor: Styles().colors.fillColorSecondary, + borderColor: Styles().colors.surfaceAccent, + onTap: _onRemoveMyInfoClicked, + ), + Visibility(visible: _isDeleting, child: + Row(children: [ + Expanded(child: + Padding(padding: EdgeInsets.only(top: 12), child: + Center(child: + Container(width: 24, height:24, child: + CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary)), + ), + ), + ), + ), + ],) + ), + ],); + } + + Widget _buildRemoveMyInfoDialog(BuildContext context) { + return StatefulBuilder( + builder: (context, setState){ + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Styles().colors.fillColorPrimary, + borderRadius: BorderRadius.vertical(top: Radius.circular(8)), + ), + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Center( + child: Text( + "Delete your COVID-19 event history?", + style: TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Styles().colors.white, width: 2), + ), + child: Center( + child: Text( + '\u00D7', + style: TextStyle( + fontSize: 24, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + "This will permanently delete all of your COVID-19 event history information. Are you sure you want to continue?", + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Container( + height: 26, + ), + Text( + "Are you sure?", + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Colors.black), + ), + Container( + height: 16, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: RoundedButton( + onTap: () => _onCancelRemoveMyInfo(), + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorPrimary, + textColor: Styles().colors.fillColorPrimary, + label: 'No'), + ), + Container( + width: 10, + ), + Expanded(child: + RoundedButton( + onTap: () => _onConfirmRemoveMyInfo(), + backgroundColor: Styles().colors.fillColorSecondaryVariant, + borderColor: Styles().colors.fillColorSecondaryVariant, + textColor: Styles().colors.surface, + label: Localization().getStringEx("panel.profile_info.dialog.remove_my_information.yes.title", "Yes"), + height: 48,), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _onRepostClicked(){ + _repostHistory(); + } + + void _onRemoveMyInfoClicked() { + Analytics.instance.logSelect(target: 'Delete my COVID-19 Information'); + if (!_isDeleting) { + showDialog(context: context, builder: (context) => _buildRemoveMyInfoDialog(context)); + } + } + + void _onConfirmRemoveMyInfo() { + Analytics.instance.logAlert(text: "Remove My Information", selection: "Yes"); + Navigator.pop(context); + + if (!_isDeleting) { + setState(() { + _isDeleting = true; + }); + Health().clearUserData().then((bool result) { + setState(() { + _isDeleting = false; + }); + if (result) { + Navigator.pop(context); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.health.covid19.history.message.clear_failed', 'Failed to clear COVID-19 event history')); + } + }); + } + } + + void _onCancelRemoveMyInfo() { + Analytics.instance.logAlert(text: "Remove My Information", selection: "No"); + Navigator.pop(context); + } +} + +class _Covid19HistoryEntry extends StatefulWidget{ + final Covid19History history; + + const _Covid19HistoryEntry({Key key, this.history}) : super(key: key); + + @override + _Covid19HistoryEntryState createState() => _Covid19HistoryEntryState(); + +} + +class _Covid19HistoryEntryState extends State<_Covid19HistoryEntry> with SingleTickerProviderStateMixin{ + bool _expanded = false; + AnimationController _controller; + + bool _isLoading = false; + + @override + void initState() { + _controller = AnimationController(duration: Duration(milliseconds: 200), vsync: this); + super.initState(); + } + + @override + Widget build(BuildContext context) { + + List content = []; + content.add(Container(height: 16,)); + content.add(_buildCommonInfo(),); + + if (widget.history?.isTestVerified ?? false) { + content.addAll([ + _buildMoreButton(), + _buildResult(), + _buildAdditionalInfo(), + ]); + } + + content.add(Container(height: 16,)); + + return Container( + padding: EdgeInsets.symmetric(), + child: Column(children: content,), + ); + } + Widget _buildCommonInfo(){ + String title; + Widget details; + bool isVerifiedTest = false; + bool isTest = false; + String dateFormat = kReleaseMode ? 'MMMM d, yyyy' : 'MMMM d, yyyy HH:mm:ss'; + if (widget.history != null) { + if (widget.history.isTest) { + isTest = true; + bool isManualTest = widget.history?.isManualTest ?? false; + isVerifiedTest = widget.history?.isTestVerified ?? false; + title = widget.history?.blob?.testType ?? Localization().getStringEx("app.common.label.other", "Other"); + details = Row(children: [ + Image.asset(isManualTest? "images/u.png": "images/provider.png", excludeFromSemantics: true,), + Container(width: 11,), + Semantics(label: Localization().getStringEx("panel.health.covid19.history.label.provider.hint", "provider: "), child: + Text( isManualTest? Localization().getStringEx("panel.health.covid19.history.label.provider.self_reported", "Self reported"): + (widget.history?.blob?.provider ?? Localization().getStringEx("app.common.label.other", "Other")), + style:TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textSurface,)) + ) + ],); + } + else if (widget.history.isSymptoms) { + title = Localization().getStringEx("panel.health.covid19.history.label.self_reported.title","Self Reported Symptoms"); + details = Semantics(label: Localization().getStringEx("panel.health.covid19.history.label.self_reported.symptoms","symptoms: "), child: + Text(widget.history.blob?.symptomsDisplayString ?? '', style:TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground,)) + ); + } + else if (widget.history.isContactTrace) { + title = Localization().getStringEx("panel.health.covid19.history.label.contact_trace.title","Contact Trace"); + details = Semantics(label: Localization().getStringEx("panel.health.covid19.history.label.contact_trace.details","contact trace: "), child: + Text(widget.history.blob?.traceDurationDisplayString ?? '', style:TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground,)) + ); + } + else if (widget.history.isAction) { + title = Localization().getStringEx("panel.health.covid19.history.label.action.title","Action Required"); + details = Semantics(label: Localization().getStringEx("panel.health.covid19.history.label.action.details","action: "), child: + Text(widget.history.blob?.actionDisplayString ?? '', style:TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground,)) + ); + } + } + + return + Semantics( + sortKey: OrdinalSortKey(1), + container: true, + child: Container(color: Styles().colors.white, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppDateTime().formatDateTime(widget.history?.dateUtc?.toLocal(),format:dateFormat) ?? '',style:TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textSurface,)), + Container(height: 4,), + Text(title ?? '', style:TextStyle(fontSize: 20, fontFamily: Styles().fontFamilies.extraBold, color: Styles().colors.fillColorPrimary,)), + Container(height: 9,), + details ?? Container(), + !isTest? Container(): + Container(height: 9,), + !isTest? Container(): + Row(children: [ + Image.asset(isVerifiedTest? "images/certified-copy.png": "images/pending.png"), + Container(width: 10,), + isVerifiedTest? + Text(Localization().getStringEx("panel.health.covid19.history.label.verified","Verified"),style:TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textSurface,)): + Text(Localization().getStringEx("panel.health.covid19.history.label.verification_pending","Verification Pending"),style:TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textSurface,)), + ],), + ], + ) + )); + } + + Widget _buildResult(){ + return !_expanded || widget.history?.blob?.testResult==null? Container(): + Semantics( + sortKey: OrdinalSortKey(3), + container: true, + child: + Container(color: Styles().colors.white, + padding: EdgeInsets.symmetric(horizontal: 16), + child: + Column(children: [ + Container(height: 14,), + Row( + children: [ + Image.asset("images/selected-black.png",excludeFromSemantics: true,), + Container(width: 6,), + Text(Localization().getStringEx("panel.health.covid19.history.label.result.title","Result: "), + style: TextStyle(color: Styles().colors.textBackground,fontSize: 14, fontFamily: Styles().fontFamilies.bold,), + ), + Expanded(child:Container()), + Text(widget.history.blob?.testResult, + style: TextStyle(color: Styles().colors.textBackground,fontSize: 14, fontFamily: Styles().fontFamilies.regular,), + ) + ], + ), + Container(height: 14,), + Container(height:1, color: Styles().colors.surfaceAccent,) + ],) + ) + ); + } + + Widget _buildAdditionalInfo(){ + return + !_expanded ? Container(): + Semantics( + sortKey: OrdinalSortKey(4), + container: true, + child: + Container(color: Styles().colors.white, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Column( + children: [ + (_isLoading == true) ? Center(child: SizedBox(height: 24, width: 24, child:CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary),))): + widget.history?.blob?.location==null? Container(): + _buildDetail(Localization().getStringEx("panel.health.covid19.history.label.location.title","Test Location"), widget.history?.blob?.location, onTapData: (widget.history?.blob?.location != null) ? (){ _onTapLocation(); } : null, onTapHint: "Double tap to show location"), + + //_buildDetail(Localization().getStringEx("panel.health.covid19.history.label.technician_name.title","Technician Name"), widget.history?.technician), + //_buildDetail(Localization().getStringEx("panel.health.covid19.history.label.technician_id.title","Technician ID"), widget.history?.technicianId), + ], + ) + ) + ); + } + + Widget _buildMoreButton(){ + final Animatable _halfTween = Tween(begin: 0.0, end: 0.5); + final Animatable _easeInTween = CurveTween(curve: Curves.easeIn); + Animation _iconTurns = _controller.drive(_halfTween.chain(_easeInTween)); + + return + Semantics( + sortKey: OrdinalSortKey(2), + container: true, + button: true, + child: InkWell( + onTap: (){ + setState(() { + _expanded = !_expanded; + }); + if (_expanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + }, + child: Container( + decoration: BoxDecoration(color: Styles().colors.background, border: Border.all(color: Styles().colors.surfaceAccent,)), + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row(children: [ + Text(Localization().getStringEx("panel.health.covid19.history.label.more_info.title","More Info"), + style: TextStyle(color: Styles().colors.fillColorPrimary,fontSize: 16, fontFamily: Styles().fontFamilies.bold,), + ), + Expanded(child: Container(),), + Container(width: 4,), + RotationTransition( + turns: _iconTurns, + child: Image.asset("images/icon-down-orange.png")), + + ],) + ))); + } + + Widget _buildDetail(String title, String data, {GestureTapCallback onTapData, String onTapHint}) { + + TextStyle dataTextStyle = (onTapData != null) ? + TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.fillColorSecondary, decoration: TextDecoration.underline) : + TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground); + + Widget dataContentWidget = Padding(padding: EdgeInsets.symmetric(vertical: 4), child: Text(data ?? "", style: dataTextStyle, )); + + Widget dataWidget = (onTapData != null) ? + GestureDetector(onTap: () { onTapData(); } , + child: Semantics(label: data, hint: onTapHint, button: true, excludeSemantics: true, + child: dataContentWidget + )) : + dataContentWidget; + + return + Row(crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding(padding: EdgeInsets.symmetric(vertical: 4), child: + Text(sprintf("%s: ",[title],), + style: TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.bold, color: Styles().colors.textBackground), + ), + ), + Expanded(child: Container()), + dataWidget, + ], + ); + } + + void _onTapLocation() { + Analytics().logSelect(target: widget.history?.blob?.locationId); + String locationId = widget.history?.blob?.locationId; + if(locationId!=null){ + setState(() => _isLoading = true); + Health().loadHealthServiceLocation(locationId: locationId).then((location){ + setState(() => _isLoading = false); + if(location!=null){ + NativeCommunicator().launchMap( + target: { + 'latitude': location?.latitude, + 'longitude': location?.longitude, + 'zoom': 17, + }, + markers: [{ + 'name': location?.name, + 'latitude': location?.latitude, + 'longitude': location?.longitude, + 'description': null, + }] + ); + } else { + //error + AppToast.show("Unable to load location"); + } + }); + } else { + //missing location id + AppToast.show("Missing location id"); + } + } +} diff --git a/lib/ui/health/Covid19InfoCenterPanel.dart b/lib/ui/health/Covid19InfoCenterPanel.dart new file mode 100644 index 00000000..edb2980a --- /dev/null +++ b/lib/ui/health/Covid19InfoCenterPanel.dart @@ -0,0 +1,667 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:collection'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/health/Covid19AddTestResultPanel.dart'; +import 'package:illinois/ui/health/Covid19CareTeamPanel.dart'; +import 'package:illinois/ui/health/Covid19GuidelinesPanel.dart'; +import 'package:illinois/ui/health/Covid19StatusPanel.dart'; +import 'package:illinois/ui/health/Covid19SymptomsPanel.dart'; +import 'package:illinois/ui/health/Covid19TestLocations.dart'; +import 'package:illinois/ui/health/Covid19HistoryPanel.dart'; +import 'package:illinois/ui/health/Covid19WellnessCenter.dart'; +//import 'package:illinois/ui/settings/SettingsNewHomePanel.dart'; +import 'package:illinois/ui/settings/SettingsHomePanel.dart'; +import 'package:illinois/ui/widgets/LinkTileButton.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/SectionTitlePrimary.dart'; +import 'package:illinois/ui/widgets/StatusInfoDialog.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:sprintf/sprintf.dart'; + +class Covid19InfoCenterPanel extends StatefulWidget { + final Covid19Status status; + + Covid19InfoCenterPanel({Key key, this.status}) : super(key: key); + + @override + _Covid19InfoCenterPanelState createState() => _Covid19InfoCenterPanelState(); +} + +class _Covid19InfoCenterPanelState extends State implements NotificationsListener { + + Covid19Status _status; + bool _loadingStatus; + Covid19History _lastHistory; + String _currentCountyName; + + @override + void initState() { + super.initState(); + NotificationService().subscribe(this, [ + Health.notifyStatusChanged, + Health.notifyCountyStatusAvailable, + Health.notifyUserUpdated, + Health.notifyHistoryUpdated, + ]); + + if (widget.status != null) { + _status = widget.status; + } + else { + _loadStatus(); + } + + _loadHistory(); + _loadCountyName(); + } + + @override + void dispose() { + super.dispose(); + NotificationService().unsubscribe(this); + } + + @override + void onNotification(String name, param) { + if (name == Health.notifyStatusChanged) { + _updateStatus(param); + } + else if (name == Health.notifyCountyStatusAvailable) { + _updateStatus(param); + } + else if (name == Health.notifyUserUpdated) { + if (_status?.blob == null) { + _loadStatus(); + } + } else if (name == Health.notifyHistoryUpdated) { + _loadHistory(); + } + } + + void _loadStatus() { + setState(() { + _loadingStatus = true; + }); + Health().currentCountyStatus.then((Covid19Status status) { + setState(() { + _status = status; + _loadingStatus = Health().processingCountyStatus; + }); + }); + } + + void _updateStatus(Covid19Status status) { + if (mounted) { + setState(() { + _status = status; + _loadingStatus = false; + }); + } + } + + void _loadHistory(){ + Health().loadCovid19History().then((List history) { + if (mounted) { + setState(() { + _lastHistory = Covid19History.mostRecent(history); + }); + } + }); + } + + void _loadCountyName(){ + Health().loadCounties().then((List counties) { + LinkedHashMap _counties = HealthCounty.listToMap(counties); + if(counties != null && _counties.containsKey(Health().currentCountyId)) { + if(mounted){ + setState((){ + _currentCountyName = _counties[Health().currentCountyId].nameDisplayText; + }); + } + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.background, + appBar: _Covid19HomeHeaderBar(context: context,), + body: SingleChildScrollView( + child: SafeArea( + child: Column( + children: [ + _buildNextStepPrimarySection(), + _buildHealthPrimarySection(), + ], + ), + ), + ), + ); + } + + Widget _buildNextStepPrimarySection() { + return Health().isUserLoggedIn ? SectionTitlePrimary( + title: Localization().getStringEx("panel.covid19home.top_heading.title", "Stay Healthy"), + iconPath: 'images/icon-health.png', + children: [ + _buildMostRecentEvent(), + Container(height: 10,), + _buildNextStepSection(), + Container(height: 10,), + _buildSymptomCheckInSection(), + Container(height: 10,), + _buildAddTestResultSection(), + Container(height: 20,), + ],) : Container(); + } + + Widget _buildHealthPrimarySection() { + bool isLoggedIn = Health().isUserLoggedIn; + return SectionTitlePrimary( + title: Localization().getStringEx("panel.covid19home.label.health.title","Your Health"), + iconPath: 'images/icon-member.png', + children: [ + isLoggedIn ? _buildStatusSection() : Container(), + isLoggedIn ? Container(height: 10,) : Container(), + _buildTileButtons(), + Container(height: 5,), + isLoggedIn ? _buildViewHistoryButton() : _buildFindTestLocationsButton(), + Container(height: 10,), + _buildCovidWellnessCenter(), + ]); + + } + Widget _buildMostRecentEvent(){ + if(_lastHistory?.blob == null) { + return Container(); + } + String headingText = Localization().getStringEx("panel.covid19home.label.most_recent_event.title", "MOST RECENT EVENT"); + DateTime entryDate = _lastHistory.dateUtc; + String dateText = (entryDate != null) ? AppDateTime().formatDateTime(entryDate, format:"MMMM dd, yyyy") : ''; + String historyTitle = "", info = ""; + Covid19HistoryBlob blob = _lastHistory.blob; + if(blob.isTest){ + bool isManualTest = _lastHistory.isManualTest ?? false; + historyTitle = blob?.testType ?? Localization().getStringEx("app.common.label.other", "Other"); + info = isManualTest? Localization().getStringEx("panel.covid19home.label.provider.self_reported", "Self reported"): + (blob?.provider ?? Localization().getStringEx("app.common.label.other", "Other")); + } else if(blob.isAction){ + historyTitle = Localization().getStringEx("panel.covid19home.label.action_required.title", "Action Required"); + info = blob.actionDisplayString?? ""; + } else if(blob.isContactTrace){ + historyTitle = Localization().getStringEx("panel.covid19home.label.contact_trace.title", "Contact Trace"); + info = blob.traceDurationDisplayString; + } else if(blob.isSymptoms){ + historyTitle = Localization().getStringEx("panel.covid19home.label.reported_symptoms.title", "Self Reported Symptoms"); + info = blob.symptomsDisplayString; + } + + return Semantics(container: true, child: Container( + padding: EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration(color: Styles().colors.surface, borderRadius: BorderRadius.all(Radius.circular(4)), boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(2, 2))] ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Stack(children: [ + Visibility(visible: (_loadingStatus != true), + child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(headingText, style: TextStyle(letterSpacing: 0.5, fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + Expanded(child: Container(),), + Text(dateText, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 12, color: Styles().colors.textSurface),) + ],), + Container(height: 12,), + Text(historyTitle, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary),), + info.isNotEmpty ? Container(height: 12,) : Container(), + info.isNotEmpty ? + Text(info,style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface),) + : Container(), + ],), + ), + ), + Visibility(visible: (_loadingStatus == true), + child: Container( + height: 60, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + ],), + + Container(margin: EdgeInsets.only(top: 14, bottom: 14), height: 1, color: Styles().colors.fillColorPrimaryTransparent015,), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Semantics(explicitChildNodes: true, child: RoundedButton( + label: Localization().getStringEx("panel.covid19home.button.view_history.title", "View Health History"), + hint: Localization().getStringEx("panel.covid19home.button.view_history.hint", ""), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.surface, + textColor: Styles().colors.fillColorPrimary, + onTap: _onTapTestHistory, + )), + ) + ],), + )); + } + + Widget _buildNextStepSection() { + String headingText = (_status?.blob?.nextStep != null) ? Localization().getStringEx("panel.covid19home.label.next_step.title", "NEXT STEP") : ''; + String nextStepTitle = _status?.blob?.displayNextStep ?? ''; + String headingDate = (_status?.blob?.nextStepDateUtc != null) ? AppDateTime().formatDateTime(_status.blob.nextStepDateUtc.toLocal(), format:"MMMM dd, yyyy") : ''; + String scheduleText = (_status?.blob?.nextStepDateUtc != null) ? sprintf(Localization().getStringEx("panel.covid19home.label.schedule_after.title", "Schedule after %s"), [AppDateTime().formatDateTime(_status.blob.nextStepDateUtc.toLocal(), format:"MMMM dd")]) : ''; + + return Semantics(container: true, child: Container( + padding: EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration(color: Styles().colors.surface, borderRadius: BorderRadius.all(Radius.circular(4)), boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(2, 2))] ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Stack(children: [ + Visibility(visible: (_loadingStatus != true), + child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(headingText, style: TextStyle(letterSpacing: 0.5, fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + Expanded(child: Container(),), + Text(headingDate, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 12, color: Styles().colors.textSurface),) + ],), + Container(height: 12,), + Text(nextStepTitle, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary),), + scheduleText.isNotEmpty ? Container(height: 12,) : Container(), + scheduleText.isNotEmpty ? Row(children: [ + Visibility(visible: scheduleText.isNotEmpty, child: Padding(padding: EdgeInsets.only(right: 8), child: Image.asset('images/icon-calendar.png', excludeFromSemantics: true, ),),), + Text(scheduleText,style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface),), + ],) : Container(), + ],), + ), + ), + Visibility(visible: (_loadingStatus == true), + child: Container( + height: 60, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + ],), + + Container(margin: EdgeInsets.only(top: 14, bottom: 14), height: 1, color: Styles().colors.fillColorPrimaryTransparent015,), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Semantics(explicitChildNodes: true, child: RoundedButton( + label: Localization().getStringEx("panel.covid19home.button.find_test_locations.title", "Find test locations"), + hint: Localization().getStringEx("panel.covid19home.button.find_test_locations.hint", ""), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.surface, + textColor: Styles().colors.fillColorPrimary, + onTap: ()=> _onTapFindLocations(), + )), + ) + ],), + )); + } + + Widget _buildSymptomCheckInSection() { + String title = Localization().getStringEx("panel.covid19home.label.check_in.title","Symptom Check-in"); + String description = Localization().getStringEx("panel.covid19home.label.check_in.description","Self-report any symptoms to see if you should get tested or stay home"); + return Semantics(container: true, child: + InkWell(onTap: _onTapSymptomCheckIn, child: + Container( + padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16 + ), + decoration: BoxDecoration(color: Styles().colors.surface, borderRadius: BorderRadius.all(Radius.circular(4)), boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(2, 2))] ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded(child: + Text(title, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary),), + ), + Image.asset('images/chevron-right.png'), + ],), + Padding(padding: EdgeInsets.only(top: 5), child: + Text(description, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface),), + ), + ],),),), + ); + } + + Widget _buildAddTestResultSection() { + String title = Localization().getStringEx("panel.covid19home.label.result.title","Add Test Result"); + String description = Localization().getStringEx("panel.covid19home.label.result.description","To keep your status up-to-date"); + return Semantics(container: true, child: + InkWell(onTap: () => _onTapReportTest(), child: + Container( + padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16 + ), + decoration: BoxDecoration(color: Styles().colors.surface, borderRadius: BorderRadius.all(Radius.circular(4)), boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(2, 2))] ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded(child: + Text(title, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary),), + ), + Image.asset('images/chevron-right.png'), + ],), + Padding(padding: EdgeInsets.only(top: 5), child: + Text(description, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface),), + ), + ],),),), + ); + } + + Widget _buildStatusSection() { + String statusName = _status?.blob?.localizedHealthStatus; + Color statusColor = covid19HealthStatusColor(_status?.blob?.healthStatus) ?? Styles().colors.textSurface; + bool hasStatusCard = Health().isUserLoggedIn; + return Semantics(container: true, child: Container( + padding: EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration(color: Styles().colors.surface, borderRadius: BorderRadius.all(Radius.circular(4)), boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(2, 2))] ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Stack(children: [ + Visibility(visible: (_loadingStatus != true), + child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded(child: + Text(Localization().getStringEx("panel.covid19home.label.status.title","Current Status:"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ), + IconButton(icon: Image.asset('images/icon-info-orange.png'), onPressed: () => StatusInfoDialog.show(context, _currentCountyName), padding: EdgeInsets.all(10),) + ],), + Container(height: 6,), + Row( + children: [ + Visibility(visible:AppString.isStringNotEmpty(statusName), child: Image.asset('images/icon-member.png', color: statusColor,),), + Visibility(visible:AppString.isStringNotEmpty(statusName), child: Container(width: 4,),), + Expanded(child: + Text(AppString.isStringNotEmpty(statusName) ? statusName : Localization().getStringEx('panel.covid19home.label.status.na', 'Not Available'), style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Styles().colors.textSurface),), + ) + ], + ) + ],), + ), + ), + Visibility(visible: (_loadingStatus == true), + child: Container( + height: 16, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 16, width: 16, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + Container(height: 16,), + ],), + + hasStatusCard + ? Container(margin: EdgeInsets.only(top: 14, bottom: 14), height: 1, color: Styles().colors.fillColorPrimaryTransparent015,) + : Container(), + + hasStatusCard + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Semantics(explicitChildNodes: true, child: RoundedButton( + label: Localization().getStringEx("panel.covid19home.button.show_status_card.title","Show Status Card"), + hint: '', + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.surface, + textColor: Styles().colors.fillColorPrimary, + onTap: ()=> _onTapShowStatusCard(), + )), + ) + : Container() + ],), + )); + } + + Widget _buildTileButtons(){ + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: LinkTileSmallButton( + width: double.infinity, + iconPath: 'images/icon-country-guidelines.png', + label: Localization().getStringEx("panel.covid19home.button.country_guidelines.title", "County\nGuidelines"), + hint: Localization().getStringEx("panel.covid19home.button.country_guidelines.hint", ""), + onTap: ()=>_onTapCountryGuidelines(), + ), + ), + Container(width: 8,), + Expanded( + child: LinkTileSmallButton( + width: double.infinity, + iconPath: 'images/icon-your-care-team.png', + label: Localization().getStringEx( "panel.covid19home.button.care_team.title", "Your\nCare Team"), + hint: Localization().getStringEx( "panel.covid19home.button.care_team.hint", ""), + onTap: () => _onTapCareTeam(), + ), + ), + ], + ), + Container(height: 8,), + /*Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: LinkTileSmallButton( + width: double.infinity, + iconPath: 'images/icon-report-test.png', + label: Localization().getStringEx("panel.covid19home.button.report_test.title", "Report a Test Result"), + hint: Localization().getStringEx("panel.covid19home.button.report_test.hint", ""), + onTap: ()=> _onTapReportTest(), + ), + ), + Container(width: 8,), + Expanded( + child: LinkTileSmallButton( + width: double.infinity, + iconPath: 'images/icon-test-history.png', + label: Localization().getStringEx("panel.covid19home.button.test_history.title", "Your Testing History"), + hint: Localization().getStringEx("panel.covid19home.button.test_history.hint", ""), + onTap: ()=>_onTapTestHistory(), + ), + ), + ], + )*/ + ], + ); + } + + Widget _buildViewHistoryButton() { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [BoxShadow(color: Color.fromRGBO(19, 41, 75, 0.3), spreadRadius: 2.0, blurRadius: 8.0, offset: Offset(0, 2))], + ), + child: RibbonButton( + label: Localization().getStringEx("panel.covid19.button.health_history.title", "View Health History"), + borderRadius: BorderRadius.circular(4), + onTap: ()=>_onTapTestHistory(), + ), + ); + } + + Widget _buildCovidWellnessCenter() { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [BoxShadow(color: Color.fromRGBO(19, 41, 75, 0.3), spreadRadius: 2.0, blurRadius: 8.0, offset: Offset(0, 2))], + ), + child: RibbonButton( + label: Localization().getStringEx("panel.covid19.button.covid_wellness_center.title", "COVID-19 Wellness Answer Center"), + borderRadius: BorderRadius.circular(4), + onTap: ()=>_onTapCovidWellnessCenter(), + ), + ); + } + + Widget _buildFindTestLocationsButton() { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [BoxShadow(color: Color.fromRGBO(19, 41, 75, 0.3), spreadRadius: 2.0, blurRadius: 8.0, offset: Offset(0, 2))], + ), + child: RibbonButton( + label: Localization().getStringEx("panel.covid19home.button.find_test_locations.title", "Find test locations"), + hint: Localization().getStringEx("panel.covid19home.button.find_test_locations.hint", ""), + borderRadius: BorderRadius.circular(4), + onTap: ()=>_onTapFindLocations(), + ), + ); + } + + + /*Widget _buildCampusUpdatesSection() { + String title = Localization().getStringEx("panel.covid19home.label.campus_updates.title","Campus Updates"); + return Semantics(container: true, child: + InkWell(onTap: () => _onTapCampusUpdates(), child: + Container( + padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16 + ), + decoration: BoxDecoration(color: Styles().colors.surface, borderRadius: BorderRadius.all(Radius.circular(4)), boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(2, 2))] ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded(child: + Text(title, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary),), + ), + Image.asset('images/chevron-right.png'), + ],), + ],),),), + ); + }*/ + + /*Widget _buildAboutButton() { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [BoxShadow(color: Color.fromRGBO(19, 41, 75, 0.3), spreadRadius: 2.0, blurRadius: 8.0, offset: Offset(0, 2))], + ), + child: RibbonButton( + label: Localization().getStringEx("panel.covid19home.button.about.title","About"), + borderRadius: BorderRadius.circular(4), + onTap: ()=>_onTapAbout(), + ), + ); + }*/ + + void _onTapCountryGuidelines() { + Analytics.instance.logSelect(target: "COVID-19 County Guidlines"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19GuidelinesPanel(status: _status))); + } + + /*void _onTapCampusUpdates() { + Analytics.instance.logSelect(target: "COVID-19 Campus Updates"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19UpdatesPanel())); + }*/ + + void _onTapCareTeam() { + Analytics.instance.logSelect(target: "Your Care Team"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19CareTeamPanel(status: _status,))); + } + + void _onTapReportTest(){ + Analytics.instance.logSelect(target: "COVID-19 Report Test"); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19AddTestResultPanel())); + } + + void _onTapTestHistory(){ + Analytics.instance.logSelect(target: "COVID-19 Test History"); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19HistoryPanel())); + } + + void _onTapFindLocations(){ + Analytics.instance.logSelect(target: "COVID-19 Find Test Locations"); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19TestLocationsPanel())); + } + + void _onTapShowStatusCard(){ + Analytics.instance.logSelect(target: "Show Status Card"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19StatusPanel())); + } + + void _onTapSymptomCheckIn() { + Analytics.instance.logSelect(target: "Symptom Check-in"); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19SymptomsPanel())); + } + + void _onTapCovidWellnessCenter(){ + Analytics.instance.logSelect(target: "Wellness Center"); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19WellnessCenter())); + } + + /*void _onTapAbout() { + Analytics.instance.logSelect(target: "About"); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19AboutPanel())); + }*/ +} + +class _Covid19HomeHeaderBar extends AppBar { + final BuildContext context; + final Widget titleWidget; + final bool searchVisible; + final bool rightButtonVisible; + final String rightButtonText; + final GestureTapCallback onRightButtonTap; + + static Color get _titleColor => + Config().configEnvironment == ConfigEnvironment.dev + ? Colors.yellow : Config().configEnvironment == ConfigEnvironment.test ? Colors.green + : Colors.white; + + _Covid19HomeHeaderBar({@required this.context, this.titleWidget, this.searchVisible = false, + this.rightButtonVisible = false, this.rightButtonText, this.onRightButtonTap}) + : super( + backgroundColor: Styles().colors.fillColorPrimaryVariant, + title: Text(Localization().getStringEx("panel.covid19home.header.title", "Safer Illinois Home"), + style: TextStyle(color: _titleColor, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold), + ), + actions: [ + Semantics( + label: Localization().getStringEx('headerbar.settings.title', 'Settings'), + hint: Localization().getStringEx('headerbar.settings.hint', ''), + button: true, + excludeSemantics: true, + child: IconButton( + icon: Image.asset('images/settings-white.png'), + onPressed: () { + Analytics.instance.logSelect(target: "Settings"); + //TMP: Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsNewHomePanel())); + Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsHomePanel())); + })) + ], + centerTitle: true); +} \ No newline at end of file diff --git a/lib/ui/health/Covid19NewsPanel.dart b/lib/ui/health/Covid19NewsPanel.dart new file mode 100644 index 00000000..183ad288 --- /dev/null +++ b/lib/ui/health/Covid19NewsPanel.dart @@ -0,0 +1,259 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/ui/WebPanel.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:sprintf/sprintf.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class Covid19NewsPanel extends StatefulWidget { + final Covid19News covid19news; + + Covid19NewsPanel({@required this.covid19news}); + + @override + _Covid19NewsPanelState createState() => _Covid19NewsPanelState(); +} + +class _Covid19NewsPanelState extends State implements NotificationsListener { + + @override + void initState() { + NotificationService().subscribe(this, [User.notifyFavoritesUpdated]); + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + String displayDate = widget.covid19news?.displayDate; + String title = widget.covid19news?.title; + String htmlContent = widget.covid19news?.htmlContent; + bool isFavorite = User().isFavorite(widget.covid19news); + bool starVisible = User().favoritesStarVisible; + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + Localization().getStringEx("panel.covid19_news.header.title", "COVID-19"), + style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold), + ), + ), + body: SingleChildScrollView( + child: Stack( + alignment: Alignment.topRight, + children: [ + Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Visibility( + visible: AppString.isStringNotEmpty(displayDate), + child: Padding( + padding: EdgeInsets.only(bottom: 16), + child: Text( + sprintf(Localization().getStringEx('panel.covid19_news.news.posted.label', 'Posted on %s'), + [AppString.getDefaultEmptyString(value: displayDate)]), + style: TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.bold, color: Styles().colors.textBackground), + ), + ), + ), + Visibility( + visible: AppString.isStringNotEmpty(title), + child: Padding( + padding: EdgeInsets.only(bottom: 18), + child: Text( + AppString.getDefaultEmptyString(value: title), + style: TextStyle(fontSize: 28, color: Styles().colors.fillColorPrimary), + ), + ), + ), + Visibility( + visible: AppString.isStringNotEmpty(htmlContent), + child: Padding( + padding: EdgeInsets.only(bottom: 16), + child: Html( + data: htmlContent, + onLinkTap: (url) => _onLinkTap(url), + defaultTextStyle: TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground), + ), + ), + ) + ], + ), + ), + Visibility( + visible: starVisible, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _onTapNewsCardStar(widget.covid19news), + child: Semantics( + label: isFavorite + ? Localization().getStringEx('widget.card.button.favorite.off.title', 'Remove From Favorites') + : Localization().getStringEx('widget.card.button.favorite.on.title', 'Add To Favorites'), + hint: isFavorite + ? Localization().getStringEx('widget.card.button.favorite.off.hint', '') + : Localization().getStringEx('widget.card.button.favorite.on.hint', ''), + button: true, + excludeSemantics: true, + child: Container( + padding: EdgeInsets.only(top: 12, right: 12), + height: 52, + width: 52, + child: Align( + alignment: Alignment.topRight, + child: Image.asset(isFavorite ? 'images/icon-star-selected.png' : 'images/icon-star.png', excludeFromSemantics: true,), + ), + ))), + ) + ], + ), + ), + ); + } + + void _onTapNewsCardStar(Covid19News news) { + Analytics.instance.logSelect(target: "Favorite: Covid-19 News"); + User().switchFavorite(news); + } + + void _onLinkTap(String url) { + if (AppString.isStringEmpty(url)) { + return; + } + if (AppUrl.launchInternal(url)) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => WebPanel(url: url))); + } else { + launch(url); + } + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == User.notifyFavoritesUpdated) { + setState(() {}); + } + } +} + +class Covid19NewsCard extends StatefulWidget { + final Covid19News news; + + Covid19NewsCard({@required this.news}); + + @override + _Covid19NewsCardState createState() => _Covid19NewsCardState(); +} + +class _Covid19NewsCardState extends State implements NotificationsListener { + + @override + void initState() { + super.initState(); + NotificationService().subscribe(this, [User.notifyFavoritesUpdated]); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + @override + void onNotification(String name, param) { + if (name == User.notifyFavoritesUpdated) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + String title = widget.news.title; + String dateFormatted = widget.news.displayDate; + bool isFavorite = User().isFavorite(widget.news); + bool starVisible = User().favoritesStarVisible; + return GestureDetector( + onTap: () => _onTapNewsCard(widget.news), child: Padding(padding: EdgeInsets.only(bottom: 16), child: Stack(alignment: Alignment.topRight, + children: [ + Semantics(hint: Localization().getStringEx('widget.covid19_news_card.read_more.hint', "Double tap to read more"), child: + + Container( + width: double.infinity, + padding: EdgeInsets.all(16), + decoration: BoxDecoration(color: Styles().colors.white, borderRadius: BorderRadius.all(Radius.circular(4.0)), + boxShadow: [BoxShadow(color: Styles().colors.fillColorPrimaryTransparent015, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(0, 2))],), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 12, right: 42), child: Row(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(AppString.getDefaultEmptyString(value: title), maxLines: 6, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 20, fontFamily: Styles().fontFamilies.extraBold, color: Styles().colors.fillColorPrimary),),) + ],),), + Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(right: 8), child: Image.asset('images/icon-news.png', excludeFromSemantics: true,),), + Text(AppString.getDefaultEmptyString(value: dateFormatted), + style: TextStyle(fontSize: 14, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground),) + ],) + ],))), + Visibility(visible: starVisible, child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _onTapNewsCardStar(widget.news), + child: Semantics( + label: isFavorite ? Localization().getStringEx('widget.card.button.favorite.off.title', 'Remove From Favorites') : Localization().getStringEx( + 'widget.card.button.favorite.on.title', 'Add To Favorites'), + hint: isFavorite ? Localization().getStringEx('widget.card.button.favorite.off.hint', '') : Localization().getStringEx( + 'widget.card.button.favorite.on.hint', ''), + button: true, + excludeSemantics: true, + child: Container( + padding: EdgeInsets.only(top: 16, right: 16), height: 52, width: 52, + child: Align(alignment: Alignment.topRight, + child: Image.asset(isFavorite ? 'images/icon-star-selected.png' : 'images/icon-star.png', excludeFromSemantics: true,),),) + )),) + ],),)); + } + + void _onTapNewsCardStar(Covid19News news) { + Analytics.instance.logSelect(target: "Favorite: Covid-19 News"); + User().switchFavorite(news); + } + + void _onTapNewsCard(Covid19News news) { + Analytics.instance.logSelect(target: "Covid-19 News: ${news?.title}"); + if (news != null) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19NewsPanel(covid19news: news,))); + } + } +} + diff --git a/lib/ui/health/Covid19NextStepsPanel.dart b/lib/ui/health/Covid19NextStepsPanel.dart new file mode 100644 index 00000000..23e2edb6 --- /dev/null +++ b/lib/ui/health/Covid19NextStepsPanel.dart @@ -0,0 +1,113 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/health/Covid19CareTeamPanel.dart'; +import 'package:illinois/ui/health/Covid19TestLocations.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; + +class Covid19NextStepsPanel extends StatefulWidget { + final Covid19Status status; + + Covid19NextStepsPanel({this.status} ); + + @override + _Covid19NextStepsPanelState createState() => _Covid19NextStepsPanelState(); +} + +class _Covid19NextStepsPanelState extends State { + + @override + void initState() { + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.fillColorPrimary, + body:Container( + child: Column( + children: [ + Expanded( + child: _buildContent(), + ), +// _buildPageIndicator(), + Container( + padding: EdgeInsets.symmetric(vertical: 20, horizontal: 16), + child: RoundedButton( + label: _nextStepRequiresTest? Localization().getStringEx("panel.health.next_steps.button.continue.title.find_locatio","Find location") : + Localization().getStringEx("panel.health.next_steps.button.continue.title.care_team","Get in Touch with Care Team") , + backgroundColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.white, + onTap:_onContinueTap, + ), + ) + ], + ), + ) + ); + } + + Widget _buildContent(){ + return SingleChildScrollView( + child: Column(children: [ + Container(height: 165,), + Text(Localization().getStringEx("panel.health.next_steps.label.next_steps","NEXT STEPS"), style: TextStyle(color: Colors.white, fontSize: 28, fontFamily: Styles().fontFamilies.bold)), + Container(height: 29,), + Container( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Container(height: 1, color: Styles().colors.surfaceAccent ,), + ), + Container(height: 34,), + Padding(padding: EdgeInsets.symmetric(horizontal: 32), child: + Text(widget.status?.blob?.displayNextStep ?? " ", textAlign: TextAlign.center, style: TextStyle(color: Colors.white, fontSize: 20, fontFamily: Styles().fontFamilies.extraBold)), + ), + Container(height: 12,), + Container(child:Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("images/icon-calendar.png", excludeFromSemantics: true), + Container(width: 8,), + Text(Localization().getStringEx("panel.health.next_steps.label.asap","ASAP"), style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.regular)), + ],)), + ],), + ); + } + + void _onContinueTap(){ + if(_nextStepRequiresTest) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19TestLocationsPanel())).then((dynamic) { + Navigator.pop(context); + }); + } + else { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19CareTeamPanel())).then((dynamic) { + Navigator.pop(context); + }); + } + } + + bool get _nextStepRequiresTest{ + return widget.status?.blob?.nextStep?.contains("test"); //TBD + } +} \ No newline at end of file diff --git a/lib/ui/health/Covid19QrCodePanel.dart b/lib/ui/health/Covid19QrCodePanel.dart new file mode 100644 index 00000000..ee26048f --- /dev/null +++ b/lib/ui/health/Covid19QrCodePanel.dart @@ -0,0 +1,147 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Covid19.dart'; +import 'package:illinois/utils/Crypt.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:pointycastle/export.dart' as secure; + +class Covid19QrCodePanel extends StatefulWidget { + + const Covid19QrCodePanel({Key key}) : super(key: key); + @override + _Covid19QrCodePanelState createState() => _Covid19QrCodePanelState(); +} + +class _Covid19QrCodePanelState extends State { + Uint8List _qrCodeBytes; + + @override + void initState() { + super.initState(); + _loadQrImageBytes().then((imageBytes) { + setState(() { + _qrCodeBytes = imageBytes; + }); + }); + } + + Future _loadQrImageBytes() async { + secure.PrivateKey privateKey = await Health().loadRSAPrivateKey(); + Uint8List privateKeyData = (privateKey != null) ? RsaKeyHelper.encodePrivateKeyToPEMDataPKCS1(privateKey): null; + List privateKeyCompressedData = (privateKeyData != null) ? GZipEncoder().encode(privateKeyData) : null; + String privateKeyString = (privateKeyData != null) ? base64.encode(privateKeyCompressedData) : null; + if (AppString.isStringEmpty(privateKeyString)) { + return null; + } + return await NativeCommunicator().getBarcodeImageData({ + 'content': privateKeyString, + 'format': 'qrCode', + 'width': 1024, + 'height': 1024, + }); + } + + Future _saveQrCode() async{ + Analytics.instance.logSelect(target: "Save QR Code"); + + if (_qrCodeBytes == null) { + AppAlert.showDialogResult(context, Localization().getStringEx("panel.covid19.qr_code.alert.no_qr_code.msg", "There is no QR Code")); + } + else { + bool result = await Covid19Utils.saveQRCodeImageToPictures(qrCodeBytes: _qrCodeBytes, title: Localization().getStringEx("panel.covid19.transfer.label.qr_image_label", "Safer Illinois COVID-19 Code")); + String platformTargetText = (defaultTargetPlatform == TargetPlatform.android)?Localization().getStringEx("panel.health.covid19.alert.save.success.pictures", "Pictures"): Localization().getStringEx("panel.health.covid19.alert.save.success.gallery", "gallery"); + String message = result + ? (Localization().getStringEx("panel.covid19.transfer.alert.save.success.msg", "Successfully saved qr code in ") + platformTargetText) + : Localization().getStringEx("panel.covid19.transfer.alert.save.fail.msg", "Failed to save qr code in ") + platformTargetText; + AppAlert.showDialogResult(context, message); + } + + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + Localization().getStringEx('panel.covid19.qr_code.title', 'COVID-19 QR Code'), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 1.0, + ), + textAlign: TextAlign.center, + ), + ), + body: SingleChildScrollView( + child: Container( + color: Styles().colors.background, + child: Padding(padding: EdgeInsets.all(24), child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + Localization().getStringEx('panel.covid19.qr_code.description.heading.1', 'If you use more than one device with the Safer Illinois app, use this QR code to transfer the necessary secret to decode your COVID-19 health information.'), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold),), + Padding(padding: EdgeInsets.only(top: 24), child: + ((_qrCodeBytes != null) ? + Semantics(label: Localization().getStringEx('panel.covid19.qr_code.code.hint', "QR code image"), child: + Container( + decoration: BoxDecoration(color: Styles().colors.white, borderRadius: BorderRadius.all( Radius.circular(5))), + padding: EdgeInsets.all(5), child: + Image.memory(_qrCodeBytes, fit: BoxFit.fitWidth, semanticLabel: Localization().getStringEx("panel.health.covid19.qr_code.primary.heading.title", "Your COVID-19 Encryption Key"), + ),),) : + Container()), + ), + Padding(padding: EdgeInsets.only(top: 24), child: Text( + Localization().getStringEx('panel.covid19.qr_code.description.heading.2', 'Save this QR code so that If you lose or replace your phone, you can retrieve your COVID-19 health information on your new phone.'), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold),),), + Padding(padding: EdgeInsets.only(top: 24, bottom: 12), child: RoundedButton( + label: Localization().getStringEx('panel.covid19.qr_code.button.save.title', 'Save'), + hint: '', + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + onTap: _onTapSave, + ),), + ], + ),), + ), + ), + backgroundColor: Styles().colors.background, + ); + } + + void _onTapSave() { + _saveQrCode(); + } +} diff --git a/lib/ui/health/Covid19ReportTestPanel.dart b/lib/ui/health/Covid19ReportTestPanel.dart new file mode 100644 index 00000000..2121f102 --- /dev/null +++ b/lib/ui/health/Covid19ReportTestPanel.dart @@ -0,0 +1,730 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dotted_border/dotted_border.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/PopupDialog.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:path_provider/path_provider.dart'; + +class Covid19ReportTestPanel extends StatefulWidget { + final HealthServiceProvider provider; + Covid19ReportTestPanel({this.provider}); + + @override + _Covid19ReportTestPanelSate createState() => _Covid19ReportTestPanelSate(); +} + +class _Covid19ReportTestPanelSate extends State{ + + int _loadingProgress = 0; + + File _testPhoto; + + LinkedHashMap _locations; + List_types; + LinkedHashMap _results; + + DateTime _selectedDate; + String _selectedLocationId; + TestDropDownItem _selectedTestType; + String _selectedResultId; + //String _imageUrl; + + @override + void initState() { + DateTime now = DateTime.now(); + _selectedDate = DateTime(now.year, now.month, now.day); + if(_isCustomProvider) { + _loadTestTypes(); + } else { + _loadLocations(); + } + super.initState(); + } + + void _loadLocations(){ + _increaseProgress(); + Health().loadHealthServiceLocations(countyId: Health().currentCountyId, providerId: widget.provider?.id).then((List locations){ + setState(() { + try { + _locations = locations != null ? Map.fromIterable(locations, key: ((location) => location.id)) : null; + } catch(e){ + print(e); + } + _decreaseProgress(); + }); + }); + } + + void _loadTestTypes(){ + _increaseProgress(); + List typesIds = _selectedLocationId!=null? _locations[_selectedLocationId]?.availableTests: null; + Health().loadHealthServiceTestTypes(typeIds: typesIds).then((List types){ + setState(() { + _decreaseProgress(); + try{ + _types = List(); + if(types?.isNotEmpty?? false) { + _types.addAll(types?.map((HealthTestType type) { + TestDropDownItem item = TestDropDownItem(type: TestDropDownItemType.provider, item: type); + + return item; + })?.toList()); + } + _types.add(TestDropDownItem(type: TestDropDownItemType.other, item: null)); + } catch(e){ + print(e); + } + }); + }); + } + + void _loadTestResults(){ + List results = _selectedTestType?.item?.results; + setState(() { + _results = results!=null?Map.fromIterable(results,key:((result)=>result.id)): null; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.health.report_test.heading.title","Manually Enter Result"), style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: _isLoading? _buildLoading() : _buildContent() + ), + ), + ], + ), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildContent(){ + return Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 26,), + _buildProviderField(), + _buildDateField(), + _buildLocationField(), + _buildTypeField(), + _buildResultField(), + _buildAddImageField(), + _buildAddTestButton() + ], + ), + ); + } + + Widget _buildLoading(){ + return + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height:21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ),),); + } + + Widget _buildDateField(){ + String dateText = _selectedDate != null ? AppDateTime().formatDateTime(_selectedDate, format: AppDateTime.covid19ReportTestDateFormat) : ""; + + return Semantics(container: true, child: + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldTitle(Localization().getStringEx("panel.health.report_test.label.date","TEST DATE AND TIME")), + Container(height: 8,), + GestureDetector( + onTap: _onTapPickTime, + child: Container( + height: 48, +// width: 142, + decoration: BoxDecoration( + border: Border.all(color: Styles().colors.surfaceAccent, width: 1), + borderRadius: BorderRadius.all(Radius.circular(4))), + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppString.getDefaultEmptyString(value: dateText, defaultValue: ''), + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontSize: 16, + fontFamily: Styles().fontFamilies.medium), + ), + Image.asset('images/icon-down-orange.png') + ], + ), + ), + ), + Container(height: 26,) + ],), + )); + } + + Widget _buildProviderField(){ + return Semantics(container: true, child: + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldTitle(Localization().getStringEx("panel.health.report_test.label.provider","HEALTHCARE PROVIDER")), + Container(height: 8,), + _fieldTitle(widget?.provider?.name?? Localization().getStringEx("app.common.label.other", "Other")), + Container(height: 26,) + ],), + )); + } + + Widget _buildLocationField(){ + return _showLocationField? + Semantics(container: true, child: + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldTitle(Localization().getStringEx("panel.health.report_test.label.date.location","TEST LOCATION")), + Container(height: 8,), + _dropDownMenu(Localization().getStringEx("panel.health.report_test.label.location.empty","Select location…"), _locations!=null?_locations[_selectedLocationId] : null, _locations?.values, + getLabel: (HealthServiceLocation selectedValue) => selectedValue?.name ?? "unknown", + onChanged:(HealthServiceLocation selectedValue) { + _selectedLocationId = selectedValue.id; + _loadTestTypes(); + }), + Container(height: 26,) + ],), + )) : + Container(); + } + + Widget _buildTypeField(){ + return Semantics(container: true, child: + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldTitle(Localization().getStringEx("panel.health.report_test.label.type","TEST TYPE")), + Container(height: 8,), + _dropDownMenu("Select test type…", _selectedTestType, _types, /*enabled: _selectedLocationId!=null,*/ + getLabel: (TestDropDownItem selectedValue) => selectedValue.title, + onChanged:(TestDropDownItem selectedValue) { + _selectedTestType = selectedValue; + _loadTestResults(); + }), + Container(height: 26,) + ],), + )); + } + + Widget _buildResultField(){ + return _showResultField? Semantics(container: true, child: + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldTitle(Localization().getStringEx("panel.health.report_test.label.result","RESULT")), + Container(height: 8,), + _dropDownMenu(Localization().getStringEx("panel.health.report_test.label.result.empty","Select test result…"),_results!=null ? _results[_selectedResultId]: null, _results?.values, /*enabled: _selectedTypeId!=null,*/ + getLabel: (HealthTestTypeResult selectedValue) => selectedValue.name, + onChanged:(HealthTestTypeResult selectedValue) => _selectedResultId = selectedValue.id), + Container(height: 26,) + ],), + )) : + Container(); + } + + Widget _buildAddImageField(){ + return Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldTitle(Localization().getStringEx("panel.health.report_test.label.image","ADD TEST RESULT")), + Container(height: 2,), + Text(Localization().getStringEx("panel.health.report_test.label.image.hint","Upload an image of your test result."), + style: TextStyle(color: Styles().colors.textBackground, fontSize: 16, fontFamily: Styles().fontFamilies.regular,),), + Container(height: 11,), + InkWell( + onTap: _testPhoto!=null? _onTapViewImage : _onTapUploadImage, + child: DottedBorder( + color: Styles().colors.mediumGray2, padding: EdgeInsets.all(0), strokeWidth: 3,dashPattern: [4, 4], + child: Container(height: 85, + decoration: BoxDecoration( + color: Styles().colors.lightGray, + image: _testPhoto != null ? DecorationImage(image: FileImage(_testPhoto,)) : null, + ), + alignment: Alignment.center, + child: Row(children: [ + Expanded(child:Container()), + + _testPhoto!=null?Container(): + Text(Localization().getStringEx("panel.health.report_test.button.add_image.title","Upload Image"), + style: TextStyle(color: Styles().colors.fillColorPrimaryVariant, fontSize: 16, fontFamily: Styles().fontFamilies.bold,),), + Container(width: 8,), + _testPhoto!=null?Container(): + Image.asset("images/icon-plus.png", excludeFromSemantics: true,), + Expanded(child:Container()), + ], + ), + ) ) ) + ],), + ); + } + + //Buttons + Widget _buildAddTestButton() { + return + Stack(children: [ + Container( + padding: EdgeInsets.symmetric(vertical: 26), + child: Center( + child: RoundedButton( + label: Localization().getStringEx("panel.health.report_test.button.add_test.title", "Add Test"), + backgroundColor: Colors.white, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + onTap: _onTapAddTest, + height: 48, + ), + ) + ,), + Visibility(visible: _isLoading, + child: Container( + padding: EdgeInsets.symmetric(vertical: 26), + height: 48, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary), ) + ), + ), + ), + ), + ],); + } + + Widget _fieldTitle(String title){ + return Container( + alignment: Alignment.centerLeft, + child: Text( + title, + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold,letterSpacing: 0.86), + ), + ); + } + + //Dtopdown + + Widget _dropDownMenu(String hint, dynamic selectedValue, Iterable values ,{Function onChanged, Function getLabel, bool enabled=true}){ + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Styles().colors.surfaceAccent, + width: 1), + borderRadius: + BorderRadius.all(Radius.circular(4))), + child: Padding( + padding: + EdgeInsets.only(left: 12, right: 16), + child: DropdownButtonHideUnderline( + child: DropdownButton( + + icon: enabled? Image.asset( + 'images/icon-down-orange.png') : Container(), + isExpanded: true, + style: TextStyle( + color: Styles().colors.mediumGray, + fontSize: 16, + fontFamily: + Styles().fontFamilies.regular), + hint: Text(selectedValue!=null ? _getLabel(selectedValue, getLabel) : hint), + items: enabled?_buildDropDownItems(values, getLabel): null, + onChanged: (value)=>setState(()=>onChanged(value)), + )), + ), + ); + } + + List> _buildDropDownItems(Iterable items, Function getLabel) { + int itemsCount = items?.length ?? 0; + if (itemsCount == 0) { + return null; + } + return items.map((dynamic item) { + return DropdownMenuItem( + value: item, + child: Text( + _getLabel(item, getLabel) + ), + ); + }).toList(); + } + + String _getLabel(dynamic item ,Function getLabel){ + return getLabel!=null? + getLabel(item) : item?.toString()??""; + } + + void _onTapPickTime() async{ + //This always returns 00:00 as Time + DateTime date = await _pickDate(_selectedDate); + + if(date!=null){ + TimeOfDay time = await showTimePicker(context: context, initialTime: new TimeOfDay(hour: _selectedDate?.hour??date.hour, minute: _selectedDate?.minute??date.minute), + builder: (BuildContext context, Widget child) { + return MediaQuery( //Show in 12 hour format (depending on locale) + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: false), + child: child, + ); + } + ); + if (time != null){ + DateTime accurateDate = DateTime(date.year, date.month, date.day,time.hour,time.minute); + _selectedDate = accurateDate; + } else { + //Canceled time selection so use the previously selected hours + DateTime previuslySelectedTime= DateTime(date.year, date.month, date.day,_selectedDate?.hour??date.hour,_selectedDate.minute?? time.minute); + _selectedDate = previuslySelectedTime; + } + } + + setState(() {}); + } + + void _onTapViewImage() async { + Analytics.instance.logSelect(target: "View Image"); + await showDialog( + context: context, + builder: (_) => Material( + type: MaterialType.transparency, + child: + Container( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + color: Styles().colors.background, + padding: EdgeInsets.symmetric(vertical: 0), + child: Image.file(_testPhoto),), + Container(color: Styles().colors.background,height: 4,), + Container( + color: Styles().colors.background, + child: Row(children: [ + Container(width: 4,), + Expanded(child: + RoundedButton( + label: Localization().getStringEx("panel.health.report_test.button.close.title","Close"), + onTap: _onTapCloseView, + backgroundColor: Styles().colors.background, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary,), + ), + Container(width: 8,), + Expanded(child: + RoundedButton( + label: Localization().getStringEx("panel.health.report_test.button.retake.title","Retake"), + onTap: _onTapRetake, + backgroundColor: Styles().colors.background, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + )), + Container(width: 4,), + ],)), + Container(color: Styles().colors.background,height: 4,) + ],) + ))); + } + + void _onTapRetake(){ + Analytics.instance.logSelect(target: "Retake"); + Navigator.pop(context); + _onTapUploadImage(); + } + void _onTapCloseView(){ + Analytics.instance.logSelect(target: "Close"); + Navigator.pop(context); + } + + void _onTapUploadImage() async { + showCupertinoModalPopup(context: context, builder: (context){ + Analytics.instance.logSelect(target: "Uppload Image"); + return CupertinoActionSheet( + title: Text(Localization().getStringEx("panel.health.report_test.label.select_photo","Select photo")), + message: Text(Localization().getStringEx("panel.health.report_test.label.select_photo.description","Please take a photo of the test result or select it from the gallery")), + actions: [ + CupertinoActionSheetAction( + child: Text(Localization().getStringEx("panel.health.report_test.button.take_photo","Take photo"), style: TextStyle(color: Styles().colors.fillColorSecondary),), + onPressed: _onTapUseCamera, + ), + CupertinoActionSheetAction( + child: Text(Localization().getStringEx("panel.health.report_test.button.select_gallery","Select from gallery"), style: TextStyle(color: Styles().colors.fillColorSecondary),), + onPressed: _onTapSelectFromGallery, + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + child: Text(Localization().getStringEx("panel.health.report_test.button.cancel","Cancel"), style: TextStyle(color: Styles().colors.fillColorSecondary),), + onPressed: () { Navigator.pop(context); }, + ), + ); + }); + + /*_imageUrl = await showDialog( + context: context, + builder: (_) => Material( + type: MaterialType.transparency, + child: AddImageWidget(), + ) + );*/ + } + + void _onTapSelectFromGallery(){ + Analytics.instance.logSelect(target: "Select Forom Gallery"); + Navigator.pop(context); + ImagePicker().getImage(source: ImageSource.gallery).then((PickedFile pickedFile){ + setState(() {_testPhoto = File(pickedFile.path);}); + }); + } + + void _onTapUseCamera(){ + Analytics.instance.logSelect(target: "Use Camera"); + Navigator.pop(context); + ImagePicker().getImage(source: ImageSource.camera).then((PickedFile pickedFile){ + setState(() {_testPhoto = File(pickedFile.path);}); + }); + } + + void _onTapAddTest() async{ + Analytics.instance.logSelect(target: "Add Test"); + + if(!_validate()){ + return; + } + _increaseProgress(); + + File resizedImage = await resizeImage(_testPhoto); + + String imageBase64; + try{ + List photoBytes = resizedImage!=null? resizedImage.readAsBytesSync() : _testPhoto.readAsBytesSync(); + imageBase64 = base64Encode(photoBytes); + }catch(e){ + + } + + HealthServiceProvider provider = widget.provider; + HealthServiceLocation location = _locations!=null ? _locations[_selectedLocationId] : null; + HealthTestType testType = _selectedTestType?.item; + HealthTestTypeResult testResult = _results!=null ? _results[_selectedResultId]: null; + + DateTime dateUtc = _selectedDate.toUtc(); + + // Ensure county if provider is 'Other' + String countyId; + if (_isCustomProvider) { + countyId = Storage().currentHealthCountyId; + if (AppString.isStringEmpty(countyId)) { + List counties = await Health().loadCounties(); + countyId = HealthCounty.defaultCounty(counties)?.id; + } + } + + Covid19ManualTest test = Covid19ManualTest( + provider: _isCustomProvider? null: provider?.name, + providerId: _isCustomProvider? null: provider?.id, + location: _isCustomProvider? null: location?.name, + locationId: _isCustomProvider? null: location?.id, + countyId: countyId, + testType: testType?.name, + testResult: testResult?.name, + dateUtc: dateUtc, + image: imageBase64, + ); + + Health().processManualTest(test).then((success){ + _decreaseProgress(); + if(success) + Navigator.pop(context,"success"); + else + AppToast.show(Localization().getStringEx("panel.health.report_test.error.create.message","Unable to create test")); + }); + } + + bool _validate(){ + if(_testPhoto==null){ + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return PopupDialog(displayText: Localization().getStringEx("panel.health.report_test.missing.image.message","Please upload image"), positiveButtonText: Localization().getStringEx("dialog.ok.title","OK")); + }, + ); + return false; + } else if(_selectedDate?.isAfter(DateTime.now())?? false ){ + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return PopupDialog(displayText: Localization().getStringEx("panel.health.report_test.future_date.forbidden.message","You cannot submit a test in the future"), positiveButtonText: Localization().getStringEx("dialog.ok.title","OK")); + }, + ); + return false; + } + + return true; + } + + //Utils + Future _pickDate(DateTime date) async { + Analytics.instance.logSelect(target: "Pick Date"); + DateTime now = DateTime.now(); + DateTime firstDate = now.subtract(new Duration(days: 30)); + date = date ?? now; + DateTime initialDate = date; + if (firstDate.isAfter(date)) { + firstDate = initialDate; //Fix exception + } + + DateTime result = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: now, + builder: (BuildContext context, Widget child) { + return Theme( + data: ThemeData.light(), + child: child, + ); + }, + ); + + return result; + } + + bool get _isCustomProvider { + return widget?.provider==null; + } + + bool get _showLocationField{ + return !_isCustomProvider; // is not "Other" + } + + bool get _showResultField{ + return _selectedTestType?.type != TestDropDownItemType.other; + } + + void _increaseProgress() { + setState(() { + _loadingProgress++; + }); + } + + void _decreaseProgress() { + setState(() { + _loadingProgress--; + }); + } + + bool get _isLoading { + return (_loadingProgress > 0); + } + + Future resizeImage(File image) async{ + final int requiredBytesSize = 300000; //300k + + if(image == null) return image; + final Directory path = await getApplicationDocumentsDirectory(); + String directoryPath = path?.path; + String imagePath = directoryPath+"/upload_image.jpg"; + int originalSize = image.lengthSync(); + + if(originalSize<=requiredBytesSize) + return image; + + double reducedCoefficient = (originalSize / requiredBytesSize); //Percentage difference + double reducedQuality = 100 - reducedCoefficient; //reduces the bytes size of 1 pixel // 80 - give us 20 times smaller, 60 give us 40 times smaller + File result; + try { + result = await FlutterImageCompress.compressAndGetFile( + image.absolute.path, imagePath, + quality: reducedQuality?.toInt() ?? 90, + minHeight: 1280, + minWidth: 720 + ); + } catch(e){ + print(e); + } + print("Original Size: "+ image.lengthSync().toString()); + print("Resized Size: "+ result?.lengthSync()?.toString()); + print("Quality "+ reducedQuality?.toString()); + + return result; + } +} + +enum TestDropDownItemType{ + provider, other +} + +class TestDropDownItem{ + final TestDropDownItemType type; + final HealthTestType item; + + TestDropDownItem({this.type, this.item}); + + String get title{ + if(type == TestDropDownItemType.other){ + return Localization().getStringEx("app.common.label.other", "Other"); + } else { + return item?.name ?? ""; + } + } + + String get id{ + if(type == TestDropDownItemType.other){ + return null; + } else { + return item?.id; + } + } +} \ No newline at end of file diff --git a/lib/ui/health/Covid19StatusPanel.dart b/lib/ui/health/Covid19StatusPanel.dart new file mode 100644 index 00000000..c6d5a8de --- /dev/null +++ b/lib/ui/health/Covid19StatusPanel.dart @@ -0,0 +1,605 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_swiper/flutter_swiper.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/TransportationService.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/ui/health/Covid19InfoCenterPanel.dart'; +import 'package:illinois/ui/widgets/StatusInfoDialog.dart'; +import 'package:illinois/ui/widgets/TrianglePainter.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +class Covid19StatusPanel extends StatefulWidget { + + @override + _Covid19StatusPanelState createState() => _Covid19StatusPanelState(); +} + +class _Covid19StatusPanelState extends State implements NotificationsListener { + final double _headingH1 = 130; + final double _headingH2 = 80; + final double _photoSize = 240; + + int _loadingProgress = 0; + bool _netIdStatusChecked; + Covid19Status _covid19Status; + bool _covid19Access; + Color _colorOfTheDay; + LinkedHashMap _counties; + + MemoryImage _photoImage; + + @override + void initState() { + super.initState(); + NotificationService().subscribe(this, [ + Health.notifyStatusChanged, + Health.notifyCountyStatusAvailable, + ]); + _loadCounties(); + _loadCovidStatus(); + _loadColorOfTheDay(); + _loadPhotoBytes(); + } + + @override + void dispose() { + super.dispose(); + NotificationService().unsubscribe(this); + } + + @override + void onNotification(String name, param) { + if (name == Health.notifyStatusChanged) { + _updateCovidStatus(param); + } + else if (name == Health.notifyCountyStatusAvailable) { + _updateCovidStatus(param); + } + } + + void _loadCounties(){ + _loadingProgress++; + Health().loadCounties().then((List counties) { + if (mounted) { + setState(() { + _counties = HealthCounty.listToMap(counties); + _loadingProgress--; + }); + _checkNetIdStatus(); + } + }); + } + + void _loadCovidStatus() { + _loadingProgress++; + Health().currentCountyStatus.then((Covid19Status status) { + if (mounted) { + setState(() { + _covid19Status = status; + _loadingProgress--; + }); + _updateCovid19Access(); + _checkNetIdStatus(); + } + }); + } + + void _updateCovidStatus(Covid19Status status) { + if (mounted) { + setState(() { + _covid19Status = status; + }); + _updateCovid19Access(); + } + } + + void _updateCovid19Access() { + Health().isAccessGranted(_covid19Status?.blob?.healthStatus).then((bool granted) { + if (mounted) { + setState(() { + _covid19Access = granted; + }); + } + }); + } + + void _loadPhotoBytes() { + _loadingProgress++; + _loadAsyncPhotoBytes().whenComplete((){ + if (mounted) { + setState(() { + _loadingProgress--; + }); + _checkNetIdStatus(); + } + }); + } + + Future _loadAsyncPhotoBytes() async { + Uint8List photoBytes = await Auth().photoImageBytes; + if(AppCollection.isCollectionNotEmpty(photoBytes)){ + _photoImage = await compute(AppImage.memoryImageWithBytes, photoBytes); + } + } + + HealthCounty get _selectedCounty { + return (_counties != null) ? _counties[Health().currentCountyId] : null; + } + + void _loadColorOfTheDay() { + _loadingProgress++; + NativeCommunicator().getDeviceId().then((deviceId) { + TransportationService().loadBussColor(deviceId: deviceId, userId: User().uuid).then((color) { + if (mounted) { + setState(() { + _colorOfTheDay = color; + _loadingProgress--; + }); + _checkNetIdStatus(); + } + }); + }); + } + + void _checkNetIdStatus() { + if ((_loadingProgress == 0) && (_netIdStatusChecked != true)) { + _netIdStatusChecked = true; + if (Auth().isShibbolethLoggedIn && (Auth().authCard?.photoBase64?.length ?? 0) == 0) { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.covid19_passport.message.missing_id_info', 'No Illini ID information found. You may have an expired i-card. Please contact the ID Center.')); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + Column( + children: [ + Container( + height: _headingH1, + color: _colorOfTheDay, + ), + Container( + height: _headingH2, + color: _colorOfTheDay, + child: CustomPaint( + painter: TrianglePainter(painterColor: _backgroundColor), + child: Container(), + ), + ), + Expanded(child: Container(color: _backgroundColor,)) + ], + ), + Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Expanded(child: _userDetails()), + Container( + child: SafeArea( + bottom: true, + child: + Padding( + padding: EdgeInsets.only(bottom: 10), + child:Semantics(button: true,label: Localization().getStringEx("panel.covid19_passport.button.close.title", "Close"), child: + InkWell( + onTap: _onTapClose, + child: Image.asset('images/close-orange-large.png', excludeFromSemantics: true,) + ), + ))), + ), + ]), + SafeArea( + child: Stack( + children: [ + Padding( + padding: EdgeInsets.all(16), + child:Semantics(header: true, child: Text( + Localization().getStringEx("panel.covid19_passport.header.title", "COVID-19"), + style: TextStyle( + color: Styles().colors.white, fontFamily: Styles().fontFamilies.extraBold, fontSize: 16, + shadows: [ + Shadow( + offset: Offset(2, 2), + blurRadius: 4.0, + color: Styles().colors.blackTransparent018, + ) + ] + ),), + )), + Align( + alignment: Alignment.topRight, + child:Semantics(button: true,label: Localization().getStringEx("panel.covid19_passport.button.close.title", "Close"), child: + InkWell( + onTap: _onTapClose, child: Container(width: 48, height: 48, alignment: Alignment.center, child: Image.asset('images/close-white-shadow.png', excludeFromSemantics: true,))), + ) + ), + ], + ), + ), + Visibility(visible: _isLoading, child: Container(width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, color: Styles().colors.fillColorPrimaryTransparent09, + child: Center(child: CircularProgressIndicator(),),),) + ], + ), + ); + } + + Widget _userDetails() { + String userFullName = AppString.getDefaultEmptyString(value: Auth()?.userPiiData?.fullName); + + return SingleChildScrollView(scrollDirection: Axis.vertical, child: + Column(crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _userAvatar(), + Padding(padding: EdgeInsets.only(top: 8, bottom: 1), child: Text( + userFullName, + style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 24, color: Styles().colors.fillColorPrimary), + ),), + Padding(padding: EdgeInsets.only(bottom: 10), + child: Text(AppString.getDefaultEmptyString(value: _userRoleString), + style: TextStyle(color: Styles().colors.mediumGray1, fontSize: 16, fontFamily: Styles().fontFamilies.regular, letterSpacing: 1),),), + _buildCountyDropdown(), + _buildStatusDetails(), + SmallRoundedButton( + label: Localization().getStringEx('panel.covid19_passport.button.info_center.title', 'Your COVID-19 info center'), + borderColor: Styles().colors.fillColorSecondary, + showChevron: true, + onTap: _onTapInfoCenter,) + ], + ) + ); + } + + Widget _buildStatusDetails(){ + return Container( + child: + Column(children: [ + SizedBox( + width: 300, + height: 240, + child: + Swiper( + containerHeight: 240, // Distance from SwiperIndicator + itemHeight: 200, + itemCount: 2, + pagination:SwiperCustomPagination( + builder:(BuildContext context, SwiperPluginConfig config){ + return Container(padding: EdgeInsets.only(top: 200),child:_buildPageIndicator(config.activeIndex)); + }), + itemBuilder: (BuildContext context, int index) { + if(0==index){ + return _buildAccesLayout(); + } else if(1== index){ + return _buildQrCode(); + } + return Container(); + }, + ) + ), + Container(height: 10,), +// _buildPageIndicator() + ],), + ); + } + + + Widget _buildPageIndicator(int index){ + return + Container( + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 12, + width: 12, + decoration: BoxDecoration( + color: 0==index? Styles().colors.fillColorSecondary : Styles().colors.background, + borderRadius: BorderRadius.all(Radius.circular(100)), + border: Border.all(color: Styles().colors.fillColorSecondary, width: 2), + ), + ), + Container(width: 8,), + Container( + height: 12, + width: 12, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(100)), + color: 1==index? Styles().colors.fillColorSecondary : Styles().colors.background, + border: Border.all(color: Styles().colors.fillColorSecondary, width: 2), + ), + ), + ],), + ); + } + + Widget _buildAccesLayout() { + String imageAsset = (_covid19Access == true) ? 'images/group-20.png' : 'images/group-28.png'; + String accessText = ''; + switch (_covid19Access) { + case true: accessText = Localization().getStringEx("panel.covid19_passport.label.access.granted","GRANTED"); break; + case false: accessText = Localization().getStringEx("panel.covid19_passport.label.access.denied","DENIED"); break; + } + return Container( + child: Column(children: [ + Container(height: 15,), + Image.asset(imageAsset, excludeFromSemantics: true,), + Container(height: 7,), + Text(Localization().getStringEx("panel.covid19_passport.label.access.heading","Building Access"), + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Styles().colors.fillColorPrimary),), + Container(height: 6,), + Text(accessText, style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 28, color: Styles().colors.fillColorPrimary),), + ],), + ); + } + + Widget _buildQrCode(){ + String healthStatus = _covid19Status?.blob?.healthStatus; + String statusName = _covid19Status?.blob?.localizedHealthStatus ?? ''; + bool userHasHealthStatus = (healthStatus != null); + Color statusColor = (userHasHealthStatus ? (covid19HealthStatusColor(healthStatus) ?? _backgroundColor) : _backgroundColor); + String authCardOrPhone = Auth().isShibbolethLoggedIn + ? Auth().authCard?.magTrack2 ?? "" + : (Auth().isPhoneLoggedIn ? Auth().userPiiData?.phone : ""); + String textAuthCardOrPhone = Auth().isShibbolethLoggedIn + ? "" + : (Auth().isPhoneLoggedIn ? Auth().userPiiData?.phone : ""); + String noStatusDescription = (_counties?.isNotEmpty ?? false) ? + Localization().getStringEx('panel.covid19_passport.label.status.empty', "No available status for this County") : + Localization().getStringEx('panel.covid19_passport.label.counties.empty', "No counties available"); + String qrCodeImageData = AppString.getDefaultEmptyString(value: authCardOrPhone, defaultValue: User().uuid); + return Column(children: [ + Visibility( + visible: userHasHealthStatus, + child: Container( + width: 176, + height: 176, + padding: EdgeInsets.all(13), + decoration: BoxDecoration( + color: statusColor, borderRadius: BorderRadius.circular(4)), + child: Container(decoration: BoxDecoration( + color: Styles().colors.white, borderRadius: BorderRadius.circular(4)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Visibility(visible: AppString.isStringNotEmpty(qrCodeImageData), child: QrImage( + data: AppString.getDefaultEmptyString(value: qrCodeImageData), + version: QrVersions.auto, + size: MediaQuery.of(context).size.width / 4 + 10, + padding: EdgeInsets.all(5),),), + Visibility(visible: AppString.isStringNotEmpty(textAuthCardOrPhone), child: Padding(padding: EdgeInsets.only(top: 5), + child: Text( + AppString.getDefaultEmptyString(value: textAuthCardOrPhone), + style: TextStyle(color: Colors.black, fontSize: 12, fontFamily: Styles().fontFamilies.regular),),),), + ],),),),), + userHasHealthStatus ? + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(statusName, style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Styles().colors.textSurface),), + Container(width: 6,), + IconButton(icon: Image.asset('images/icon-info-orange.png'), onPressed: () => StatusInfoDialog.show(context, _selectedCounty?.nameDisplayText ?? ""), padding: EdgeInsets.all(10),) + ],)): + Container( + padding: EdgeInsets.only(bottom: 8), + child: Text(noStatusDescription, style:TextStyle(color: Colors.black, fontSize: 18, fontFamily: Styles().fontFamilies.regular)), + ), + + ],); + } + + Widget _buildCountyDropdown(){ + return Visibility(visible: _counties?.isNotEmpty ?? false, + child: Padding(padding: EdgeInsets.symmetric(horizontal: 32, vertical: 0), + child: Column(crossAxisAlignment:CrossAxisAlignment.center, children: [ + Semantics(container: true, child: + Padding(padding: EdgeInsets.only(bottom: 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, children: [ + Container( + child: Padding(padding: EdgeInsets.only(left: 12, right: 16), + child: DropdownButtonHideUnderline( + child:DropdownButton( + icon: Icon(Icons.arrow_drop_down, color:Styles().colors.fillColorPrimary, semanticLabel: null,), + isExpanded: false, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary,), + hint: Text(_selectedCounty?.nameDisplayText ?? Localization().getStringEx('panel.covid19_passport.label.county.empty.hint',"Select a county..."), + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary,),), + items: _buildCountyDropdownItems(), + onChanged: (value) { _switchCounty(value); }, + ) + ) + ), + ) + ],), + ), + ), + Container(height: 12,) + ]))); + } + + List _buildCountyDropdownItems(){ + List result; + if (_counties?.isNotEmpty ?? false) { + result = List (); + for (HealthCounty county in _counties.values) { + result.add(DropdownMenuItem( + value: county.id, + child: Text(county.nameDisplayText), + )); + } + } + return result; + } + + String get _userRoleString { // Simplified - show resident for the rest of the situations + if(Auth().isShibbolethLoggedIn && AppString.isStringNotEmpty(Auth()?.authCard?.role)){ + return Auth()?.authCard?.role; + } + return UserRole.toRoleString(UserRole.resident); + } + + Widget _userAvatar() { + return Padding( + padding: EdgeInsets.only(top: _headingH1 + (_headingH2 - _photoSize) / 2), + child: _RotatingBorder( + activeColor: _colorOfTheDay ?? Colors.transparent, + child: Padding( + padding: EdgeInsets.all(16), + child: _userPhotoImage() + )), + ); + } + + Widget _userPhotoImage() { + if (_photoImage != null) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + image: DecorationImage( + fit: BoxFit.cover, + alignment: Alignment.center, + image: _photoImage, + ), + )); + } else { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Styles().colors.fillColorPrimary, + image: DecorationImage(image: ExactAssetImage('images/3.0x/icon-avatar-placeholder.png'), fit: BoxFit.cover) + )); + } + } + + void _switchCounty(String countyId) { + setState(() { + _loadingProgress++; + }); + Health().switchCounty(countyId).then((Covid19Status status) { + if (mounted) { + setState(() { + _loadingProgress--; + if (status != null) { + _covid19Status = status; + } + }); + _updateCovid19Access(); + } + }); + } + + void _onTapClose() { + Navigator.of(context).pop(); + } + + void _onTapInfoCenter() { + Analytics.instance.logSelect(target: "COVID19 Info Center"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19InfoCenterPanel(status: _covid19Status))); + } + + Color get _backgroundColor { + return Styles().colors.background; + } + + bool get _isLoading { + return (_loadingProgress > 0); + } +} + +class _RotatingBorder extends StatefulWidget{ + final Widget child; + final Color activeColor; + final Color baseGradientColor; + const _RotatingBorder({Key key, this.child, this.activeColor, this.baseGradientColor}) : super(key: key); + + @override + _RotatingBorderState createState() => _RotatingBorderState(); + +} + +class _RotatingBorderState extends State<_RotatingBorder> + with SingleTickerProviderStateMixin{ + final double _photoSize = 240; + Animation animation; + AnimationController controller; + + @override + void initState() { + super.initState(); + controller = AnimationController(vsync: this, duration: Duration(hours: 1),animationBehavior: AnimationBehavior.preserve); + animation = Tween(begin: 0, end: 15000,).animate(controller) + ..addListener(() { + setState(() {}); + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + controller.repeat().orCancel; + + } else if (status == AnimationStatus.dismissed) { + controller.forward(); + } + }); + controller.forward(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + double angle = animation.value; + return Container( width: _photoSize, height: _photoSize, + child:Stack(children: [ + Transform.rotate( + angle: angle, + child:Container( + height: _photoSize, + width: _photoSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [widget.activeColor, widget.baseGradientColor ?? Styles().colors.fillColorSecondary], + stops: [0.0, 1.0], + ) + ), + )), + widget.child, + ], )); + } + +} \ No newline at end of file diff --git a/lib/ui/health/Covid19StatusUpdatePanel.dart b/lib/ui/health/Covid19StatusUpdatePanel.dart new file mode 100644 index 00000000..7d163178 --- /dev/null +++ b/lib/ui/health/Covid19StatusUpdatePanel.dart @@ -0,0 +1,308 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:collection'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/health/Covid19NextStepsPanel.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/StatusInfoDialog.dart'; + +class Covid19StatusUpdatePanel extends StatefulWidget { + final Covid19Status status; + final String previousHealthStatus; + + Covid19StatusUpdatePanel({this.status, this.previousHealthStatus} ); + + @override + _Covid19StatusUpdatePanelState createState() => _Covid19StatusUpdatePanelState(); +} + +class _Covid19StatusUpdatePanelState extends State { + bool _loading = false; + + String _updateDate; + + String _oldStatusType; + String _oldStatusDescription; + Color _oldSatusColor; + + String _newStatusType; + String _newStatusDescription; + Color _newSatusColor; + + LinkedHashMap _counties; + String _currentCountyName; + + @override + void initState() { + //_updateStatus(); + _updateDate = (widget.status?.dateUtc != null) ? AppDateTime().formatDateTime(widget.status.dateUtc, format:"MMMM dd, yyyy") : ''; + + _oldStatusType = Covid19StatusBlob.localizedHealthStatusTypeFromKey(widget.previousHealthStatus) ?? ''; + _oldStatusDescription = Covid19StatusBlob.localizedHealthStatusDescriptionFromKey(widget.previousHealthStatus) ?? ''; + _oldSatusColor = covid19HealthStatusColor(widget.previousHealthStatus) ?? Styles().colors.mediumGray; + + _newStatusType = widget?.status?.blob?.localizedHealthStatusType ?? ''; + _newStatusDescription = widget?.status?.blob?.localizedHealthStatusDescription ?? ''; + _newSatusColor = covid19HealthStatusColor(widget.status?.blob?.healthStatus) ?? Styles().colors.mediumGray; + + _loadCovidCounties(); + super.initState(); + } + + void _loadCovidCounties(){ + setState(() { + _loading = true; + }); + Health().loadCounties().then((List counties) { + _counties = HealthCounty.listToMap(counties); + if(_counties != null && _counties.containsKey(Health().currentCountyId)) { + _currentCountyName = _counties[Health().currentCountyId].nameDisplayText; + } + }).whenComplete((){ + setState(() { + _loading = false; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.fillColorPrimary, + body:Container( + child: Column( + children: [ + Expanded( + child: _loading? _buildLoading() : _buildContent(), + ), +// _buildPageIndicator(), + Container( + padding: EdgeInsets.symmetric(vertical: 20, horizontal: 16), + child: RoundedButton( + label: Localization().getStringEx("panel.health.status_update.button.continue.title","See Next Steps"), + backgroundColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.white, + onTap:(){ + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19NextStepsPanel(status: widget.status,))).then((dynamic){Navigator.pop(context);}); + }, + ), + ) + ], + ), + ) + ); + } + + Widget _buildContent(){ + String county = "$_currentCountyName ${Localization().getStringEx("app.common.label.county", "County")}"; + return SingleChildScrollView( + child: Column(children: [ + Container(height: 90,), + Text(_updateDate,textAlign: TextAlign.center,style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.regular),), + Container(height: 12,), + Text(Localization().getStringEx("panel.health.status_update.heading.title","Status Update"),textAlign: TextAlign.center,style: TextStyle(color: Colors.white, fontSize: 28, fontFamily: Styles().fontFamilies.regular),), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(county,textAlign: TextAlign.center,style: TextStyle(color: Colors.white, fontSize: 14, fontFamily: Styles().fontFamilies.regular),), + IconButton(icon: Image.asset('images/icon-info-orange.png'), onPressed: () => StatusInfoDialog.show(context, _currentCountyName), padding: EdgeInsets.all(10),) + ], + ), + Container(height: 25,), + Container(child:Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + CircleAvatar(child: Image.asset("images/icon-avatar-placeholder.png", excludeFromSemantics: true,),backgroundColor: _oldSatusColor, radius: 32,), + Container(height: 8,), + Text(_oldStatusType,style:TextStyle(color: Styles().colors.white, fontSize: 14, fontFamily: Styles().fontFamilies.regular)), + Text(_oldStatusDescription,style:TextStyle(color: Styles().colors.white, fontSize: 14, fontFamily: Styles().fontFamilies.bold)), + ], + ), + Container(width: 25,), + Container(margin: EdgeInsets.only(top: 20), width: 16,child:Image.asset("images/icon-white-arrow-right.png", excludeFromSemantics: true)), + Container(width: 25,), + Column( + children: [ + CircleAvatar(child: Image.asset("images/icon-avatar-placeholder.png", excludeFromSemantics: true),backgroundColor: _newSatusColor, radius: 32,), + Container(height: 8 ,), + Text(_newStatusType,style:TextStyle(color: Styles().colors.white, fontSize: 14, fontFamily: Styles().fontFamilies.regular)), + Text(_newStatusDescription,style:TextStyle(color: Styles().colors.white, fontSize: 14, fontFamily: Styles().fontFamilies.bold)), + ], + ), + ],)), + Container(height: 24,), + Container( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Container(height: 1, color: Styles().colors.surfaceAccent ,), + ), + _buildReasonContent(), + ],), + ); + } + + Widget _buildReasonContent(){ + String date = AppDateTime().formatUniLocalTimeFromUtcTime(widget.status?.dateUtc, AppDateTime.covid19UpdateDateFormat); + String reasonStatusText = widget.status?.blob?.reason; + + Covid19HistoryBlob reasonHistory = widget.status?.blob?.historyBlob; + String reasonHistoryName; + Widget reasonHistoryDetail; + if (reasonHistory != null) { + if (reasonHistory.isTest) { + reasonHistoryName = reasonHistory.testType; + + reasonHistoryDetail = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("images/icon-selected.png",excludeFromSemantics: true,), + Container(width: 7,), + Text(Localization().getStringEx("panel.health.status_update.label.reason.result", "Result:"), style: TextStyle(color: Colors.white, fontSize: 12, fontFamily: Styles().fontFamilies.bold)), + Container(width: 5,), + Text(reasonHistory.testResult ?? '', style: TextStyle(color: Colors.white, fontSize: 12, fontFamily: Styles().fontFamilies.regular)), + ],); + + } + else if (reasonHistory.isSymptoms) { + reasonHistoryName = Localization().getStringEx("panel.health.status_update.label.reason.symptoms.title", "You reported new symptoms"); + + List symptomLayouts = List(); + List symptoms = reasonHistory.symptoms; + if (symptoms?.isNotEmpty ?? false) { + symptoms.forEach((HealthSymptom symptom){ + symptomLayouts.add(Text(symptom?.name ?? "", style: TextStyle(color: Colors.white, fontSize: 14, fontFamily: Styles().fontFamilies.regular))); + }); + } + + reasonHistoryDetail = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: symptomLayouts, + ); + } + else if (reasonHistory.isContactTrace) { + reasonHistoryName = Localization().getStringEx("panel.health.status_update.label.reason.exposed.title","You were exposed to someone who was likely infected"); + + reasonHistoryDetail = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("images/icon-selected.png",excludeFromSemantics: true,), + Container(width: 7,), + Text(Localization().getStringEx("panel.health.status_update.label.reason.exposure.detail","Duration of exposure: "), style: TextStyle(color: Colors.white, fontSize: 12, fontFamily: Styles().fontFamilies.bold)), + Container(width: 5,), + Text(reasonHistory.traceDurationDisplayString ?? "", style: TextStyle(color: Colors.white, fontSize: 12, fontFamily: Styles().fontFamilies.regular)), + ],); + } + else if (reasonHistory.isAction) { + reasonHistoryName = Localization().getStringEx("panel.health.status_update.label.reason.action.title", "You were required an action by health authorities"); + + reasonHistoryDetail = Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("images/icon-selected.png",excludeFromSemantics: true,), + Container(width: 7,), + Text(Localization().getStringEx("panel.health.status_update.label.reason.action.detail", "Action Required: "), style: TextStyle(color: Colors.white, fontSize: 12, fontFamily: Styles().fontFamilies.bold)), + ],), + Text(reasonHistory.actionDisplayString ?? "", style: TextStyle(color: Colors.white, fontSize: 12, fontFamily: Styles().fontFamilies.regular)), + + + ],); + + } + } + + if ((reasonStatusText != null) || (reasonHistoryName != null) || (reasonHistoryDetail != null)) { + List content = [ + Container(height: 30,), + Text(Localization().getStringEx("panel.health.status_update.label.reason.title", "STATUS CHANGED BECAUSE:"), textAlign: TextAlign.center, style: TextStyle(color: Colors.white, fontSize: 12, fontFamily: Styles().fontFamilies.bold),), + Container(height: 30,), + ]; + + if (date != null) { + content.addAll([ + Text(date, textAlign: TextAlign.center,style: TextStyle(color: Colors.white, fontSize: 12, fontFamily: Styles().fontFamilies.bold),), + Container(height: 2,), + ]); + } + + if (reasonHistoryName != null) { + content.addAll([ + Text(reasonHistoryName,textAlign: TextAlign.center,style: TextStyle(color: Colors.white, fontSize: 18, fontFamily: Styles().fontFamilies.extraBold),), + Container(height: 9,), + ]); + } + + if (reasonHistoryDetail != null) { + content.addAll([ + reasonHistoryDetail, + ]); + } + + if (reasonStatusText != null) { + content.addAll([ + Container(height: 60,), + Text(reasonStatusText, textAlign: TextAlign.center, style: TextStyle(color: Colors.white, fontSize: 14, fontFamily: Styles().fontFamilies.bold),), + Container(height: 30,), + ]); + } + + return Container( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 48), + child: Column(children: content,), + ), + ); + } + + return Container(); + } + + Widget _buildLoading(){ + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 24), + alignment:Alignment.center,child: + Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(Localization().getStringEx("panel.health.status_update.label.loading","Hang tight while we update your status"),textAlign: TextAlign.center,style: TextStyle(color: Colors.white, fontSize: 28, fontFamily: Styles().fontFamilies.bold),), + Container(height: 23,), + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 3,), + Container(height: 48,) + ],) + ) + ) + ]); + } +} \ No newline at end of file diff --git a/lib/ui/health/Covid19SymptomsPanel.dart b/lib/ui/health/Covid19SymptomsPanel.dart new file mode 100644 index 00000000..d850dc7a --- /dev/null +++ b/lib/ui/health/Covid19SymptomsPanel.dart @@ -0,0 +1,255 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + +class Covid19SymptomsPanel extends StatefulWidget { + + Covid19SymptomsPanel({Key key}) : super(key: key); + + @override + _Covid19SymptomsPanelState createState() => _Covid19SymptomsPanelState(); +} + +class _Covid19SymptomsPanelState extends State { + + List _symptomsGroups; + Set _selectedSymptoms = Set(); + bool _loadingSymptoms; + bool _submittingSymptoms; + + @override + void initState() { + super.initState(); + _loadingSymptoms = true; + Health().loadSymptomsGroups().then((List groups) { + setState(() { + _loadingSymptoms = false; + _symptomsGroups = groups; + }); + }); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + String title = Localization().getStringEx("panel.health.symptoms.heading.title","Are you experiencing any of these symptoms?"); + return Scaffold(backgroundColor: Styles().colors.background, + body:SafeArea( + child: Column(children: [ + Stack(children: [ + Align(alignment: Alignment.topLeft, + child: OnboardingBackButton(image: 'images/chevron-left-blue.png', padding: EdgeInsets.only(left: 4, top: 16, right: 20, bottom: 20), onTap: () => _goBack()), + ), + Align(alignment: Alignment.topCenter, + child: Padding(padding: EdgeInsets.only(left: 64, right: 64, bottom: 16, top: 20), + child: Text(title, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary),), + ), + ), + ],), + + Expanded( + child: SingleChildScrollView( + child: Padding(padding: EdgeInsets.all(24), + child: Column( + children: _buildContent() + ), + ), + ), + ), + ]), + ), + ); + } + + List _buildContent() { + if (_loadingSymptoms == true) { + return _buildLoadingContent(); + } + else if (_symptomsCount == 0) { + return _buildStatusContent(Localization().getStringEx("panel.health.symptoms.label.error.loading","Failed to load symptoms.")); + } + else { + return _buildSymptomsContent(); + } + } + + List _buildSymptomsContent() { + List result = []; + if (_symptomsGroups != null) { + for (HealthSymptomsGroup group in _symptomsGroups) { + result.addAll(_buildGroup(group)); + } + } + if (0 < result.length) { + result.add(_bulldSubmit()); + } + return result; + } + + List _buildGroup(HealthSymptomsGroup group) { + List result = []; + if (group.symptoms != null) { + for (HealthSymptom symptom in group.symptoms) { + result.addAll(_buildSymptom(symptom)); + } + } + return result; + } + + List _buildSymptom(HealthSymptom symptom) { + bool _selected = _selectedSymptoms.contains(symptom.id); + String imageName = _selected ? 'images/icon-selected-checkbox.png' : 'images/icon-deselected-checkbox.png'; + return [ + Semantics( + label: symptom.name, + value: (_selected?Localization().getStringEx("toggle_button.status.checked", "checked",) : + Localization().getStringEx("toggle_button.status.unchecked", "unchecked")) + + ", "+ Localization().getStringEx("toggle_button.status.checkbox", "checkbox"), + button:true, + excludeSemantics: true, + child: Padding(padding: EdgeInsets.only(bottom: 10), child: + InkWell(onTap: () => _onTapSymptom(symptom), child: + Container(padding: EdgeInsets.all(16), color: Colors.white, child: + Row(children: [ + Image.asset(imageName), + Expanded(child: + Padding(padding: EdgeInsets.only(left: 16), child: + Text(symptom.name, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ), + ), + ],) + ), + ), + )), + ]; + } + + Widget _bulldSubmit() { + bool enabled = (0 < _selectedSymptoms.length); + return Padding(padding: EdgeInsets.only(top: 20, bottom: 20), child: + Stack(children: [ + RoundedButton(label: Localization().getStringEx("panel.health.symptoms.button.submit.title","Submit"), + backgroundColor: enabled ? Styles().colors.white : Styles().colors.whiteTransparent01, + textColor: enabled ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColorTwo, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + padding: EdgeInsets.symmetric(horizontal: 32, ), + borderColor: enabled ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColorTwo, + borderWidth: 2, + height: 48, + onTap:() { _onSubmit(); } + ), + Visibility(visible: (_submittingSymptoms == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 12), child: + Container(width: 24, height:24, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ],) + ); + } + + List _buildLoadingContent() { + return [ + Padding(padding:EdgeInsets.symmetric(vertical: 200), child: + Align(alignment: Alignment.center, child: + Container(width: 42, height: 42, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary), strokeWidth: 2,) + ), + ), + )]; + } + + List _buildStatusContent(String text) { + return [Padding(padding: EdgeInsets.only(left: 32, right:32, top: 200), + child:Align(alignment: Alignment.center, child: + Text(text, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ), + )]; + } + + int get _symptomsCount { + int count = 0; + if (_symptomsGroups != null) { + for (HealthSymptomsGroup group in _symptomsGroups) { + count += (group.symptoms?.length ?? 0); + } + } + return count; + } + + void _goBack() { + Analytics.instance.logSelect(target: 'Back'); + Navigator.of(context).pop(); + } + + void _onTapSymptom(HealthSymptom symptom) { + setState(() { + if (_selectedSymptoms.contains(symptom.id)) { + _selectedSymptoms.remove(symptom.id); + } + else { + _selectedSymptoms.add(symptom.id); + } + AppSemantics.announceCheckBoxStateChange(context, _selectedSymptoms?.contains(symptom.id), symptom.name); + }); + } + + void _onSubmit() { + Analytics.instance.logSelect(target: "Submit"); + if (_submittingSymptoms == true) { + return; + } + setState(() { + _submittingSymptoms = true; + }); + + Health().processSymptoms(groups: _symptomsGroups, selected: _selectedSymptoms).then((dynamic result) { + if (mounted) { + setState(() { + _submittingSymptoms = false; + }); + if (result == null) { + AppAlert.showDialogResult(context, Localization().getStringEx("panel.health.symptoms.label.error.submit", "Failed to submit symptoms.")); + } + else if (result is Covid19History) { + AppAlert.showDialogResult(context,Localization().getStringEx("panel.health.symptoms.label.success.submit.message", "Your symptoms have been processed.")).then((_){ + Navigator.of(context).pop(); + }); + } + else if (result is Covid19Status) { + Navigator.of(context).pop(); // pop immidiately as status update panel will be pushed. + } + } + }); + } +} \ No newline at end of file diff --git a/lib/ui/health/Covid19TestLocations.dart b/lib/ui/health/Covid19TestLocations.dart new file mode 100644 index 00000000..1ad10333 --- /dev/null +++ b/lib/ui/health/Covid19TestLocations.dart @@ -0,0 +1,559 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:collection'; + +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:location/location.dart' as Core; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/service/LocationServices.dart'; + +class Covid19TestLocationsPanel extends StatefulWidget { + _Covid19TestLocationsPanelState createState() => _Covid19TestLocationsPanelState(); +} + +class _Covid19TestLocationsPanelState extends State{ + + LinkedHashMap _counties; + List _providerItems; + ProviderDropDownItem _selectedProviderItem; + String _initialProviderId; + + + Core.LocationData _locationData; + LocationServicesStatus _locationServicesStatus; + + bool _isLoading = false; + List _locations; + + @override + void initState() { + _initialProviderId = Storage().lastHealthProvider?.id; + _loadLocationsServicesData(); + _loadCounties(); + + if (Health().currentCountyId != null) { + _loadProviders(); + } else { + _loadLocations(); //load all by default + } + + super.initState(); + } + + _loadLocations(){ + _isLoading = true; + Health().loadHealthServiceLocations(countyId: Health().currentCountyId, providerId: _selectedProviderItem?.providerId).then((List locations){ + setState(() { + try { + _locations = locations; + } catch(e){ + print(e); + } + _isLoading = false; + }); + }); + + } + + @override + Widget build(BuildContext context) { + int itemsLength = _locations?.length ?? 0; + itemsLength+= 2; // for dropdowns + return Scaffold( + backgroundColor: Styles().colors.background, + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.covid19_test_locations.header.title", "Test locations"), + style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold), + ), + ), + body: _isLoading + ? Center(child: CircularProgressIndicator(),) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: itemsLength>0? ListView.builder( + itemCount: itemsLength, + itemBuilder: (BuildContext context, int index) { + if(index == 0) + return _buildCountyField(); + if(index == 1) + return _buildProviderField(); + + index -= 2; //for dropdowns + HealthServiceLocation location = (_locations?.isNotEmpty ?? false)? _locations[index] : null; + //double distance = _locationData!=null && location!=null? AppLocation.distance(location.latitude, location.longitude, _locationData.latitude, _locationData.longitude) : 0; + return _TestLocation(testLocation: location, /*distance: distance,*/); + }, + ) : Container(), + ), + ); + } + + void _sortLocations() async{ + _locationData = _userLocationEnabled ? await LocationServices.instance.location : null; + if((_locations?.isNotEmpty?? false) && _locationData!=null){ + _locations.sort((fistLocation, secondLocation) { + double firstDistance = AppLocation.distance(fistLocation.latitude, fistLocation.longitude, _locationData.latitude, _locationData.longitude); + double secondDistance = AppLocation.distance(secondLocation.latitude, secondLocation.longitude, _locationData.latitude, _locationData.longitude); + return (firstDistance - secondDistance)?.toInt(); + }); + setState(() {}); + } + } + + void _loadLocationsServicesData(){ + + if (User().privacyMatch(2)) { + LocationServices.instance.status.then((LocationServicesStatus locationServicesStatus) { + _locationServicesStatus = locationServicesStatus; + + if (_locationServicesStatus == LocationServicesStatus.PermissionNotDetermined) { + LocationServices.instance.requestPermission().then((LocationServicesStatus locationServicesStatus) { + _locationServicesStatus = locationServicesStatus; + _sortLocations(); + }); + } else { + _sortLocations(); + } + }); + } else { + _sortLocations(); + } + } + + Widget _buildCountyField(){ + return Semantics(label: "County dropdown", container: true, child: + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 8,), + _dropDownMenu("Select County…",_counties!=null? _counties[Health().currentCountyId]: null, _counties?.values, + onChanged: (HealthCounty selectedValue) { + Health().switchCounty(selectedValue.id); + //_selectedCountyId = Storage().currentHealthCountyId = selectedValue.id; + _loadProviders(); + }, + getLabel: (HealthCounty county){ + return "${county?.name} County"; + } + ), + Container(height: 26,) + ],), + )); + } + + Widget _buildProviderField(){ + return Semantics(label: "Provider dropdown", container: true, child: + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 8,), + Container( + decoration: BoxDecoration( + border: Border.all( + color: Styles().colors.surfaceAccent, + width: 1), + borderRadius: + BorderRadius.all(Radius.circular(4))), + child: Padding( + padding: EdgeInsets.only(left: 12, right: 16), + child: DropdownButtonHideUnderline( + child: DropdownButton( + icon: Image.asset( + 'images/icon-down-orange.png', excludeFromSemantics: true,), + isExpanded: true, + style: TextStyle( + color: Styles().colors.mediumGray, + fontSize: 16, + fontFamily: + Styles().fontFamilies.regular), + hint: Text(_selectedProviderItem == null? Localization().getStringEx("panel.health.covid19.add_test.label.provider.empty_hint","Select a provider") : + _selectedProviderItem.title), + items: _buildProviderDropDownItems(), + onChanged: (ProviderDropDownItem value)=>setState((){ + Analytics.instance.logSelect(target: "Selected provider: "+value?.title); + _selectedProviderItem = value; + if(value!= null && ProviderDropDownItemType.provider == value.type ){ + Storage().lastHealthProvider = value.provider; + } + _loadLocations(); + }), + )), + ), + ), + Container(height: 26,) + ],), + )); + } + + List> _buildProviderDropDownItems() { + int itemsCount = _providerItems?.length ?? 0; + if (itemsCount == 0) { + return null; + } + List> items = List>(); + + items.addAll(_providerItems.map((ProviderDropDownItem providerItem){ + return DropdownMenuItem( + value: providerItem, + child: Text(providerItem?.title), + ); + })?.toList()); + + return items; + } + + + Widget _dropDownMenu(String hint, dynamic selectedValue, Iterable values ,{Function onChanged, Function getLabel, bool enabled=true}){ + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Styles().colors.surfaceAccent, + width: 1), + borderRadius: + BorderRadius.all(Radius.circular(4))), + child: Padding( + padding: + EdgeInsets.only(left: 12, right: 16), + child: DropdownButtonHideUnderline( + child: DropdownButton( + + icon: enabled? Image.asset( + 'images/icon-down-orange.png', excludeFromSemantics: true,) : Container(), + isExpanded: true, + style: TextStyle( + color: Styles().colors.mediumGray, + fontSize: 16, + fontFamily: + Styles().fontFamilies.regular), + hint: Text(selectedValue!=null ? _getLabel(selectedValue, getLabel) : hint), + items: enabled?_buildDropDownItems(values, getLabel): null, + onChanged: (value)=>setState(()=>onChanged(value)), + )), + ), + ); + } + + List> _buildDropDownItems(Iterable items, Function getLabel) { + int itemsCount = items?.length ?? 0; + if (itemsCount == 0) { + return null; + } + return items.map((dynamic item) { + return DropdownMenuItem( + value: item, + child: Text( + _getLabel(item, getLabel) + ), + ); + }).toList(); + } + + String _getLabel(dynamic item ,Function getLabel){ + return getLabel!=null? + getLabel(item) : item?.toString()??""; + } + + //loading + void _loadCounties(){ + setState(()=> _isLoading = true); + Health().loadCounties().then((List counties) { + if (counties != null) { + _counties = LinkedHashMap(); + for (HealthCounty county in counties) { + _counties[county.id] = county; + } + } + setState(()=> _isLoading = false); + }); + } + + void _loadProviders(){ + setState(()=> _isLoading = true); + Health().loadHealthServiceProviders().then((List providers){ + _isLoading = false; + _providerItems = List(); + ProviderDropDownItem allProvidersItem = ProviderDropDownItem(type: ProviderDropDownItemType.all, provider: null); + _providerItems.add(allProvidersItem); + if(_selectedProviderItem==null && _initialProviderId == null){ + //If there was no previously selected provider select All by default + _selectedProviderItem = allProvidersItem; + } + if(providers?.isNotEmpty?? false) { + _providerItems.addAll(providers?.map((HealthServiceProvider provider) { + ProviderDropDownItem item = ProviderDropDownItem(type: ProviderDropDownItemType.provider, provider: provider); + //Initial selection + if (_selectedProviderItem == null && _initialProviderId != null) { + // If we don't have selection but have previously selected providerId + if (provider?.id == _initialProviderId) { + _selectedProviderItem = item; + } + } + return item; + })?.toList()); + } + setState((){}); + _loadLocations(); + }); + } + + bool get _userLocationEnabled { + return User().privacyMatch(2) && (_locationServicesStatus == LocationServicesStatus.PermissionAllowed); + } +} + +class _TestLocation extends StatelessWidget{ + final HealthServiceLocation testLocation; + final double distance; + + _TestLocation({this.testLocation, this.distance = 0}); + + @override + Widget build(BuildContext context) { + + String distanceSufix = Localization().getStringEx("panel.covid19_test_locations.distance.text","mi away get directions"); + String distanceText = distance?.toStringAsFixed(2); + return + Semantics(button: false, container: true, child: + Container( + margin: EdgeInsets.only(top: 8, bottom: 8), + padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16), + decoration: BoxDecoration( + color: Styles().colors.surface, + borderRadius: BorderRadius.all(Radius.circular(4)), + boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(2, 2))] + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + testLocation?.name ?? "", + style: TextStyle( + fontFamily: Styles().fontFamilies.extraBold, + fontSize: 20, + color: Styles().colors.fillColorPrimary, + ), + ), + Semantics(button: true, + child: Container( + padding: EdgeInsets.only(top: 8, bottom: 4), + child: Row( + children: [ + Image.asset('images/icon-location.png',excludeFromSemantics: true), + Container(width: 8,), + Expanded(child: + Text( + distance>0? '$distanceText' + distanceSufix: + (testLocation?.fullAddress?? Localization().getStringEx("panel.covid19_test_locations.distance.unknown","unknown distance")), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.textSurface, + ), + ) + ) + ], + ))), + /*Semantics(label: Localization().getStringEx("panel.covid19_test_locations.call.hint","Call"), button: true, child: + GestureDetector( + onTap: _onTapContact, + child: + Container( + padding: EdgeInsets.only(top: 4, bottom: 8), + child: Row( + children: [ + Image.asset('images/icon-phone.png', excludeFromSemantics: true,), + Container(width: 8,), + Text( + testLocation?.contact ??Localization().getStringEx("panel.covid19_test_locations.label.contact.title", "Contact"), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.textSurface, + ), + ) + ], + )) + )),*/ + Semantics(explicitChildNodes:true,button: false, child: + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset('images/icon-time.png',excludeFromSemantics: true), + Container(width: 8,), + _buildWorkTime(), + ], + ) + ) + ], + ), + ) + ); + } + + Widget _buildWorkTime(){ + List items = List(); + HealthLocationDayOfOperation period; + LinkedHashMap workingPeriods; + List workTimes = testLocation?.daysOfOperation; + if(workTimes?.isNotEmpty?? false){ + workingPeriods = workTimes!=null?Map.fromIterable(workTimes,key: + (period)=>AppDateTime.getWeekDayFromString(period is HealthLocationDayOfOperation? period?.name?.toLowerCase(): null)): null; + items = workingPeriods?.values?.toList()?? List(); + period = _determineTodayPeriod(workingPeriods); + period = _determineIsOpen(period) ? period : _findNextPeriod(workingPeriods); + } else { + return Container( + child: Text(Localization().getStringEx("panel.covid19_test_locations.work_time.unknown","Unknown working time")) + ); + } + + return DropdownButton( + underline: Container(), + value: period, + onChanged: (value){}, + icon: Image.asset('images/chevron-down.png', color: Styles().colors.fillColorSecondary, excludeFromSemantics: false,), + selectedItemBuilder:(context){ + return items.map((entry){ + return Row( + children: [ + Text( + _getPeriodText(entry, workingPeriods), + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary, + ), + ), + ], + ); + }).toList(); + }, + items: items.map((entry){ + return DropdownMenuItem( + value: entry, + child: Text( +// _getPeriodText(entry, activePeriod), + entry.displayString, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary, + ), + ), + ); + }).toList(), + ); + } + + String _getPeriodText(HealthLocationDayOfOperation period, LinkedHashMap workingPeriods){ + String openText = Localization().getStringEx("panel.covid19_test_locations.work_time.open_until","Open until"); + String closedText = Localization().getStringEx("panel.covid19_test_locations.work_time.closed_until","Closed until"); + if(_determineIsOpen(period)){ //This is the active Period + String end = period?.closeTime; + return "$openText $end"; + } else { + //Closed until the next open period + HealthLocationDayOfOperation nextPeriod = _findNextPeriod(workingPeriods); + String nextOpenTime = nextPeriod!=null? nextPeriod.name +" "+nextPeriod.openTime : " "; + return "$closedText $nextOpenTime"; + } + } + + HealthLocationDayOfOperation _determineTodayPeriod(LinkedHashMap workingPeriods){ + int currentWeekDay = DateTime.now().weekday; + return workingPeriods!=null? workingPeriods[currentWeekDay] : null; + } + + HealthLocationDayOfOperation _findNextPeriod(LinkedHashMap workingPeriods){ + int currentWeekDay = DateTime.now().weekday; + if(workingPeriods!=null && workingPeriods.isNotEmpty) { + int nextDay = currentWeekDay +1; + if(nextDay == DateTime.sunday) //start from the begining + return workingPeriods?.values?.toList()[0]; + else { + for (int i = nextDay; i <= DateTime.sunday; i++) { + HealthLocationDayOfOperation period = workingPeriods[i]; + if(period!=null) + return period; + } + //If there is no nex period - reloop + return workingPeriods?.values?.toList()[0]; + } + } + return null; + } + + bool _determineIsOpen(HealthLocationDayOfOperation period){ + String start = period?.openTime?.toUpperCase(); + String end = period?.closeTime?.toUpperCase(); + TimeOfDay startPeriod = start!=null? TimeOfDay.fromDateTime(AppDateTime().dateTimeFromString(start,format: "hh:mma")) : null; + TimeOfDay endPeriod = end!=null? TimeOfDay.fromDateTime(AppDateTime().dateTimeFromString(end,format: "hh:mma")) : null; + TimeOfDay now = TimeOfDay.fromDateTime(DateTime.now()); + if(startPeriod!=null && endPeriod!=null){ + int startMinutes = startPeriod.hour * 60 + startPeriod.minute; + int endtMinutes = endPeriod.hour * 60 + endPeriod.minute; + int nowMinutes = now.hour * 60 + now.minute; + + return startMinutes _Covid19TransferEncryptionKeyPanelState(); +} + +class _Covid19TransferEncryptionKeyPanelState extends State { + + PointyCastle.PublicKey _userHealthPublicKey; + PointyCastle.PrivateKey _userHealthPrivateKey; + bool _userHealthKeysLoading, _userHealthKeysPaired, _userHealthPublicKeyLoaded, _userHealthPrivateKeyLoaded; + Uint8List _qrCodeBytes; + bool _saving = false; + + @override + void initState() { + _userHealthKeysLoading = true; + _loadHealthRSAPublicKey(); + _loadHealthRSAPrivateKey(); + super.initState(); + } + + void _loadHealthRSAPublicKey() { + Health().loadRSAPublicKey().then((publicKey) { + if (mounted) { + _userHealthPublicKey = publicKey; + _userHealthPublicKeyLoaded = true; + _verifyHealthRSAKeys(); + } + }); + } + + void _loadHealthRSAPrivateKey() { + Health().loadRSAPrivateKey().then((privateKey) { + if (mounted) { + _userHealthPrivateKey = privateKey; + _userHealthPrivateKeyLoaded = true; + _verifyHealthRSAKeys(); + } + }); + } + + void _verifyHealthRSAKeys() { + + if ((_userHealthPrivateKey != null) && (_userHealthPublicKey != null)) { + RsaKeyHelper.verifyRsaKeyPair(PointyCastle.AsymmetricKeyPair(_userHealthPublicKey, _userHealthPrivateKey)).then((bool result) { + if (mounted) { + _userHealthKeysPaired = result; + _buildHealthRSAQRCode(); + } + }); + } + else if ((_userHealthPrivateKeyLoaded == true) && (_userHealthPublicKeyLoaded == true)) { + _finishHealthRSAKeysLoading(); + } + } + + void _buildHealthRSAQRCode() { + Uint8List privateKeyData = (_userHealthKeysPaired && (_userHealthPrivateKey != null)) ? RsaKeyHelper.encodePrivateKeyToPEMDataPKCS1(_userHealthPrivateKey) : null; + List privateKeyCompressedData = (privateKeyData != null) ? GZipEncoder().encode(privateKeyData) : null; + String privateKeyString = (privateKeyData != null) ? base64.encode(privateKeyCompressedData) : null; + if (privateKeyString != null) { + NativeCommunicator().getBarcodeImageData({ + 'content': privateKeyString, + 'format': 'qrCode', + 'width': 1024, + 'height': 1024, + }).then((Uint8List qrCodeBytes) { + if (mounted) { + _qrCodeBytes = qrCodeBytes; + _finishHealthRSAKeysLoading(); + } + }); + } + else { + _finishHealthRSAKeysLoading(); + } + } + + void _finishHealthRSAKeysLoading() { + setState(() { + _userHealthKeysLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + Localization().getStringEx('panel.covid19.transfer.title', 'Transfer Encryption Key'), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 1.0, + ), + textAlign: TextAlign.center, + ), + ), + body: Column(children: [ + Expanded(child: + Padding(padding: EdgeInsets.all(24), child: + _userHealthKeysLoading ? _buildWaitingContent() : _buildPrivateKeyContent() + ), + ), + ],), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildWaitingContent() { + return Center(child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ); + } + + Widget _buildPrivateKeyContent(){ + return SingleChildScrollView( + child: (_qrCodeBytes != null) ? _buildQrCodeContent() : _buildNoQrCodeContent(), + ); + } + + Widget _buildQrCodeContent(){ + return Column( children: [ + Container(height: 15,), + Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx("panel.covid19.transfer.primary.heading.title", "Your COVID-19 Encryption Key"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color:Styles().colors.fillColorPrimary), + )), + Container(height: 30,), + _buildQrCode(), + Container(height: 20,), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: RoundedButton( + label: Localization().getStringEx("panel.covid19.transfer.primary.button.save.title", "Save Your Encryption Key"), + hint: Localization().getStringEx("panel.covid19.transfer.primary.button.save.hint", ""), + borderColor: Styles().colors.fillColorSecondaryVariant, + backgroundColor: Styles().colors.surface, + fontSize: 16, + height: 40, + padding: EdgeInsets.symmetric(vertical: 5), + textColor: Styles().colors.fillColorPrimary, + onTap: _onSaveImage, + ), + ), + Container(height: 30,) + ], + ); + } + + Widget _buildNoQrCodeContent(){ + return Column( children: [ + Container(height: 15,), + Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx("panel.covid19.transfer.secondary.heading.title", "Missing COVID-19 Encryption Key"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color:Styles().colors.fillColorPrimary), + )), + Container(height: 30,), + _buildAction( + heading: Localization().getStringEx("panel.covid19.transfer.secondary.button.scan.heading", "If you are adding a second device:"), + description: Localization().getStringEx("panel.covid19.transfer.secondary.button.scan.description", "If you still have access to your primary device, you can directly scan the COVID-19 Encryption Key QR code from that device."), + title: Localization().getStringEx("panel.covid19.transfer.secondary.button.scan.title", "Scan Your QR Code"), + iconRes: "images/fill-1.png", + onTap: _onScan + ), + Container(height: 12,), + _buildAction( + heading: Localization().getStringEx("panel.covid19.transfer.secondary.button.retrieve.heading", "If you are using a replacement device:"), + description: Localization().getStringEx("panel.covid19.transfer.secondary.button.retrieve.description", "If you no longer have access to your primary device, but saved your QR code to a cloud photo service, you can transfer your COVID-19 Encryption Key by retrieving it from your photos."), + title: Localization().getStringEx("panel.covid19.transfer.secondary.button.retrieve.title", "Retrieve Your QR Code"), + iconRes: "images/group-10.png", + onTap: _onRetrieve + ), + Container(height: 12,), + Container(height: 40,) + ], +); + } + + Widget _buildQrCode(){ + return Container( + decoration: BoxDecoration( + color: Styles().colors.white, + borderRadius: BorderRadius.all( Radius.circular(5))), + padding: EdgeInsets.all(1), + child: Semantics(child: + Image.memory(_qrCodeBytes, fit: BoxFit.fitWidth, semanticLabel: Localization().getStringEx("panel.covid19.transfer.primary.heading.title", "Your COVID-19 Encryption Key"), + )), + ); + } + + Widget _buildAction({String heading, String description, String title, String iconRes, Function onTap}){ + return Semantics(container: true, child:Container( + decoration: BoxDecoration( + color: Styles().colors.white, + borderRadius: BorderRadius.all( Radius.circular(5))), + child: Column( + children: [ + Container(height: 18,), + Container( padding: EdgeInsets.symmetric(horizontal: 20), + child: Text(heading, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color:Styles().colors.fillColorPrimary))), + Container(height: 9,), + Container( padding: EdgeInsets.symmetric(horizontal: 20), + child: Text(description, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 14, color:Styles().colors.fillColorPrimary))), + Container(height: 14,), + Semantics( + explicitChildNodes: true, + child: Container(child: + GestureDetector( + onTap: onTap, + child:Container( + decoration: BoxDecoration( + color: Styles().colors.background, + borderRadius: BorderRadius.only( bottomLeft: Radius.circular(5), bottomRight: Radius.circular(5)), + border: Border.all(color: Styles().colors.surfaceAccent,) + ), + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 15), + child: Row( + children: [ + Image.asset(iconRes, excludeFromSemantics: true,), + Container(width: 7,), + Semantics(button: true, excludeSemantics:false, child: + Text(title, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 14, color:Styles().colors.fillColorPrimary))), + Expanded(child: Container(),), + Image.asset('images/chevron-right.png',excludeFromSemantics: true,), + ], + ))))), + ], + ) + )); + } + + void _onSaveImage(){ + Analytics.instance.logSelect(target: "Save Your Encryption Key"); + if(!_saving) { + setState(() { + _saving = true; + }); + Covid19Utils.saveQRCodeImageToPictures(qrCodeBytes: _qrCodeBytes, title: Localization().getStringEx("panel.covid19.transfer.label.qr_image_label", "Safer Illinois COVID-19 Code")).then((bool result) { + setState(() { + _saving = false; + }); + String platformTargetText = (defaultTargetPlatform == TargetPlatform.android) ? Localization().getStringEx("panel.covid19.transfer.alert.save.success.pictures", "Pictures") : Localization().getStringEx("panel.covid19.transfer.alert.save.success.gallery", "Gallery"); + String message = result + ? (Localization().getStringEx("panel.covid19.transfer.alert.save.success.msg", "Successfully saved qr code in ") + platformTargetText) + : Localization().getStringEx("panel.covid19.transfer.alert.save.fail.msg", "Failed to save qr code in ") + platformTargetText; + AppAlert.showDialogResult(context, message); + }); + } + } + + void _onScan(){ + Analytics.instance.logSelect(target: "Scan Your QR Code"); + BarcodeScanner.scan().then((result) { + // barcode_scan plugin returns 8 digits when it cannot read the qr code. Prevent it from storing such values + if (AppString.isStringEmpty(result) || (result.length <= 8)) { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.covid19.transfer.alert.qr_code.scan.failed.msg', 'Failed to read QR code.')); + } + else { + _onCovid19QrCodeScanSucceeded(result); + } + }); + } + + void _onRetrieve() { + Analytics.instance.logSelect(target: "Retrieve Your QR Code"); + Covid19Utils.loadQRCodeImageFromPictures().then((String qrCodeString) { + _onCovid19QrCodeScanSucceeded(qrCodeString); + }); + } + + void _onCovid19QrCodeScanSucceeded(String result) { + + PointyCastle.PrivateKey privateKey; + try { + Uint8List pemCompressedData = (result != null) ? base64.decode(result) : null; + List pemData = (pemCompressedData != null) ? GZipDecoder().decodeBytes(pemCompressedData) : null; + privateKey = (pemData != null) ? RsaKeyHelper.parsePrivateKeyFromPemData(pemData) : null; + } + catch (e) { print(e?.toString()); } + + if (privateKey != null) { + RsaKeyHelper.verifyRsaKeyPair(PointyCastle.AsymmetricKeyPair(_userHealthPublicKey, privateKey)).then((bool result) { + if (mounted) { + if (result == true) { + Health().setUserRSAPrivateKey(privateKey).then((success) { + if (mounted) { + String resultMessage = success ? + Localization().getStringEx("panel.covid19.transfer.alert.qr_code.transfer.succeeded.msg", "COVID-19 secret transferred successfully.") : + Localization().getStringEx("panel.covid19.transfer.alert.qr_code.transfer.failed.msg", "Failed to transfer COVID-19 secret."); + AppAlert.showDialogResult(context, resultMessage); + } + }); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.covid19.transfer.alert.qr_code.not_match.msg', 'COVID-19 secret key does not match existing public RSA key.')); + } + } + }); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.covid19.transfer.alert.qr_code.invalid.msg', 'Invalid QR code.')); + } + } + +} diff --git a/lib/ui/health/Covid19UpdatesPanel.dart b/lib/ui/health/Covid19UpdatesPanel.dart new file mode 100644 index 00000000..2d0594db --- /dev/null +++ b/lib/ui/health/Covid19UpdatesPanel.dart @@ -0,0 +1,634 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Assets.dart'; + +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/ui/WebPanel.dart'; +import 'package:illinois/ui/health/Covid19NewsPanel.dart'; +import 'package:illinois/ui/widgets/FlexContentWidget.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/SectionTitlePrimary.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:sprintf/sprintf.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class Covid19UpdatesPanel extends StatefulWidget { + @override + _Covid19UpdatesPanelState createState() => _Covid19UpdatesPanelState(); +} + +class _Covid19UpdatesPanelState extends State with TickerProviderStateMixin implements NotificationsListener { + List _covid19News; + bool _newsLoading = false; + Covid19FAQ _faq; + bool _faqLoading = false; + List _animationControllers = List(); + + @override + void initState() { + NotificationService().subscribe(this, [ + FlexUI.notifyChanged, + FirebaseMessaging.notifySettingUpdated, + User.notifyFavoritesUpdated + ]); + _loadNews(); + _loadFAQs(); + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + if(_animationControllers!=null && _animationControllers.isNotEmpty){ + _animationControllers.forEach((controller){ + controller.dispose(); + }); + } + super.dispose(); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == FlexUI.notifyChanged) { + setState(() {}); + } else if (name == FirebaseMessaging.notifySettingUpdated) { + setState(() {}); + } else if (name == User.notifyFavoritesUpdated) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.covid19.header.title", "COVID-19"), + style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold), + ), + ), + backgroundColor: Styles().colors.background, + body: SingleChildScrollView( + child: Column( + children: _buildContent(), + ), + ), + ); + } + + void _loadNews() { + _newsLoading = true; + Health().loadCovid19News().then((covid19News) { + if (mounted) { + setState(() { + _newsLoading = false; + _covid19News = covid19News; + }); + } + }); + } + + void _loadFAQs() { + _faqLoading = true; + Health().loadCovid19FAQs().then((Covid19FAQ faq) { + if (mounted) { + setState(() { + _faqLoading = false; + _faq = faq; + }); + } + }); + } + + List _buildContent() { + List contentCodes = FlexUI()['health.covid19'] ?? ['latest_update', 'stay_informed', 'news', 'resources', 'general', 'faq']; + List contentWidgets = List(); + for (String code in contentCodes) { + Widget widget; + if (code == 'latest_update') { + widget = _buildLatestUpdate(); + } + else if (code == 'stay_informed') { + widget = _buildStayInformed(); + } + else if (code == 'news') { + widget = _buildNews(); + } + else if (code == 'resources') { + widget = _buildResources(); + } + else if (code == 'general') { + widget = _buildGeneralInfo(); + } + else if (code == 'faq') { + widget = _buildFaq(); + } + else { + dynamic data = Assets()[code]; + if (data is Map) { + widget = FlexContentWidget(jsonContent: data); + } + } + + if (widget != null) { + contentWidgets.add(widget); + } + } + + return contentWidgets; + } + + Widget _buildLatestUpdate() { + Widget latestUpdateContentWidget; + if (_newsLoading) { + latestUpdateContentWidget = CircularProgressIndicator(); + } else { + Covid19News latestUpdateNews = _covid19News?.first; + bool hasLatestUpdate = (latestUpdateNews != null); + String dateFormatted = AppString.getDefaultEmptyString( + value: AppDateTime().formatDateTime(latestUpdateNews?.date, format: AppDateTime.covid19UpdateDateFormat)); + String title = AppString.getDefaultEmptyString(value: latestUpdateNews?.title); + String description = AppString.getDefaultEmptyString(value: latestUpdateNews?.description); + bool isFavorite = User().isFavorite(latestUpdateNews); + bool starVisible = User().favoritesStarVisible; + latestUpdateContentWidget = Visibility(visible: hasLatestUpdate, child: GestureDetector(onTap: () => _onTapNewsCard(latestUpdateNews), child: Stack( + alignment: Alignment.topRight, children: [ + Stack( + alignment: Alignment.bottomCenter, children: [Container( + height: 288, + padding: EdgeInsets.symmetric(vertical: 16, horizontal: 12), + decoration: BoxDecoration(color: Styles().colors.white, borderRadius: BorderRadius.all(Radius.circular(4.0)), + boxShadow: [BoxShadow(color: Styles().colors.fillColorPrimaryTransparent015, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(0, 2))],), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(dateFormatted, style: TextStyle(fontSize: 12, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground),), + Padding(padding: EdgeInsets.only(top: 12), + child: Text(title, style: TextStyle(fontSize: 20, fontFamily: Styles().fontFamilies.extraBold, color: Styles().colors.fillColorPrimary),)), + Expanded(child: Padding(padding: EdgeInsets.only(top: 16), + child: Text(description, overflow: TextOverflow.ellipsis, + maxLines: 20, + style: TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground),),),), + ])), + Container( + height: 44, + width: double.infinity, + decoration: BoxDecoration(color: Styles().colors.white, + borderRadius: BorderRadius.vertical(bottom: Radius.circular(4.0))), + padding: EdgeInsets.symmetric(horizontal: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Container(height: 1, color: Styles().colors.fillColorSecondary,), + Expanded(child: Center(child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(Localization().getStringEx('panel.covid19.latest_updates.read_more.title', 'Read more'), style: TextStyle(fontSize: 16, + fontFamily: Styles().fontFamilies.bold, + color: Styles().colors.fillColorPrimary),), + Padding( + padding: EdgeInsets.only(left: 7), child: Image.asset('images/icon-down-orange.png'),), + ],),),) + ],),), + ],), Visibility(visible: starVisible, child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _onTapNewsCardStar(latestUpdateNews), + child: Semantics( + label: isFavorite ? Localization().getStringEx('widget.card.button.favorite.off.title', 'Remove From Favorites') : Localization().getStringEx( + 'widget.card.button.favorite.on.title', 'Add To Favorites'), + hint: isFavorite ? Localization().getStringEx('widget.card.button.favorite.off.hint', '') : Localization().getStringEx( + 'widget.card.button.favorite.on.hint', ''), + button: true, + excludeSemantics: true, + child: Container( + padding: EdgeInsets.only(top: 12, right: 12), height: 52, width: 52, + child: Align(alignment: Alignment.topRight, child: Image.asset(isFavorite ? 'images/icon-star-selected.png' : 'images/icon-star.png'),),) + )),) + ],),),); + } + + return SectionTitlePrimary( + title: Localization().getStringEx('panel.covid19.latest_update.title', 'Latest Update'), + iconPath: 'images/happening.png', + children: [latestUpdateContentWidget]); + } + + void _onTapNewsCard(Covid19News news) { + Analytics.instance.logSelect(target: "Covid-19 News: ${news?.title}"); + if (news != null) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19NewsPanel(covid19news: news,))); + } + } + + void _onTapNewsCardStar(Covid19News news) { + Analytics.instance.logSelect(target: "Favorite: Covid-19 News"); + User().switchFavorite(news); + } + + Widget _buildStayInformed() { + return _StayInformed(); + } + + Widget _buildNews() { + List newsList; + if (AppCollection.isCollectionNotEmpty(_covid19News)) { + Covid19News firstNews = _covid19News.first; + newsList = List.from(_covid19News); // Create copy so that we can modify it. + if (firstNews != null) { + newsList.remove(firstNews); + } + } + return Padding(padding: EdgeInsets.only(top: 24), child: SectionTitlePrimary( + title: Localization().getStringEx("panel.covid19.news.title", 'COVID-19 News'), + iconPath: 'images/icon-news.png', + children: _buildNewsItems(newsList)),); + } + + List _buildNewsItems(List newsList) { + List widgets = List(); + if (_newsLoading) { + widgets.add(CircularProgressIndicator()); + } else if (AppCollection.isCollectionNotEmpty(newsList)) { + newsList.forEach((news) { + widgets.add(Covid19NewsCard(news: news,)); + }); + } + return widgets; + } + + Widget _buildResources() { + return Covid19Resources(); + } + + + Widget _buildFaq() { + return Column( + children:[ + _buildFAQTitle(), + Semantics(explicitChildNodes: true, child: + Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child:Column(children: _constructFaqContent(), + )))]); + } + + List _constructFaqContent(){ + List widgets = new List(); + List faqSections = _faq?.sections; + + if (_faqLoading) { + widgets.add(Container(height: 10,)); + widgets.add(CircularProgressIndicator()); + return widgets; + } + + widgets.add(_buildFAQDescription()); + if (AppCollection.isCollectionNotEmpty(faqSections)) { + faqSections.forEach((Covid19FAQSection section) { + widgets.add(_buildFAQSectionWidget(section)); + widgets.add(Container(height: 1,)); + }); + widgets.add(Container(height: 32,)); + } + return widgets; + } + + Widget _buildGeneralInfo(){ + List widgets = List(); + List faqGeneralEntries= _faq?.general; + if(AppCollection.isCollectionNotEmpty(faqGeneralEntries)) { + faqGeneralEntries.forEach((entry) { + TextStyle titleStyle = TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.extraBold, color: Styles().colors.textBackground); + TextStyle descriptionStyle = TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground); + widgets.add(_buildGeneralFaqEntry(entry, titleStyle, descriptionStyle)); + }); + widgets.add(Container(height: 32,)); + return Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child:Column(children: widgets,)); + } + + return Container(); + } + + Widget _buildFAQSectionWidget(Covid19FAQSection section) { + final Animatable _halfTween = Tween(begin: 0.0, end: 0.5); + final Animatable _easeInTween = CurveTween(curve: Curves.easeIn); + AnimationController _controller = AnimationController(duration: Duration(milliseconds: 200), vsync: this); + _animationControllers.add(_controller); + Animation _iconTurns = _controller.drive(_halfTween.chain(_easeInTween)); + + return Container(color: Styles().colors.fillColorPrimary, + child: Theme(data: ThemeData(accentColor: Styles().colors.white, + dividerColor: Colors.white, + backgroundColor: Styles().colors.white, + textTheme: TextTheme(subtitle1: TextStyle(color: Styles().colors.white, fontFamily: Styles().fontFamilies.bold, fontSize: 16))), + child: ExpansionTile( + title: + Semantics(label: section.title, + hint: Localization().getStringEx("panel.covid19.faq.question.hint","Double tap to show questions"),/*+(expanded?"Hide" : "Show ")+" questions",*/ + excludeSemantics:true,child: + Container(child: Text(section.title, style: TextStyle(color: Styles().colors.white, fontFamily: Styles().fontFamilies.bold, fontSize: 16),))), + backgroundColor: Styles().colors.fillColorPrimary, + trailing: RotationTransition( + turns: _iconTurns, + child: Icon(Icons.arrow_drop_down, color: Styles().colors.white,)), + children: _buildFagSectionEntries(section.questions), + onExpansionChanged: (bool expand) { + Analytics.instance.logSelect(target: "FAQ question:" + section?.title); + if (expand) { + _controller.forward(); + } else { + _controller.reverse(); + } + }, + ))); + } + + List _buildFagSectionEntries(List questions) { + List widgets = List(); + if (AppCollection.isCollectionNotEmpty(questions)) { + questions.forEach((Covid19FAQEntry entry) { + TextStyle titleStyle = TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.bold, color: Styles().colors.fillColorPrimary); + TextStyle descriptionStyle = TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground); + Widget faqQuestionEntry = Container( + padding: EdgeInsets.only(top: 16, left: 16, right: 16), color: Styles().colors.white, child: _buildGeneralFaqEntry(entry, titleStyle, descriptionStyle,question: true),); + widgets.add(faqQuestionEntry); + }); + } + widgets.add(Container(height: 16, color: Styles().colors.white,)); + return widgets; + } + + Widget _buildFAQTitle(){ + String title=Localization().getStringEx('panel.covid19.faq.title',"FAQ"); + String label=Localization().getStringEx('panel.covid19.faq.title.label',"Frequently asked questions"); + return Container( + color: Styles().colors.fillColorPrimary, + padding: EdgeInsets.only(left: 16, top: 20, right: 16, bottom: 20), + child: Semantics(label:label, header: true, excludeSemantics: true, child:Row( + children: [ + Padding( + padding: EdgeInsets.only(right: 16), + child: Image.asset( + "images/icon-news.png"), + ), + Text( + title, + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontFamily: Styles().fontFamilies.extraBold), + )]))); + } + + Widget _buildFAQDescription(){ + String description = Localization().getStringEx('panel.covid19.faq.description',"Answers to your most common questions:"); + DateTime time =_faq?.dateUpdated; + if(time==null) + return Container(); + + String formatedTimeText = AppDateTime().formatDateTime(time,format: "MMMM dd, yyyy"); + String updateTime = sprintf(Localization().getStringEx('panel.covid19.faq.update.text',"Updated %s"), [formatedTimeText]); + return + Semantics(container: true,child:Container(child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(top: 20), + child: Text(description, style: TextStyle(fontSize: 20, fontFamily: Styles().fontFamilies.extraBold, color: Styles().colors.fillColorPrimary),)), + Padding(padding: EdgeInsets.only(top: 5,bottom: 24), + child: Text(updateTime, overflow: TextOverflow.ellipsis, + maxLines: 20, + style: TextStyle(fontSize: 16, fontFamily: Styles().fontFamilies.regular, color: Styles().colors.textBackground),),), + ]))); + } + + Widget _buildGeneralFaqEntry(Covid19FAQEntry entry, TextStyle titleStyle, TextStyle descriptionStyle,{bool question = false}) { + if (entry == null) { + return Container(); + } + return Semantics(inMutuallyExclusiveGroup:true,container: true,child:Container(child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ +// Semantics(label:question? Localization().getStringEx('panel.covid19.faq.question.label',"Question: ") : " . ", child: + Text(AppString.getDefaultEmptyString(value: entry.title), + style: titleStyle,), +// ), +// Semantics(label: question? Localization().getStringEx('panel.covid19.faq.answer.label',"Answer: "): " . ", child: + Padding(padding: EdgeInsets.only(top: 12), + child: Html(data: AppString.getDefaultEmptyString(value: entry.description), + defaultTextStyle: descriptionStyle, + onLinkTap: (url) => _onLinkTap(url),),) +// ), + ]))); + } + + void _onLinkTap(String url) { + if (AppString.isStringEmpty(url)) { + return; + } + if (AppUrl.launchInternal(url)) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => WebPanel(url: url))); + } else { + launch(url); + } + } +} + +class Covid19Resources extends StatefulWidget { + + _Covid19ResourcesState createState() => _Covid19ResourcesState(); +} + +class _Covid19ResourcesState extends State { + + List _contentResources; + + bool _isLoading = false; + + @override + void initState() { + _loadContentResources(); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _loadContentResources(){ + _isLoading = true; + Health().loadCovid19Resources().then((List contentResources){ + if(mounted) { + _contentResources = contentResources; + } + }).whenComplete((){ + if(mounted) { + setState(() { + _isLoading = false; + }); + } + }); + } + + Widget _buildCovidResource(BuildContext context, Covid19Resource resource) { + GestureTapCallback onTap = () => _onTapResourcse(context, resource); + + return GestureDetector( + onTap: onTap, + child: Semantics(button: true, + hint: Localization().getStringEx("panel.covid19.resources.poor_accessibility.hint", "This link takes you to a website outside of the Safer Illinois app"), + child:Container( + decoration: BoxDecoration( + color: Styles().colors.white, + ), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( + children: [ + Expanded( + child: Text( + resource.title, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + color: Styles().colors.fillColorPrimary, + fontSize: 16 + ), + ), + ), + Image.asset('images/external-link.png') + ], + ), + ), + )); + } + + @override + Widget build(BuildContext context) { + + List rows = List(); + if(_isLoading){ + rows.add(Center(child: CircularProgressIndicator(),)); + } + else { + for (Covid19Resource resource in _contentResources) { + Widget widget = _buildCovidResource(context, resource); + if (widget != null) { + if(rows.isNotEmpty){ + rows.add(Container(height: 1, color: Styles().colors.lightGray,)); + } + rows.add(widget); + } + } + } + + return Column( + children: [ + SectionTitlePrimary( + title: Localization().getStringEx( + 'widget.home_campus_tools.label.campus_tools', + 'Campus Resources'), + iconPath: 'images/campus-tools.png', + children: [ + Container( + decoration: BoxDecoration( + border: Border.all(color: Styles().colors.lightGray, width: 1), + borderRadius: BorderRadius.all(Radius.circular(4.0)), + boxShadow: [BoxShadow(color: Styles().colors.fillColorPrimaryTransparent015, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(0, 2))] + ), + child: Column( + children: rows, + ), + ) + ], + ), + Container(height: 48,), + ], + ); + } + + + // Actions + void _onTapResourcse(BuildContext context, Covid19Resource resource) { + Analytics.instance.logSelect(target: resource.title); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>WebPanel(url: resource.link, title: resource.title,))); + } +} + +class _StayInformed extends StatefulWidget{ + _StayInformedState createState() => _StayInformedState(); +} + +class _StayInformedState extends State<_StayInformed>{ + + @override + Widget build(BuildContext context) { + return + Semantics(container: true ,child: + Container( + padding: EdgeInsets.only(left: 16, right: 16, top: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(Localization().getStringEx("widget.covid19.stay_informed.title", "Stay Informed"), + textAlign: TextAlign.left, + style: TextStyle( + fontFamily: Styles().fontFamilies.extraBold, + fontSize: 24, + color: Styles().colors.fillColorPrimary + ), + ), + Text(Localization().getStringEx("widget.covid19.stay_informed.description", "Receive notifications from the campus as soon as they happen."), + textAlign: TextAlign.left, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.textBackground + ), + ), + Container(height: 10,), + Semantics(container: true, child: + ToggleRibbonButton( + borderRadius: BorderRadius.all(Radius.circular(5)), + label: Localization().getStringEx("widget.covid19.stay_informed.button.enable_covid19_notifications.title", "Enable COVID-19 notifications"), + toggled: FirebaseMessaging().notifyCovid19, + context: context, + onTap: _onToggleNotify) + ) + ], + ), + )); + } + + void _onToggleNotify(){ + Analytics.instance.logSelect(target: "Enable COVID-19 notifications"); + setState(() { + FirebaseMessaging().notifyCovid19 = FirebaseMessaging().notifyCovid19; + }); + } +} diff --git a/lib/ui/health/Covid19WellnessCenter.dart b/lib/ui/health/Covid19WellnessCenter.dart new file mode 100644 index 00000000..be2db38e --- /dev/null +++ b/lib/ui/health/Covid19WellnessCenter.dart @@ -0,0 +1,99 @@ + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class Covid19WellnessCenter extends StatelessWidget{ + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.background, + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.covid19_wellness_center.header.title", "COVID-19 Wellness Center"), + style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold), + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Localization().getStringEx("panel.covid19_wellness_center.label.description", "If you having issues with the app or getting a test result, contact the COVID Wellness Answer Center for assistance."), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.textSurface + ), + ), + Container(height: 20,), + RichText( + textScaleFactor: MediaQuery.textScaleFactorOf(context), + textAlign: TextAlign.start, + text: TextSpan( + text: Localization().getStringEx("panel.covid19_wellness_center.label.email", "Email the Covid Wellness Answer Center at "), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.textSurface + ), + children: [ + TextSpan( + text: 'covidwellness@illinois.edu', + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.fillColorSecondary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer()..onTap = ()=>onEmailTapped(), + ), + ] + ), + ), + Container(height: 20,), + RichText( + textScaleFactor: MediaQuery.textScaleFactorOf(context), + textAlign: TextAlign.start, + text: TextSpan( + text: Localization().getStringEx("panel.covid19_wellness_center.label.phone", "Phone the Answer Center at "), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.textSurface + ), + children: [ + TextSpan( + text: '217 333-1900', + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.fillColorSecondary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer()..onTap = ()=>onCallTapped(), + ), + ] + ), + ), + ], + ), + ), + ), + ); + } + + void onEmailTapped(){ + launch('mailto: covidwellness@illinois.edu'); + } + + void onCallTapped(){ + launch('tel://+12173331900'); + } +} \ No newline at end of file diff --git a/lib/ui/health/debug/Covid19DebugActionPanel.dart b/lib/ui/health/debug/Covid19DebugActionPanel.dart new file mode 100644 index 00000000..3f50f528 --- /dev/null +++ b/lib/ui/health/debug/Covid19DebugActionPanel.dart @@ -0,0 +1,316 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + +class Covid19DebugActionPanel extends StatefulWidget { + + Covid19DebugActionPanel(); + + @override + _Covid19DebugActionPanelState createState() => _Covid19DebugActionPanelState(); +} + +class _Covid19DebugActionPanelState extends State { + + DateTime _selectedDate; + TextEditingController _typeController; + FocusNode _typeNode; + + TextEditingController _textController; + FocusNode _textNode; + + TextEditingController _intervalController; + FocusNode _intervalNode; + + bool _submitting; + + @override + void initState() { + super.initState(); + _typeController = TextEditingController(text: 'quarantine'); + _typeNode = FocusNode(); + + _textController = TextEditingController(text: 'You are quarantined. Take two PCR tests after 4 days.'); + _textNode = FocusNode(); + + _intervalController = TextEditingController(text: '2'); + _intervalNode = FocusNode(); + } + + @override + void dispose() { + super.dispose(); + _typeController.dispose(); + _typeNode.dispose(); + + _textController.dispose(); + _textNode.dispose(); + + _intervalController.dispose(); + _intervalNode.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text("COVID-19 Action", style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeading(), + _buildContent(), + ], + ), + ), + ), + _buildSubmit(), + ], + ), + ), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildHeading() { + + return Semantics(container: true, child: + Container(color:Colors.white, + child: Padding(padding: EdgeInsets.all(32), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, + children:[ + Row(children: [ + Padding(padding: EdgeInsets.only(right: 4), child: Image.asset('images/campus-tools-blue.png',excludeFromSemantics: true,)), + Text("Create COVID-19 Action", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ],), + ]), + ), + )); + } + + + Widget _buildContent() { + String dateText = _selectedDate != null ? AppDateTime().formatDateTime(_selectedDate, format: AppDateTime.scheduleServerQueryDateTimeFormat) : "-"; + return Padding(padding: EdgeInsets.all(32), + child: Column(crossAxisAlignment:CrossAxisAlignment.start, children: [ + Semantics(container: true, child: + Padding(padding: EdgeInsets.symmetric(vertical: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text(Localization().getStringEx("panel.health.covid19.debug.trace.label.date","Date"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + GestureDetector(onTap: _onTapPickDate, + child: Container(height: 48, + decoration: BoxDecoration( + border: Border.all(color: Styles().colors.surfaceAccent, width: 1), + borderRadius: BorderRadius.all(Radius.circular(4))), + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppString.getDefaultEmptyString(value: dateText, defaultValue: '-'), + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontSize: 16, + fontFamily: Styles().fontFamilies.medium), + ), + Image.asset('images/icon-down-orange.png') + ], + ), + ), + ), + ],), + ), + ), + + + Padding(padding: EdgeInsets.symmetric(vertical: 8), child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text("Type", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + Semantics(textField: true, child:Container(color: Styles().colors.white, + child: TextField( + controller: _typeController, + focusNode: _typeNode, + decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0)), contentPadding: EdgeInsets.symmetric(horizontal: 8)), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,), + ), + )), + ],), + ), + + Padding(padding: EdgeInsets.symmetric(vertical: 8), child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text("Text", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + Semantics(textField: true, child:Container(color: Styles().colors.white, + child: TextField( + controller: _textController, + focusNode: _textNode, + decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0)), contentPadding: EdgeInsets.symmetric(horizontal: 8)), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,), + ), + )), + ],), + ), + + /*Padding(padding: EdgeInsets.symmetric(vertical: 8), child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text("Interval (days)", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + Semantics(textField: true, child:Container(color: Styles().colors.white, + child: TextField( + controller: _intervalController, + focusNode: _intervalNode, + decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0)), contentPadding: EdgeInsets.symmetric(horizontal: 8)), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,), + ), + )), + ],), + ),*/ + + ]), + ); + } + + Widget _buildSubmit() { + return Padding(padding: EdgeInsets.all(16), + child: Stack(children: [ + Row(children: [ + Expanded(child: Container(),), + RoundedButton(label: "Submit Action", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + padding: EdgeInsets.symmetric(horizontal: 32, ), + borderWidth: 2, + height: 42, + onTap:() { _onSubmit(); } + ), + Expanded(child: Container(),), + ],), + Visibility(visible: (_submitting == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height:21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ],), + ); + } + + void _onTapPickDate() { + DateTime initialDate = (_selectedDate != null) ? _selectedDate : DateTime.now(); + DateTime firstDate = initialDate.subtract(new Duration(days: 365 * 5)); + DateTime lastDate = initialDate.add(new Duration(days: 365 * 5)); + showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + builder: (BuildContext context, Widget child) { + return Theme(data: ThemeData.light(), child: child,); + }, + ).then((DateTime result) { + if (mounted && (result != null)) { + setState(() { + _selectedDate = result; + }); + } + }); + } + + void _onSubmit() { + if (_selectedDate == null) { + AppAlert.showDialogResult(context, "Please select a date").then((_) { + _onTapPickDate(); + }); + return; + } + DateTime dateUtc = _selectedDate.toUtc(); + + String actionType = _typeController.text; + if (AppString.isStringEmpty(actionType)) { + AppAlert.showDialogResult(context, "Please enter an type").then((value) { + _typeNode.requestFocus(); + }); + return; + } + + String actionText = _textController.text; + if (AppString.isStringEmpty(actionText)) { + AppAlert.showDialogResult(context, "Please enter an text").then((value) { + _textNode.requestFocus(); + }); + return; + } + + /*int actionInterval = int.tryParse(_intervalController.text); + if (actionInterval == null) { + AppAlert.showDialogResult(context, "Please enter an integer interval").then((value) { + _intervalNode.requestFocus(); + }); + return; + }*/ + + setState(() { + _submitting = true; + }); + + Health().processAction({ + 'health.covid19.action.date': healthDateTimeToString(dateUtc), + 'health.covid19.action.type': actionType, + 'health.covid19.action.text': actionText, + }).then((bool result) { + if (mounted) { + setState(() { + _submitting = false; + }); + if (result != true) { + AppAlert.showDialogResult(context,Localization().getStringEx("panel.health.covid19.debug.trace.error.submit.text", "Failed to submit contact trace data.")); + } + else { + Navigator.of(context).pop(); + } + } + }); + } +} diff --git a/lib/ui/health/debug/Covid19DebugCreateEventPanel.dart b/lib/ui/health/debug/Covid19DebugCreateEventPanel.dart new file mode 100644 index 00000000..f9e78287 --- /dev/null +++ b/lib/ui/health/debug/Covid19DebugCreateEventPanel.dart @@ -0,0 +1,509 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:collection'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Crypt.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import "package:pointycastle/export.dart" as PointyCastle; + +class Covid19DebugCreateEventPanel extends StatefulWidget { + + Covid19DebugCreateEventPanel(); + + @override + _Covid19DebugCreateEventPanelState createState() => _Covid19DebugCreateEventPanelState(); +} + +class _Covid19DebugCreateEventPanelState extends State { + + TextEditingController _blobController; + String _headerStatus; + + PointyCastle.PublicKey _rsaPublicKey; + bool _loadingPublicKey; + bool _refreshingPublicKey; + + LinkedHashMap _providers; + String _selectedProviderId; + + bool _submitting; + + @override + void initState() { + super.initState(); + + _blobController = TextEditingController(text: this._sampleTestNegativeBlob); + + _selectedProviderId = Storage().lastHealthProvider?.id; + + _loadingPublicKey = true; + Health().loadRSAPublicKey().then((PointyCastle.PublicKey rsaPublicKey) { + if (mounted) { + setState(() { + _loadingPublicKey = false; + _rsaPublicKey = rsaPublicKey; + _headerStatus = (_rsaPublicKey != null) ? "User's public RSA key loaded." : "Failed to load user's public RSA key."; + }); + } + }); + + Health().loadHealthServiceProviders().then((List providers){ + if (mounted) { + setState(() { + try { + _providers = (providers != null) ? Map.fromIterable(providers, key: ((provider) => provider.id)): null; + } + catch(e) {} + }); + } + }); + + } + + @override + void dispose() { + super.dispose(); + _blobController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text("COVID-19 Event", style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + body: SafeArea(child: + Column(children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeading(), + _buildContent(), + ], + ), + ), + ), + _buildSubmit(), + ],), + ), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildHeading() { + String input; + if (_loadingPublicKey == true) { + input = Localization().getStringEx("panel.health.covid19.debug.create.loading.user_key","Loading user's public RSA key..."); + } + else if (_refreshingPublicKey == true) { + input = Localization().getStringEx("panel.health.covid19.debug.create.loading.refresh_keys","Refreshing RSA Keys..."); + } + else if (_headerStatus != null) { + input = _headerStatus; + } + else { + input = ''; + } + + + return Semantics(container: true, child: + Container(color:Colors.white, + child: Padding(padding: EdgeInsets.all(32), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, + children:[ + Row(children: [ + Padding(padding: EdgeInsets.only(right: 4), child: Image.asset('images/campus-tools-blue.png',excludeFromSemantics: true,)), + Text("Enter COVID-19 Event", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ],), + Padding(padding: EdgeInsets.only(top: 8), child: + Semantics(label: Localization().getStringEx("panel.health.covid19.debug.create.label.description.hint","status: "), child: Text(input, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Color(0xff494949)))), + ), + _buildRefresh(), + ]), + ), + )); + } + + Widget _buildRefresh() { + bool loading = (_loadingPublicKey == true) || (_refreshingPublicKey == true); + return Padding(padding: EdgeInsets.only(top: 8), child: + Row(children: [ + Stack(children: [ + RoundedButton(label:Localization().getStringEx("panel.health.covid19.debug.create.button.refresh.title", "Refresh RSA Keys"), + textColor: !loading ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: !loading ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColorTwo, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + width: 200, + height: 42, + onTap:() { _onRefreshRSAKeys(); } + ), + Visibility(visible: loading, child: + Padding(padding: EdgeInsets.only(left: (200 - 21) / 2), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height:21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ), + ],), + ],), + ); + } + + Widget _buildContent() { + return Padding(padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: Column(crossAxisAlignment:CrossAxisAlignment.start, children: [ + + Semantics(container: true, child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text(Localization().getStringEx("panel.health.covid19.debug.create.label.provider","Provider"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + Container(decoration: BoxDecoration(color: Styles().colors.white, border: Border.all(color: Colors.black, width: 1), borderRadius: BorderRadius.all(Radius.circular(4))), + child: Padding(padding: EdgeInsets.only(left: 12, right: 16), + child: DropdownButtonHideUnderline( + child: DropdownButton( + icon: Image.asset('images/icon-down-orange.png', excludeFromSemantics: true,), + isExpanded: true, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,), + hint: Text(_selectedProvider?.name ?? "Select a provider...",style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + items: _buildProviderDropDownItems(_providers?.values), + onChanged: (value) { setState(() { + Storage().lastHealthProvider = value; + _selectedProviderId = value?.id; + });} + ), + ), + ), + ) + ],), + ), + + Padding(padding: EdgeInsets.symmetric(vertical: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text(Localization().getStringEx("panel.health.covid19.debug.create.label.blob","Blob"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + Stack(children: [ + Semantics(textField: true, child:Container(color: Styles().colors.white, + child: TextField( + maxLines: 5, + controller: _blobController, + decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,), + ), + )), + Align(alignment: Alignment.topRight, + child: Semantics (button: true, label: Localization().getStringEx("panel.health.covid19.debug.create.hint.provider","Clear"), + child: GestureDetector(onTap: () { _clearBlob(); }, + child: Container(width: 36, height: 36, + child: Align(alignment: Alignment.center, + child: Semantics( excludeSemantics: true,child:Text('X', style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.fillColorPrimary,),)), + ), + ), + ), + )), + ]), + ]), + ), + + Padding(padding: EdgeInsets.symmetric(vertical: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text("Populate", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + + Row(children: [ + Expanded(child: + RoundedButton(label: "Negative Test", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onPopulate(this._sampleTestNegativeBlob); } + ), + ), + Container(width: 4,), + Expanded(child: + RoundedButton(label: "Positive Test", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onPopulate(this._sampleTestPositiveBlob); } + ), + ), + ],), + + Container(height: 4,), + + Row(children: [ + Expanded(child: + RoundedButton(label: "Quarantine ON", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onPopulate(this._sampleActionQuarantineOnBlob); } + ), + ), + Container(width: 4,), + Expanded(child: + RoundedButton(label: "Quarantine OFF", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onPopulate(this._sampleActionQuarantineOffBlob); } + ), + ), + ],), + + Container(height: 4,), + + Row(children: [ + Expanded(child: + RoundedButton(label: "Out Of Compliance", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onPopulate(this._sampleActionOutOfComplianceBlob); } + ), + ), + Container(width: 4,), + Expanded(child: + RoundedButton(label: "Pending Test", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onPopulate(this._sampleActionTestPendingBlob); } + ), + ), + ],), + + ],), + ), + + ]), + ); + } + + Widget _buildSubmit() { + return Padding(padding: EdgeInsets.all(16), + child: Stack(children: [ + Row(children: [ + Expanded(child: Container(),), + RoundedButton(label: "Submit Event", + textColor: (_rsaPublicKey != null) ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: (_rsaPublicKey != null) ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColor, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + padding: EdgeInsets.symmetric(horizontal: 32, ), + borderWidth: 2, + height: 42, + onTap:() { _onSubmit(); } + ), + Expanded(child: Container(),), + ],), + Visibility(visible: (_submitting == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height:21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ],), + ); + } + + // Provider + + HealthServiceProvider get _selectedProvider { + return (_providers != null) ? _providers[_selectedProviderId] : null; + } + + List> _buildProviderDropDownItems(Iterable items) { + return (items != null) ? items.map((HealthServiceProvider item) { + return DropdownMenuItem(value: item, child: Text(item.name),); + }).toList() : null; + } + + // Encryption + + void _clearBlob() { + _blobController.text = ''; + } + + void _onRefreshRSAKeys() { + setState(() { + _refreshingPublicKey = true; + }); + Health().refreshRSAKeys().then((PointyCastle.AsymmetricKeyPair rsaKeys) { + if (mounted) { + setState(() { + _refreshingPublicKey = false; + PointyCastle.PublicKey rsaPublicKey = rsaKeys?.publicKey; + if (rsaPublicKey != null) { + _rsaPublicKey = rsaPublicKey; + _headerStatus = Localization().getStringEx("panel.health.covid19.debug.create.label.status.refreshed","User's public RSA key refreshed."); + } + else { + _headerStatus = Localization().getStringEx("panel.health.covid19.debug.create.label.status.error","Failed to refresh user's public RSA key."); + } + }); + + } + }); + } + + String get _sampleTestNegativeBlob { + String date = healthDateTimeToString(DateTime.now().toUtc()); + return '''{ + "Date": "$date", + "TestName": "COVID-19 PCR", + "Result": "negative" +}''';} + + String get _sampleTestPositiveBlob { + String date = healthDateTimeToString(DateTime.now().toUtc()); + return '''{ + "Date": "$date", + "TestName": "COVID-19 PCR", + "Result": "positive" +}''';} + + String get _sampleActionQuarantineOnBlob { + String date = healthDateTimeToString(DateTime.now().toUtc()); + return '''{ + "Date": "$date", + "ActionType": "quarantine-on", + "ActionText": "You are in quarantine" +}''';} + + String get _sampleActionQuarantineOffBlob { + String date = healthDateTimeToString(DateTime.now().toUtc()); + return '''{ + "Date": "$date", + "ActionType": "quarantine-off", + "ActionText": "You are out of quarantine" +}''';} + + String get _sampleActionOutOfComplianceBlob { + String date = healthDateTimeToString(DateTime.now().toUtc()); + return '''{ + "Date": "$date", + "ActionType": "out-of-test-compliance", + "ActionText": "You are out of test compliance" +}''';} + + String get _sampleActionTestPendingBlob { + String date = healthDateTimeToString(DateTime.now().toUtc()); + return '''{ + "Date": "$date", + "ActionType": "test_pending", + "ActionText": "Your test is pending" +}''';} + + void _onPopulate(String content) { + _blobController.text = content; + } + + Future _postEvent({String blob, String providerId}) async { + String aesKey = AESCrypt.randomKey(); + String encryptedBlob = AESCrypt.encrypt(blob, aesKey); + String encryptedKey = RSACrypt.encrypt(aesKey, _rsaPublicKey); + + //PointyCastle.PrivateKey privateKey = await Health().loadRSAPrivateKey(); + //String decryptedKey = ((privateKey != null) && (encryptedKey != null)) ? RSACrypt.decrypt(encryptedKey, privateKey) : null; + //String decryptedBlob = ((decryptedKey != null) && (encryptedBlob != null)) ? AESCrypt.decrypt(encryptedBlob, decryptedKey) : null; + + String url = "${Config().healthUrl}/covid19/ctests"; + String post = AppJson.encode({ + 'provider_id': providerId, + 'uin': Auth().authInfo?.uin, + 'encrypted_key': encryptedKey, + 'encrypted_blob': encryptedBlob + }); + + Response response = await Network().post(url, body:post, headers: { Network.RokwireHSApiKey : Config().healthApiKey }); + if (response == null) { + return Localization().getStringEx("panel.health.covid19.debug.create.label.error.timeout","Request Timeout"); + } + else if (response.statusCode == 200) { + return null; + } + else { + return response.body ?? Localization().getStringEx("panel.health.covid19.debug.create.label.error.unknown","Unknwon Error Occured"); + } + } + + void _onSubmit() { + if ((_rsaPublicKey != null) && (_submitting != true) && (_loadingPublicKey != true)) { + setState(() { + _submitting = true; + }); + _postEvent(blob: _blobController.text, providerId: _selectedProviderId).then((String error) { + if (mounted) { + setState(() { + _submitting = false; + }); + if (error == null) { + Navigator.of(context).pop(); + } + else { + Analytics.instance.logAlert(text: error); + AppAlert.showDialogResult(context, error); + } + } + }); + } + } +} diff --git a/lib/ui/health/debug/Covid19DebugExposureLogsPanel.dart b/lib/ui/health/debug/Covid19DebugExposureLogsPanel.dart new file mode 100644 index 00000000..8884b5e5 --- /dev/null +++ b/lib/ui/health/debug/Covid19DebugExposureLogsPanel.dart @@ -0,0 +1,1541 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:io'; + +import 'package:device_info/device_info.dart'; +import 'package:http/http.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:illinois/model/Exposure.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Exposure.dart'; +import 'package:illinois/service/Network.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:url_launcher/url_launcher.dart' as url_launcher; + +class Covid19DebugExposureLogsPanel extends StatefulWidget { + Covid19DebugExposureLogsPanel(); + + @override + _Covid19DebugExposureLogsPanelState createState() => + _Covid19DebugExposureLogsPanelState(); +} + +class _Covid19DebugExposureLogsPanelState extends State + implements NotificationsListener { + TextEditingController _minDurationSettingController; + FocusNode _minDurationSettingFocusNode; + + TextEditingController _minRSSISettingController; + FocusNode _minRSSISettingFocusNode; + + List _teks; + List _exposures; + + bool _poolingTEKs; + bool _reportingTEKs; + bool _checkingTEKs; + bool _checkingExposures; + String _connection; + + // testing framework variables + static const String Url = + "http://ec2-18-191-37-235.us-east-2.compute.amazonaws.com:8003/"; + static const String QueryUrl = + "http://ec2-18-191-37-235.us-east-2.compute.amazonaws.com:8003/SessionReport?"; + TextEditingController _sessionIDTextController; + TextEditingController _additionalDetailTextController; + TextEditingController _querySessionTextController; + TextEditingController _queryDeviceIndexTextController; + //String _currentTestingSessionID = ""; + bool _processingSessionID = false; // circling animation and on tap event + bool _isInSession = + false; // first row button color, onTap event and endSession button + String _executionStatus = "None"; + int _currentSession; + bool _isAndroid; + String _deviceID; + String _thisDeviceIndex; // always shown as this device Index + + Map _startSettings; + + @override + void initState() { + NotificationService().subscribe(this, [ + Exposure.notifyStartStop, + Exposure.notifyTEKsUpdated, + Exposure.notifyExposureUpdated, + Exposure.notifyExposureThick, + ]); + _startSettings = Map.from(Exposure().startSettings ?? Config().settings); + + //int minDuration = _startSettings['covid19ExposureServiceMinDuration']; + int minDuration = Exposure().exposureMinDuration; + _minDurationSettingController = + TextEditingController(text: minDuration?.toString() ?? ''); + _minDurationSettingFocusNode = FocusNode(); + + int minRssi = _startSettings['covid19ExposureServiceMinRSSI']; + _minRSSISettingController = + TextEditingController(text: minRssi?.toString() ?? ''); + _minRSSISettingFocusNode = FocusNode(); + + _sessionIDTextController = TextEditingController(); + _additionalDetailTextController = TextEditingController(); + _querySessionTextController = TextEditingController(); + _queryDeviceIndexTextController = TextEditingController(); + + _loadTEKs(); + _loadExposures(); + + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + + _minDurationSettingController.dispose(); + _minDurationSettingFocusNode.dispose(); + + _minRSSISettingController.dispose(); + _minRSSISettingFocusNode.dispose(); + + // testing Framework: dispose text controller + _sessionIDTextController.dispose(); + _additionalDetailTextController.dispose(); + _querySessionTextController.dispose(); + _queryDeviceIndexTextController.dispose(); + + super.dispose(); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == Exposure.notifyStartStop) { + _updateConnection(null); + } else if (name == Exposure.notifyTEKsUpdated) { + _loadTEKs(); + } else if (name == Exposure.notifyExposureUpdated) { + _loadExposures(); + } else if (name == Exposure.notifyExposureThick) { + _updateConnection(param); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + "COVID-19 Exposure Logs", + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: Styles().fontFamilies.extraBold), + ), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeading(), + Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTEKs(), + Container( + height: 32, + ), + _buildExposures(), + Container( + height: 32, + ), + _buildTesting(), + ]), + ), + ], + ), + ), + ), + ], + ), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildHeading() { + String status; + bool canStart, canStop, canEdit; + if (!Exposure().isEnabled) { + status = 'disabled'; + canStart = canStop = canEdit = false; + } else { + status = Exposure().isStarted ? 'started' : 'stopped'; + canStart = !Exposure().isStarted; + canStop = Exposure().isStarted; + canEdit = !Exposure().isStarted; + } + + return Container( + color: Colors.white, + child: Padding( + padding: EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: < + Widget>[ + Row( + children: [ + Padding( + padding: EdgeInsets.only(right: 4), + child: Text( + 'Status: ', + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ), + Text( + status, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Color(0xff494949), + ), + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(right: 4), + child: Text( + 'Conn: ', + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ), + Text( + _connection ?? '', + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Color(0xff494949), + ), + ), + ], + ), + Padding( + padding: EdgeInsets.only(top: 8), + child: Row(children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(bottom: 4), + child: Text( + "Min RSSI", + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 12, + color: canEdit + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor), + ), + ), + TextField( + controller: _minRSSISettingController, + focusNode: _minRSSISettingFocusNode, + enabled: canEdit, + decoration: InputDecoration( + border: OutlineInputBorder( + borderSide: + BorderSide(color: Colors.black, width: 1.0)), + contentPadding: EdgeInsets.symmetric(horizontal: 8)), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: canEdit + ? Styles().colors.textBackground + : Styles().colors.disabledTextColor, + ), + ), + ], + ), + ), + Container( + width: 16, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(bottom: 4), + child: Text( + "Min Duration", + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 12, + color: canEdit + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor), + ), + ), + TextField( + controller: _minDurationSettingController, + focusNode: _minDurationSettingFocusNode, + enabled: canEdit, + decoration: InputDecoration( + border: OutlineInputBorder( + borderSide: + BorderSide(color: Colors.black, width: 1.0)), + contentPadding: EdgeInsets.symmetric(horizontal: 8)), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: canEdit + ? Styles().colors.textBackground + : Styles().colors.disabledTextColor, + ), + ), + ], + ), + ), + ]), + ), + Padding( + padding: EdgeInsets.only(top: 8), + child: Row( + children: [ + Expanded( + child: RoundedButton( + label: "Start", + textColor: canStart + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: canStart + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColorTwo, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + height: 42, + onTap: () { + _onStart(); + }), + ), + Container( + width: 16, + ), + Expanded( + child: RoundedButton( + label: "Stop", + textColor: canStop + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: canStop + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColorTwo, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + height: 42, + onTap: () { + _onStop(); + }), + ) + ], + ), + ), + ]), + ), + ); + } + + Widget _buildTEKs() { + List tekWidgets = []; + if (_teks != null) { + for (ExposureTEK tek in _teks) { + String time = AppDateTime() + .formatDateTime(tek.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + String expiretime = AppDateTime() + .formatDateTime(tek.expireUtc, format: 'MM/dd HH:mm:ss UTC'); + tekWidgets.add( + Row( + children: [ + Text( + "${tek.tek} | start: $time | expire: $expiretime", + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 12, + color: Color(0xff494949), + ), + ), + ], + ), + ); + } + } + + if (tekWidgets.isEmpty) { + tekWidgets.add( + Row( + children: [], + ), + ); + } + + List content = []; + content.add( + Text( + 'Local TEKs:', + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ); + content.add(Container( + height: 100, + decoration: BoxDecoration( + border: + Border.all(color: Styles().colors.fillColorPrimary, width: 1)), + child: Stack( + children: [ + SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: tekWidgets)), + ), + Align( + alignment: Alignment.topRight, + child: Semantics( + button: true, + label: 'Copy', + child: GestureDetector( + onTap: () { + _onCopyTEKs(); + }, + child: Container( + width: 36, + height: 36, + child: Align( + alignment: Alignment.center, + child: Semantics( + excludeSemantics: true, + child: Image.asset('images/icon-copy.png')), + ), + ), + ), + )), + ], + ), + )); + + int teksCount = _teks?.length ?? 0; + + content.add(Padding( + padding: EdgeInsets.only(top: 8), + child: Row( + children: [ + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'Pull', + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + height: 42, + onTap: () { + _onPullTEKs(); + }), + Visibility( + visible: (_poolingTEKs == true), + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 10.5), + child: Container( + width: 21, + height: 21, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Styles().colors.fillColorSecondary), + strokeWidth: 2, + )), + ), + ), + ), + ]), + ), + Container( + width: 8, + ), + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'Report', + textColor: (0 < teksCount) + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: (0 < teksCount) + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColor, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + height: 42, + onTap: () { + _onReportTEKs(); + }), + Visibility( + visible: (_reportingTEKs == true), + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 10.5), + child: Container( + width: 21, + height: 21, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Styles().colors.fillColorSecondary), + strokeWidth: 2, + )), + ), + ), + ), + ]), + ), + Container( + width: 8, + ), + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'Check', + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + height: 42, + onTap: () { + _onCheckTEKs(); + }), + Visibility( + visible: (_checkingTEKs == true), + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 10.5), + child: Container( + width: 21, + height: 21, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Styles().colors.fillColorSecondary), + strokeWidth: 2, + )), + ), + ), + ), + ]), + ), + ], + ), + )); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: content); + } + + Widget _buildExposures() { + List content = []; + + List exposureWidgets = []; + if (_exposures != null) { + for (ExposureRecord exposure in _exposures) { + String time = AppDateTime() + .formatDateTime(exposure.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + exposureWidgets.add( + Row( + children: [ + Text( + "RPI: ${exposure.rpi} \nTime: $time | Duration: ${exposure.durationDisplayString}", + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 12, + color: Color(0xff494949), + ), + ), + ], + ), + ); + } + } + if (exposureWidgets.isEmpty) { + exposureWidgets.add( + Row( + children: [], + ), + ); + } + + content.add( + Text( + 'Recorded Exposures:', + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ); + content.add(Container( + height: 100, + decoration: BoxDecoration( + border: + Border.all(color: Styles().colors.fillColorPrimary, width: 1)), + child: Stack( + children: [ + SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: exposureWidgets)), + ), + Align( + alignment: Alignment.topRight, + child: Semantics( + button: true, + label: 'Copy', + child: GestureDetector( + onTap: () { + _onCopyExposures(); + }, + child: Container( + width: 36, + height: 36, + child: Align( + alignment: Alignment.center, + child: Semantics( + excludeSemantics: true, + child: Image.asset('images/icon-copy.png')), + ), + ), + ), + )), + ], + ), + )); + + int exposuresCount = _exposures?.length ?? 0; + + content.add(Padding( + padding: EdgeInsets.only(top: 8), + child: Row( + children: [ + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'Check', + textColor: (0 < exposuresCount) + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: (0 < exposuresCount) + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColor, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + height: 42, + onTap: () { + _onCheckExposures(); + }), + Visibility( + visible: (_checkingExposures == true), + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 10.5), + child: Container( + width: 21, + height: 21, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Styles().colors.fillColorSecondary), + strokeWidth: 2, + )), + ), + ), + ), + ]), + ), + Container( + width: 16, + ), + Expanded( + child: RoundedButton( + label: 'Clear', + textColor: (0 < exposuresCount) + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: (0 < exposuresCount) + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColor, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + height: 42, + onTap: () { + _onClearExposures(); + }), + ), + ], + ), + )); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: content); + } + + void _loadTEKs() { + Exposure().loadTeks().then((List teks) { + setState(() { + _teks = teks; + }); + }); + } + + void _loadExposures() { + Exposure() + .loadLocalExposures(timestamp: Exposure.thresholdTimestamp) + .then((List exposures) { + setState(() { + _exposures = exposures; + }); + }); + } + + void _updateConnection(Map exposure) { + String rpi = (exposure != null) ? exposure['rpi'] : null; + int timestamp = (exposure != null) ? exposure['timestamp'] : null; + int rssi = (exposure != null) ? exposure['rssi'] : null; + String time = (timestamp != null) + ? AppDateTime().formatDateTime( + DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true), + format: 'MM/dd HH:mm:ss UTC') + : null; + setState(() { + _connection = + Exposure().isStarted ? "RPI: $rpi\nTime: $time | RSSI: $rssi" : ''; + }); + } + + void _onStart() { + int minDuration = int.tryParse(_minDurationSettingController.text); + if (minDuration == null) { + AppAlert.showDialogResult( + context, "Please enter an integer minimum duration") + .then((_) { + _minDurationSettingFocusNode.requestFocus(); + }); + return; + } + + int minRssi = int.tryParse(_minRSSISettingController.text); + if (minRssi == null) { + AppAlert.showDialogResult( + context, "Please enter an integer minimum RSSI value") + .then((_) { + _minRSSISettingFocusNode.requestFocus(); + }); + return; + } + + //_startSettings['covid19ExposureServiceMinDuration'] = minDuration; + Exposure().exposureMinDuration = minDuration; + _startSettings['covid19ExposureServiceMinRSSI'] = minRssi; + + Exposure().start(settings: _startSettings); + } + + void _onStop() { + Exposure().stop(); + } + + void _onPullTEKs() { + setState(() { + _poolingTEKs = true; + }); + Exposure() + .loadReportedTEKs(timestamp: Exposure.thresholdTimestamp) + .then((List result) { + setState(() { + _poolingTEKs = false; + }); + + String copy = ""; + int copied; + if (result != null) { + copied = 0; + for (ExposureTEK tek in result) { + String time = AppDateTime() + .formatDateTime(tek.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + copy += "${tek.tek} | $time\n"; + copied++; + } + Clipboard.setData(ClipboardData(text: copy)); + } + if (copied == null) { + AppAlert.showDialogResult(context, "Failed to pull reported."); + } else { + AppAlert.showDialogResult( + context, + (0 < copied) + ? "$copied entries copied to Clipboard." + : "No entries copied to Clipboard."); + } + }); + } + + void _onReportTEKs() { + setState(() { + _reportingTEKs = true; + }); + Exposure().reportTEKs(_teks).then((bool result) { + setState(() { + _reportingTEKs = false; + }); + _loadTEKs(); + AppAlert.showDialogResult( + context, result ? "Successfully reported" : "Failed to report"); + }); + } + + void _onCheckTEKs() { + setState(() { + _checkingTEKs = true; + }); + Exposure().checkReport().then((int result) { + setState(() { + _checkingTEKs = false; + }); + String message; + if (result == null) { + message = 'Failed to report TEKs'; + } else if (result == 0) { + message = "No TEKs reported"; + } else { + message = "$result ${(1 < result) ? 'TEKs' : 'TEK'} reported"; + } + + AppAlert.showDialogResult(context, message); + }); + } + + void _onCopyTEKs() { + String copy = ""; + int copied = 0; + if (_teks != null) { + for (ExposureTEK tek in _teks) { + String time = AppDateTime() + .formatDateTime(tek.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + copy += "${tek.tek} | $time\n"; + copied++; + } + Clipboard.setData(ClipboardData(text: copy)); + } + AppAlert.showDialogResult( + context, + (0 < copied) + ? "$copied entries copied to Clipboard" + : "No entries copied to Clipboard."); + } + + void _onCheckExposures() { + setState(() { + _checkingExposures = true; + }); + Exposure().checkExposures().then((int detectedCount) { + setState(() { + _checkingExposures = false; + }); + String message; + if (detectedCount == null) { + message = "Failed to check exposures."; + } else if (detectedCount == 0) { + message = "No exposures detected"; + } else { + message = + "Detected $detectedCount ${(detectedCount != 1) ? 'exposures' : 'exposure'}."; + } + + AppAlert.showDialogResult(context, message); + }); + } + + void _onClearExposures() { + Exposure().clearLocalExposures(); + } + + void _onCopyExposures() { + String copy = ""; + int copied = 0; + if (_exposures != null) { + for (ExposureRecord exposure in _exposures) { + String time = AppDateTime() + .formatDateTime(exposure.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + copy += + "${exposure.rpi} | Time: $time | Duration: ${exposure.durationDisplayString}\n"; + copied++; + } + Clipboard.setData(ClipboardData(text: copy)); + } + AppAlert.showDialogResult( + context, + (0 < copied) + ? "$copied entries copied to Clipboard" + : "No entries copied to Clipboard."); + } + +// testing related widget builder and functions + Widget _buildTesting() { + List content = []; + + content.add( + Text( + 'Available Session ID:', + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ); + content.add(Container( + height: 50, + child: TextField( + controller: _sessionIDTextController, + // keyboardType: TextInputType.multiline, + // maxLines: null, + maxLines: 1, + enabled: _isInSession == false, // can not change once in session + decoration: new InputDecoration.collapsed( + hintText: 'press Get Session ID or Type here'), + ), + )); + + // buttons + content.add(Padding( + padding: EdgeInsets.only(top: 8), + child: Row( + children: [ + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'Get Session ID', + backgroundColor: Styles().colors.white, + textColor: (_isInSession != true) + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: (_isInSession != true) + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColor, + fontFamily: Styles().fontFamilies.bold, + fontSize: 10, + borderWidth: 2, + height: 42, + onTap: _isInSession + ? null + : () { + _onSessionGet(); + }), + Visibility( + visible: (_processingSessionID == true), + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 10.5), + child: Container( + width: 21, + height: 21, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Styles().colors.fillColorSecondary), + strokeWidth: 2, + )), + ), + ), + ), + ]), + ), + Container( + width: 16, + ), + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'Create Session', + backgroundColor: Styles().colors.white, + textColor: (_isInSession != true) + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: (_isInSession != true) + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColor, + fontFamily: Styles().fontFamilies.bold, + fontSize: 10, + borderWidth: 2, + height: 42, + onTap: _isInSession + ? null + : () { + _onSessionCreate(); + }), + Visibility( + visible: (_processingSessionID == true), + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 10.5), + child: Container( + width: 21, + height: 21, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Styles().colors.fillColorSecondary), + strokeWidth: 2, + )), + ), + ), + ), + ]), + ), + Container( + width: 16, + ), + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'Join Session', + backgroundColor: Styles().colors.white, + textColor: (_isInSession != true) + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: (_isInSession != true) + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColor, + fontFamily: Styles().fontFamilies.bold, + fontSize: 10, + borderWidth: 2, + height: 42, + onTap: _isInSession + ? null + : () { + _onSessionJoin(); + }), + Visibility( + visible: (_processingSessionID == true), + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 10.5), + child: Container( + width: 21, + height: 21, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Styles().colors.fillColorSecondary), + strokeWidth: 2, + )), + ), + ), + ), + ]), + ), + Container( + width: 16, + ), + ], + ), + )); + + content.add( + Text( + 'Execution Status: $_executionStatus', + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ); + content.add(Container( + height: 32, + )); + // add additional text input field + content.add( + Text( + "additional details", + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ); + content.add(Container( + height: 100, + child: TextField( + controller: _additionalDetailTextController, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.done, + maxLines: null, + // onSubmitted: (String value) async { + // await showDialog( + // context: context, + // builder: (BuildContext context) { + // return AlertDialog( + // title: const Text('Thanks!'), + // content: Text('You typed "$value".'), + // actions: [ + // FlatButton( + // onPressed: () { + // Navigator.pop(context); + // }, + // child: const Text('OK'), + // ), + // ], + // ); + // }, + // ); + // }, + decoration: new InputDecoration.collapsed( + hintText: 'add additional detail before ending session'), + ), + )); + + content.add(Padding( + padding: EdgeInsets.only(top: 8), + child: Row(children: [ + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'End Session', + backgroundColor: Styles().colors.white, + textColor: (_isInSession == true) + ? Styles().colors.fillColorPrimary + : Styles().colors.disabledTextColor, + borderColor: (_isInSession == true) + ? Styles().colors.fillColorSecondary + : Styles().colors.disabledTextColor, + fontFamily: Styles().fontFamilies.bold, + fontSize: 10, + borderWidth: 2, + height: 42, + onTap: _isInSession + ? () { + _onEndSession(); + } + : null), + Visibility( + visible: (_processingSessionID == true), + child: Center( + child: Padding( + padding: EdgeInsets.only(top: 10.5), + child: Container( + width: 21, + height: 21, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Styles().colors.fillColorSecondary), + strokeWidth: 2, + )), + ), + ), + ), + ]), + ), + ]))); + + content.add( + Container( + height: 70, + ), + ); + + content.add( + Text( + 'Query Session ID: ', + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ); + + content.add(Container( + height: 50, + child: TextField( + controller: _querySessionTextController, + // keyboardType: TextInputType.multiline, + // maxLines: null, + decoration: + new InputDecoration.collapsed(hintText: 'query session id '), + ), + )); + + content.add( + Text( + 'Query Device Index: --(note: this device index is: $_thisDeviceIndex)', + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + ); + + content.add(Container( + height: 50, + child: TextField( + controller: _queryDeviceIndexTextController, + // keyboardType: TextInputType.multiline, + // maxLines: null, + decoration: + new InputDecoration.collapsed(hintText: 'query device Index'), + ), + )); + content.add(Padding( + padding: EdgeInsets.only(top: 8), + child: Row(children: [ + Expanded( + child: Stack(children: [ + RoundedButton( + label: 'Query Session Report', + backgroundColor: Styles().colors.white, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + fontFamily: Styles().fontFamilies.bold, + fontSize: 10, + borderWidth: 2, + height: 42, + onTap: () { + _onSessionReport(); + }, + ) + ]), + ), + ]))); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: content); + } + + // testing framework functions + + void _onSessionGet() async { + if (_processingSessionID) { + return; + } + setState(() { + _processingSessionID = true; // block all buttons + _executionStatus = "querying for session ID"; + }); + + Map headers = {"Content-type": "application/json"}; + + Response response = await Network().post(Url + "GetSessionID", + headers: headers, body: null, auth: NetworkAuth.App); + String body = response.body; + if (response.statusCode == HttpStatus.ok) { + // update text field + setState(() { + _executionStatus = "available session id is " + body; + _processingSessionID = false; + _sessionIDTextController.text = body; + _querySessionTextController.text = body; + }); + } else { + setState(() { + _executionStatus = "error "; + _processingSessionID = false; + }); + } + + return; + } + + /* + * _onSessionCreate() async: + * step0: check if there is a sessionID in the textfield + * step1: get device info and save to a struct. + * step2: attempt to upload device info to server + * step3: attempt to create new session + * */ + void _onSessionCreate() async { + if (_processingSessionID) { + return; + } + // step 0: check for sessionID + try { + _currentSession = int.parse(_sessionIDTextController.text); + } catch (e) { + setState(() { + _executionStatus = "failed, please enter a valid session ID or get one"; + }); + return; + } + // step1: get device info and save to a struct. + try { + await _gettingDeviceInfo(); + } catch (e) { + print(e); + return; + } + // step2: attempt to upload device info to server + // step3: and create a new session with this device in it. + setState(() { + _executionStatus = "Uploading Device Info"; + }); + Map headers = {"Content-type": "application/json"}; + String req = + '{"isAndroid": $_isAndroid, "deviceID": "$_deviceID", "sessionID":$_currentSession}'; + print("" + req); + Response response = await Network().post(Url + "CreateSession", + headers: headers, body: req, auth: NetworkAuth.App); + + // statusCode: + if (response.statusCode == HttpStatus.ok) { + var parsed = json.decode(response.body); + _isInSession = true; + // display parsed["message"] + // save parsed["deviceIndex"] + _thisDeviceIndex = parsed["deviceIndex"]; + _queryDeviceIndexTextController.text = _thisDeviceIndex; + _querySessionTextController.text = _sessionIDTextController.text; + _executionStatus = + response.statusCode.toString() + "" + parsed["message"]; + } else { + _isInSession = false; + _executionStatus = response.statusCode.toString() + " " + response.body; + } + setState(() { + // should update _executaionStatus and _isInSession + _processingSessionID = false; + }); + + if (_isInSession) { + Exposure().startLogSession(_currentSession); + } + return; + } + + /* + * _onSessinJoin() async: + * step0: check if there is a sessionID in the textfield + * step1: get device info and save to a struct. + * step2: attempt to upload device info to server + * step3: attempt to join + * */ + void _onSessionJoin() async { + // Response response = await Network() + // .post(Url + "getSessionID", body: post, auth: NetworkAuth.App); + if (_processingSessionID) { + return; + } + // step 0: check for sessionID + try { + _currentSession = int.parse(_sessionIDTextController.text); + } catch (e) { + setState(() { + _executionStatus = "failed, please enter a valid session ID or get one"; + }); + return; + } + + // step1: get device info and save to a struct. + try { + await _gettingDeviceInfo(); + } catch (e) { + print(e); + return; + } + // step2: attempt to upload device info to server + // step3: and create a new session with this device in it. + setState(() { + _executionStatus = "Uploading Device Info"; + }); + Map headers = {"Content-type": "application/json"}; + String req = + '{"isAndroid": $_isAndroid, "deviceID": "$_deviceID", "sessionID":$_currentSession}'; + print("" + req); + Response response = await Network().post(Url + "JoinSession", + headers: headers, body: req, auth: NetworkAuth.App); + // statusCode: + // if ok, parse js + // if not, print the value + if (response.statusCode == HttpStatus.ok) { + var parsed = json.decode(response.body); + _isInSession = true; + // display parsed["message"] + // save parsed["deviceIndex"] + _thisDeviceIndex = parsed["deviceIndex"]; + _queryDeviceIndexTextController.text = _thisDeviceIndex; + _querySessionTextController.text = _sessionIDTextController.text; + _executionStatus = + response.statusCode.toString() + "" + parsed["message"]; + } else { + _isInSession = false; + _executionStatus = response.statusCode.toString() + " " + response.body; + } + setState(() { + _processingSessionID = false; + }); + + if (_isInSession) { + Exposure().startLogSession(_currentSession); + } + return; + } + + /* + _onEndSession: + get current session id + get additional details + + * */ + void _onEndSession() async { + if (_processingSessionID) { + return; + } + setState(() { + _processingSessionID = true; + _executionStatus = "submitting additional detail and ending session"; + }); + + try { + _currentSession = int.parse(_sessionIDTextController.text); + } catch (e) { + setState(() { + _executionStatus = "failed, please enter a valid session ID or get one"; + }); + return; + } + // post sessoin data first!!! + Exposure().endLogSession(_deviceID, _isAndroid); + Map headers = {"Content-type": "application/json"}; + String req = '{"isAndroid": $_isAndroid, "deviceID": "$_deviceID",' + ' "sessionID":$_currentSession, "additionalDetail":"${_additionalDetailTextController.text}" }'; + print("" + req); + Response response = await Network().post(Url + "EndSession", + headers: headers, body: req, auth: NetworkAuth.App); + // update text field + setState(() { + if (response.statusCode == HttpStatus.ok) { + _executionStatus = response.statusCode.toString() + " " + response.body; + _processingSessionID = false; + _isInSession = false; + } else { + _executionStatus = response.statusCode.toString() + " " + response.body; + } + }); + return; + } + + Future _gettingDeviceInfo() async { + final DeviceInfoPlugin deviceInfoPlugin = new DeviceInfoPlugin(); + setState(() { + _executionStatus = "Getting Device Info"; + }); + _isAndroid = Platform.isAndroid; + if (_isAndroid) { + var build = await deviceInfoPlugin.androidInfo; + _deviceID = build.androidId; + } else { + var data = await deviceInfoPlugin.iosInfo; + _deviceID = data.identifierForVendor; + } + } + + void _onSessionReport() async { + // construct url for GET method + String url = QueryUrl; + // check for sessionID + String sessionID = _querySessionTextController.text; + String deviceIndex = _queryDeviceIndexTextController.text; + bool a = sessionID.length == 0; + bool b = deviceIndex.length == 0; + + if (a && b) { + // both index and sessionID is empty + } else if (a && (!b)) { + // sessionID is empty and deviceIndex is nonzerok + url += "deviceIndex=$deviceIndex"; + } else if ((!a) && b) { + // sessionID is nonempty and deviceIndex is zero + url += "sessionID=$sessionID"; + } else if ((!a) && (!b)) { + // both sessionId and deviceIndex are specified + url += "sessionID=$sessionID&deviceIndex=$deviceIndex"; + } + if (await url_launcher.canLaunch(url)) { + await url_launcher.launch(url); + } else { + throw 'Could not launch $url'; + } + + // await showDialog( + // context: context, + // builder: (BuildContext context) { + // return AlertDialog( + // title: const Text('Thanks!'), + // content: Text(url), + // actions: [ + // FlatButton( + // onPressed: () { + // Navigator.pop(context); + // }, + // child: const Text('OK'), + // ), + // ], + // ); + // }, + // ); // showDialog + } +} diff --git a/lib/ui/health/debug/Covid19DebugExposurePanel.dart b/lib/ui/health/debug/Covid19DebugExposurePanel.dart new file mode 100644 index 00000000..d91859b3 --- /dev/null +++ b/lib/ui/health/debug/Covid19DebugExposurePanel.dart @@ -0,0 +1,606 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:illinois/model/Exposure.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Exposure.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + +class Covid19DebugExposurePanel extends StatefulWidget { + + Covid19DebugExposurePanel(); + + @override + _Covid19DebugExposurePanelState createState() => _Covid19DebugExposurePanelState(); +} + +class _Covid19DebugExposurePanelState extends State implements NotificationsListener { + + TextEditingController _minDurationSettingController; + FocusNode _minDurationSettingFocusNode; + + TextEditingController _minRSSISettingController; + FocusNode _minRSSISettingFocusNode; + + List _teks; + List _exposures; + + bool _poolingTEKs; + bool _reportingTEKs; + bool _checkingTEKs; + bool _checkingExposures; + String _connection; + + Map _startSettings; + + @override + void initState() { + NotificationService().subscribe(this, [ + Exposure.notifyStartStop, + Exposure.notifyTEKsUpdated, + Exposure.notifyExposureUpdated, + Exposure.notifyExposureThick, + ]); + _startSettings = Map.from(Exposure().startSettings ?? Config().settings); + + //int minDuration = _startSettings['covid19ExposureServiceMinDuration']; + int minDuration = Exposure().exposureMinDuration; + _minDurationSettingController = TextEditingController(text: minDuration?.toString() ?? ''); + _minDurationSettingFocusNode = FocusNode(); + + int minRssi = _startSettings['covid19ExposureServiceMinRSSI']; + _minRSSISettingController = TextEditingController(text: minRssi?.toString() ?? ''); + _minRSSISettingFocusNode = FocusNode(); + + _loadTEKs(); + _loadExposures(); + + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + + _minDurationSettingController.dispose(); + _minDurationSettingFocusNode.dispose(); + + _minRSSISettingController.dispose(); + _minRSSISettingFocusNode.dispose(); + + super.dispose(); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + + if (name == Exposure.notifyStartStop) { + _updateConnection(null); + } + else if (name == Exposure.notifyTEKsUpdated) { + _loadTEKs(); + } + else if (name == Exposure.notifyExposureUpdated) { + _loadExposures(); + } + else if (name == Exposure.notifyExposureThick) { + _updateConnection(param); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text("COVID-19 Exposure", style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeading(), + Padding(padding: EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildTEKs(), + Container(height: 32,), + _buildExposures(), + ]), + ), + ], + ), + ), + ), + ), + ], + ), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildHeading() { + String status; + bool canStart, canStop, canEdit; + if (!Exposure().isEnabled) { + status = 'disabled'; + canStart = canStop = canEdit = false; + } + else { + status = Exposure().isStarted ? 'started' : 'stopped'; + canStart = !Exposure().isStarted; + canStop = Exposure().isStarted; + canEdit = !Exposure().isStarted; + } + + return Container(color:Colors.white, + child: Padding(padding: EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, + children:[ + Row(children: [ + Padding(padding: EdgeInsets.only(right: 4), child: Text('Status: ', style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),),), + Text(status, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Color(0xff494949),),), + ],), + + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(right: 4), child: Text('Conn: ', style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),),), + Text(_connection ?? '', style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Color(0xff494949),),), + ],), + + Padding(padding: EdgeInsets.only(top: 8), child: + Row(children: [ + Expanded(child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text("Min RSSI", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: canEdit ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor),), + ), + TextField( + controller: _minRSSISettingController, + focusNode: _minRSSISettingFocusNode, + enabled: canEdit, + decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0)), contentPadding: EdgeInsets.symmetric(horizontal: 8)), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: canEdit ? Styles().colors.textBackground : Styles().colors.disabledTextColor,), + ), + ],), + ), + Container(width: 16,), + Expanded(child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text("Min Duration", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: canEdit ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor),), + ), + TextField( + controller: _minDurationSettingController, + focusNode: _minDurationSettingFocusNode, + enabled: canEdit, + decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0)), contentPadding: EdgeInsets.symmetric(horizontal: 8)), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: canEdit ? Styles().colors.textBackground : Styles().colors.disabledTextColor,), + ), + ],), + ), + ]), + ), + + Padding(padding: EdgeInsets.only(top: 8), child: + Row(children: [ + Expanded(child: + RoundedButton(label:"Start", + textColor: canStart ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: canStart ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColorTwo, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onStart(); } + ), + ), + Container(width: 16,), + Expanded(child: + RoundedButton(label:"Stop", + textColor: canStop ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: canStop ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColorTwo, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onStop(); } + ), + ) + ],), + ), + + ]), + ), + ); + } + + Widget _buildTEKs() { + + List tekWidgets = []; + if (_teks != null) { + for (ExposureTEK tek in _teks) { + String time = AppDateTime().formatDateTime(tek.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + String expiretime = AppDateTime().formatDateTime(tek.expireUtc, format: 'MM/dd HH:mm:ss UTC'); + tekWidgets.add(Row(children: [Text("${tek.tek} | start: $time | expire: $expiretime", style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 12, color: Color(0xff494949),),),],),); + } + } + + if (tekWidgets.isEmpty) { + tekWidgets.add(Row(children: [],),); + } + + List content = []; + content.add(Text('Local TEKs:', style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),),); + content.add(Container(height: 100, decoration: BoxDecoration(border: Border.all(color: Styles().colors.fillColorPrimary, width: 1)), child: + Stack(children: [ + SingleChildScrollView(child: + Padding(padding: EdgeInsets.all(4), child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: tekWidgets) + ), + ), + Align(alignment: Alignment.topRight, + child: Semantics (button: true, label: 'Copy', + child: GestureDetector(onTap: () { _onCopyTEKs(); }, + child: Container(width: 36, height: 36, + child: Align(alignment: Alignment.center, + child: Semantics( excludeSemantics: true, child: Image.asset('images/icon-copy.png')), + ), + ), + ), + )), + ],), + )); + + int teksCount = _teks?.length ?? 0; + + content.add(Padding(padding: EdgeInsets.only(top: 8), child: + Row(children: [ + Expanded(child: + Stack(children: [ + RoundedButton(label: 'Pull', + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onPullTEKs(); } + ), + Visibility(visible: (_poolingTEKs == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height: 21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ]), + ), + Container(width: 8,), + Expanded(child: + Stack(children: [ + RoundedButton(label: 'Report', + textColor: (0 < teksCount) ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: (0 < teksCount) ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColor, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onReportTEKs(); } + ), + Visibility(visible: (_reportingTEKs == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height: 21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ]), + ), + Container(width: 8,), + Expanded(child: + Stack(children: [ + RoundedButton(label: 'Check', + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onCheckTEKs(); } + ), + Visibility(visible: (_checkingTEKs == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height: 21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ]), + ), + ],), + )); + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: content); + } + + + Widget _buildExposures() { + List content = []; + + List exposureWidgets = []; + if (_exposures != null) { + for (ExposureRecord exposure in _exposures) { + String time = AppDateTime().formatDateTime(exposure.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + exposureWidgets.add(Row(children: [Text("RPI: ${exposure.rpi} \nTime: $time | Duration: ${exposure.durationDisplayString}", style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 12, color: Color(0xff494949),),),],),); + } + } + if (exposureWidgets.isEmpty) { + exposureWidgets.add(Row(children: [],),); + } + + content.add(Text('Recorded Exposures:', style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),),); + content.add(Container(height: 100, decoration: BoxDecoration(border: Border.all(color: Styles().colors.fillColorPrimary, width: 1)), child: + Stack(children: [ + SingleChildScrollView(child: + Padding(padding: EdgeInsets.all(4), child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: exposureWidgets) + ), + ), + Align(alignment: Alignment.topRight, + child: Semantics (button: true, label: 'Copy', + child: GestureDetector(onTap: () { _onCopyExposures(); }, + child: Container(width: 36, height: 36, + child: Align(alignment: Alignment.center, + child: Semantics( excludeSemantics: true, child: Image.asset('images/icon-copy.png')), + ), + ), + ), + )), + ],), + )); + + int exposuresCount = _exposures?.length ?? 0; + + content.add(Padding(padding: EdgeInsets.only(top: 8), child: + Row(children: [ + Expanded(child: + Stack(children: [ + RoundedButton(label: 'Check', + textColor: (0 < exposuresCount) ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: (0 < exposuresCount) ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColor, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onCheckExposures(); } + ), + Visibility(visible: (_checkingExposures == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height: 21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ]), + ), + Container(width: 16,), + Expanded(child: + RoundedButton(label: 'Clear', + textColor: (0 < exposuresCount) ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: (0 < exposuresCount) ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColor, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onClearExposures(); } + ), + ), + ],), + + )); + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: content); + } + + void _loadTEKs() { + Exposure().loadTeks().then((List teks) { + setState(() { + _teks = teks; + }); + }); + } + + void _loadExposures() { + Exposure().loadLocalExposures(timestamp: Exposure.thresholdTimestamp).then((List exposures) { + setState(() { + _exposures = exposures; + }); + }); + } + + void _updateConnection(Map exposure) { + + String rpi = (exposure != null) ? exposure['rpi'] : null; + int timestamp = (exposure != null) ? exposure['timestamp'] : null; + int rssi = (exposure != null) ? exposure['rssi'] : null; + String time = (timestamp != null) ? AppDateTime().formatDateTime(DateTime.fromMillisecondsSinceEpoch(timestamp, isUtc: true), format: 'MM/dd HH:mm:ss UTC') : null; + setState(() { + _connection = Exposure().isStarted ? "RPI: $rpi\nTime: $time | RSSI: $rssi" : ''; + }); + + } + + void _onStart() { + + int minDuration = int.tryParse(_minDurationSettingController.text); + if (minDuration == null) { + AppAlert.showDialogResult(context, "Please enter an integer minimum duration").then((_) { + _minDurationSettingFocusNode.requestFocus(); + }); + return; + } + + int minRssi = int.tryParse(_minRSSISettingController.text); + if (minRssi == null) { + AppAlert.showDialogResult(context, "Please enter an integer minimum RSSI value").then((_) { + _minRSSISettingFocusNode.requestFocus(); + }); + return; + } + + //_startSettings['covid19ExposureServiceMinDuration'] = minDuration; + Exposure().exposureMinDuration = minDuration; + _startSettings['covid19ExposureServiceMinRSSI'] = minRssi; + + Exposure().start(settings: _startSettings); + } + + void _onStop() { + Exposure().stop(); + } + + void _onPullTEKs() { + setState(() { + _poolingTEKs = true; + }); + Exposure().loadReportedTEKs(timestamp: Exposure.thresholdTimestamp).then((List result) { + setState(() { + _poolingTEKs = false; + }); + + String copy = ""; + int copied; + if (result != null) { + copied = 0; + for (ExposureTEK tek in result) { + String time = AppDateTime().formatDateTime(tek.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + copy += "${tek.tek} | $time\n"; + copied++; + } + Clipboard.setData(ClipboardData(text: copy)); + } + if (copied == null) { + AppAlert.showDialogResult(context, "Failed to pull reported."); + } else { + AppAlert.showDialogResult(context, (0 < copied) ? "$copied entries copied to Clipboard." : "No entries copied to Clipboard."); + } + }); + } + + void _onReportTEKs() { + setState(() { + _reportingTEKs = true; + }); + Exposure().reportTEKs(_teks).then((bool result) { + setState(() { + _reportingTEKs = false; + }); + _loadTEKs(); + AppAlert.showDialogResult(context, result ? "Successfully reported" : "Failed to report"); + }); + } + + void _onCheckTEKs() { + setState(() { + _checkingTEKs = true; + }); + Exposure().checkReport().then((int result) { + setState(() { + _checkingTEKs = false; + }); + String message; + if (result == null) { + message = 'Failed to report TEKs'; + } + else if (result == 0) { + message = "No TEKs reported"; + } + else { + message = "$result ${(1 < result) ? 'TEKs' : 'TEK'} reported"; + } + + AppAlert.showDialogResult(context, message); + }); + } + + void _onCopyTEKs() { + String copy = ""; + int copied = 0; + if (_teks != null) { + for (ExposureTEK tek in _teks) { + String time = AppDateTime().formatDateTime(tek.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + copy += "${tek.tek} | $time\n"; + copied++; + } + Clipboard.setData(ClipboardData(text: copy)); + } + AppAlert.showDialogResult(context, (0 < copied) ? "$copied entries copied to Clipboard" : "No entries copied to Clipboard."); + } + + void _onCheckExposures() { + setState(() { + _checkingExposures = true; + }); + Exposure().checkExposures().then((int detectedCount) { + setState(() { + _checkingExposures = false; + }); + String message; + if (detectedCount == null) { + message = "Failed to check exposures."; + } + else if (detectedCount == 0) { + message = "No exposures detected"; + } + else { + message = "Detected $detectedCount ${ (detectedCount != 1) ? 'exposures' : 'exposure' }."; + } + + AppAlert.showDialogResult(context, message); + }); + } + + void _onClearExposures() { + Exposure().clearLocalExposures(); + } + + void _onCopyExposures() { + String copy = ""; + int copied = 0; + if (_exposures != null) { + for (ExposureRecord exposure in _exposures) { + String time = AppDateTime().formatDateTime(exposure.dateUtc, format: 'MM/dd HH:mm:ss UTC'); + copy += "${exposure.rpi} | Time: $time | Duration: ${exposure.durationDisplayString}\n"; + copied++; + } + Clipboard.setData(ClipboardData(text: copy)); + } + AppAlert.showDialogResult(context, (0 < copied) ? "$copied entries copied to Clipboard" : "No entries copied to Clipboard."); + } +} diff --git a/lib/ui/health/debug/Covid19DebugKeysPanel.dart b/lib/ui/health/debug/Covid19DebugKeysPanel.dart new file mode 100644 index 00000000..2df880cc --- /dev/null +++ b/lib/ui/health/debug/Covid19DebugKeysPanel.dart @@ -0,0 +1,553 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:barcode_scan/barcode_scan.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Covid19.dart'; +import 'package:illinois/utils/Crypt.dart'; +import 'package:illinois/utils/Utils.dart'; +import "package:pointycastle/export.dart" as PointyCastle; + +class Covid19DebugKeysPanel extends StatefulWidget { + + Covid19DebugKeysPanel(); + + @override + _Covid19DebugKeysPanelState createState() => _Covid19DebugKeysPanelState(); +} + +class _Covid19DebugKeysPanelState extends State { + + TextEditingController _rsaPublicKeyController; + PointyCastle.PublicKey _rsaPublicKey; + + TextEditingController _rsaPrivateKeyController; + PointyCastle.PrivateKey _rsaPrivateKey; + + bool _refreshingRSAKeys; + String _rsaKeysStatus; + + TextEditingController _aesKeyController; + TextEditingController _blobController; + TextEditingController _encryptedAesKeyController; + TextEditingController _encryptedBlobController; + TextEditingController _decryptedAesKeyController; + TextEditingController _decryptedBlobController; + + @override + void initState() { + + _rsaPublicKeyController = TextEditingController(); + Health().loadRSAPublicKey().then((PointyCastle.PublicKey rsaPublicKey) { + if (mounted) { + setState(() { + _rsaPublicKey = rsaPublicKey; + _rsaPublicKeyController.text = (_rsaPublicKey != null) ? RsaKeyHelper.encodePublicKeyToPemPKCS1(_rsaPublicKey) : "- NA -"; + }); + _updateRsaKeysStatus(); + } + }); + + _rsaPrivateKeyController = TextEditingController(); + Health().loadRSAPrivateKey().then((PointyCastle.PrivateKey rsaPrivateKey) { + if (mounted) { + setState(() { + _rsaPrivateKey = rsaPrivateKey; + _rsaPrivateKeyController.text = (_rsaPrivateKey != null) ? RsaKeyHelper.encodePrivateKeyToPemPKCS1(_rsaPrivateKey) : "- NA -"; + }); + _updateRsaKeysStatus(); + } + }); + + _aesKeyController = TextEditingController(); + _blobController = TextEditingController(); + + _encryptedAesKeyController = TextEditingController(); + _encryptedBlobController = TextEditingController(); + + _decryptedAesKeyController = TextEditingController(); + _decryptedBlobController = TextEditingController(); + + super.initState(); + } + + @override + void dispose() { + _rsaPublicKeyController.dispose(); + _rsaPrivateKeyController.dispose(); + + _aesKeyController.dispose(); + _blobController.dispose(); + + _encryptedAesKeyController.dispose(); + _encryptedBlobController.dispose(); + + _decryptedAesKeyController.dispose(); + _decryptedBlobController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.health.covid19.debug.keys.heading.title","COVID-19 Keys"), style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding(padding: EdgeInsets.all(16), + child: _buildContent() + ), + ], + ), + ), + ), + ), + ], + ), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildContent() { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + + Text(Localization().getStringEx("panel.health.covid19.debug.keys.label.public_key","RSA Public Key:") , style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + TextField(controller: _rsaPublicKeyController, maxLines: 6, readOnly: true, decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + Padding(padding: EdgeInsets.only(top: 4), child: Container(),), + Text(Localization().getStringEx("panel.health.covid19.debug.keys.label.private_key","RSA Private Key:") , style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + TextField(controller: _rsaPrivateKeyController, maxLines: 6, readOnly: true, decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + Padding(padding: EdgeInsets.only(top: 4), child: Container(),), + Row(children: [ + Text("RSA Keys Status: " , style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + Text(_rsaKeysStatus ?? '', style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground),), + ],), + Padding(padding: EdgeInsets.only(top: 8), child: Container(),), + _buildRSAKeys1(), + Padding(padding: EdgeInsets.only(top: 8), child: Container(),), + _buildRSAKeys2(), + Padding(padding: EdgeInsets.only(top: 8), child: Container(),), + _buildRSAKeys3(), + + Padding(padding: EdgeInsets.only(top: 16), child: Container(),), + + Text(Localization().getStringEx("panel.health.covid19.debug.keys.label.aes_key","AES Key:") , style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + TextField(controller: _aesKeyController, maxLines: 1, decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + Padding(padding: EdgeInsets.only(top: 8), child: Container(),), + _buildGenerateAESKey(), + + Padding(padding: EdgeInsets.only(top: 8), child: Container(),), + + Text(Localization().getStringEx("panel.health.covid19.debug.keys.label.blob","Blob:") , style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + TextField(controller: _blobController, maxLines: 6, decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + Padding(padding: EdgeInsets.only(top: 8), child: Container(),), + _buildEncrypt(), + + Padding(padding: EdgeInsets.only(top: 16), child: Container(),), + + Text(Localization().getStringEx("panel.health.covid19.debug.keys.label.encripted_aes","Encrypted AES Key:") , style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + TextField(controller: _encryptedAesKeyController, maxLines: 6, decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + Padding(padding: EdgeInsets.only(top: 4), child: Container(),), + Text(Localization().getStringEx("panel.health.covid19.debug.keys.label.encripted_blob","Encrypted Blob:"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + TextField(controller: _encryptedBlobController, maxLines: 6, decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + Padding(padding: EdgeInsets.only(top: 8), child: Container(),), + _buildDecrypt(), + + Padding(padding: EdgeInsets.only(top: 16), child: Container(),), + + Text(Localization().getStringEx("panel.health.covid19.debug.keys.label.decripted_aes","Decrypted AES Key:") , style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + TextField(controller: _decryptedAesKeyController, maxLines: 1, readOnly: true, decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + Padding(padding: EdgeInsets.only(top: 4), child: Container(),), + Text(Localization().getStringEx("panel.health.covid19.debug.keys.label.decripted_blob","Decrypted Blob:"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + TextField(controller: _decryptedBlobController, maxLines: 6, readOnly: true, decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0))), style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,),), + + Padding(padding: EdgeInsets.only(top: 16), child: Container(),), + + ],); + } + + Widget _buildRSAKeys1() { + return Row(children: [ + Expanded(child: + Stack(children: [ + RoundedButton(label: "Refresh Pair", + textColor: (_refreshingRSAKeys != true) ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: (_refreshingRSAKeys != true) ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColorTwo, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onRefreshRSAKeys(); } + ), + Visibility(visible: (_refreshingRSAKeys == true), child: + Padding(padding: EdgeInsets.only(left: (200 - 21) / 2), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height:21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ), + ],), + ), + Container(width: 16,), + Expanded(child: + Container(), + ), + ],); + } + + Widget _buildRSAKeys2() { + return Row(children: [ + Expanded(child: + RoundedButton(label: "Scan", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onScanPrivateRSAKey(); } + ), + ), + Container(width: 16,), + Expanded(child: + RoundedButton(label: "Load", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onLoadPrivateRSAKey(); } + ), + ), + ],); + } + + Widget _buildRSAKeys3() { + return Row(children: [ + Expanded(child: + RoundedButton(label: "Save", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onSavePrivateRSAKey(); } + ), + ), + Container(width: 16,), + Expanded(child: + RoundedButton(label: "Clear", + textColor: (_rsaPrivateKey != null) ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColor, + borderColor: (_rsaPrivateKey != null) ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColorTwo, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, borderWidth: 2, height: 42, + onTap:() { _onClearPrivateRSAKey(); } + ), + ), + ],); + } + + Widget _buildGenerateAESKey() { + return Row(children: [ + RoundedButton(label: Localization().getStringEx("panel.health.covid19.debug.keys.button.generate_aes.title","Generate AES Key"), + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + width: 200, + height: 42, + onTap:() { _onGenerateAESKey(); } + ), + ],); + } + + Widget _buildEncrypt() { + return Row(children: [ + RoundedButton(label: Localization().getStringEx("panel.health.covid19.debug.keys.button.encript.title","Encrypt"), + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + width: 200, + height: 42, + onTap:() { _onEncrypt(); } + ), + ],); + } + + Widget _buildDecrypt() { + return Row(children: [ + RoundedButton(label: Localization().getStringEx("panel.health.covid19.debug.keys.button.decript.title","Decrypt"), + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + borderWidth: 2, + width: 200, + height: 42, + onTap:() { _onDecrypt(); } + ), + ],); + } + + void _updateRsaKeysStatus() { + String status; + if ((_rsaPublicKey != null) && (_rsaPrivateKey != null)) { + RsaKeyHelper.verifyRsaKeyPair(PointyCastle.AsymmetricKeyPair(_rsaPublicKey, _rsaPrivateKey)).then((bool result) { + if (mounted) { + setState(() { + switch (result) { + case true: _rsaKeysStatus = 'Paired'; break; + case false: _rsaKeysStatus = 'Unpaired'; break; + default: _rsaKeysStatus = 'Internal Error Occured'; break; + } + }); + } + }); + } + else if ((_rsaPublicKey == null) && (_rsaPrivateKey == null)) { + status = 'NA'; + } + else if ((_rsaPublicKey != null) && (_rsaPrivateKey == null)) { + status = 'Missing private key'; + } + else if ((_rsaPublicKey == null) && (_rsaPrivateKey != null)) { + status = 'Missing public key'; + } + + if (mounted && (status != null) && (_rsaKeysStatus != status)) { + setState(() { + _rsaKeysStatus = status; + }); + } + } + + void _onRefreshRSAKeys() { + Analytics.instance.logSelect( target: "Refresh RSA Keys"); + setState(() { + _refreshingRSAKeys = true; + }); + + Health().refreshRSAKeys().then((PointyCastle.AsymmetricKeyPair rsaKeys) { + if (mounted) { + setState(() { + _refreshingRSAKeys = false; + if (rsaKeys != null) { + _rsaPublicKey = rsaKeys.publicKey; + _rsaPublicKeyController.text = (_rsaPublicKey != null) ? RsaKeyHelper.encodePublicKeyToPemPKCS1(_rsaPublicKey) : "- NA -"; + + _rsaPrivateKey = rsaKeys.privateKey; + _rsaPrivateKeyController.text = (_rsaPrivateKey != null) ? RsaKeyHelper.encodePrivateKeyToPemPKCS1(_rsaPrivateKey) : "- NA -"; + + _updateRsaKeysStatus(); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx("panel.health.covid19.debug.keys.label.error.refres.title","Refresh Failed")); + } + }); + + } + }); + } + + void _onClearPrivateRSAKey() { + Health().setUserRSAPrivateKey(null).then((bool result){ + if (mounted) { + if (result) { + setState(() { + _rsaPrivateKey = null; + _rsaPrivateKeyController.text = "- NA -"; + }); + _updateRsaKeysStatus(); + } + else { + AppAlert.showDialogResult(context, "Clear Failed"); + } + } + }); + } + + void _onScanPrivateRSAKey() { + BarcodeScanner.scan().then((String result) { + // barcode_scan plugin returns 8 digits when it cannot read the qr code. Prevent it from storing such values + if (AppString.isStringEmpty(result) || (result.length <= 8)) { + AppAlert.showDialogResult(context, 'Failed to read QR code.'); + } + else { + _applyPrivateRsaKeyString(result); + } + }); + } + + void _onLoadPrivateRSAKey() { + Covid19Utils.loadQRCodeImageFromPictures().then((String qrCodeString) { + _applyPrivateRsaKeyString(qrCodeString); + }); + } + + void _onSavePrivateRSAKey() { + String privateKeyString = (_rsaPrivateKey != null) ? RsaKeyHelper.encodePrivateKeyToPemPKCS1(_rsaPrivateKey) : null; + if (privateKeyString != null) { + NativeCommunicator().getBarcodeImageData({ + 'content': privateKeyString, + 'format': 'qrCode', + 'width': 1024, + 'height': 1024, + }).then((Uint8List qrCodeBytes) { + if (qrCodeBytes != null) { + Covid19Utils.saveQRCodeImageToPictures(qrCodeBytes: qrCodeBytes, title: Localization().getStringEx("panel.covid19.transfer.label.qr_image_label", "Safer Illinois COVID-19 Code")).then((bool result) { + AppAlert.showDialogResult(context, result ? 'QR Code Image saved.' : 'Failed to save QR Code image.'); + }); + } + else { + AppAlert.showDialogResult(context, 'Failed to build QR Code image.'); + } + }); + } + else { + AppAlert.showDialogResult(context, 'No RSA private key.'); + } + } + + void _applyPrivateRsaKeyString(String result) { + + PointyCastle.PrivateKey privateKey; + try { + Uint8List pemCompressedData = (result != null) ? base64.decode(result) : null; + List pemData = (pemCompressedData != null) ? GZipDecoder().decodeBytes(pemCompressedData) : null; + privateKey = (pemData != null) ? RsaKeyHelper.parsePrivateKeyFromPemData(pemData) : null; + } + catch (e) { + print(e?.toString()); + } + + if (privateKey != null) { + RsaKeyHelper.verifyRsaKeyPair(PointyCastle.AsymmetricKeyPair(_rsaPublicKey, privateKey)).then((bool result) { + if (mounted) { + if (result == true) { + Health().setUserRSAPrivateKey(privateKey).then((success) { + if (mounted) { + if (success) { + _rsaPrivateKey = privateKey; + _rsaPrivateKeyController.text = (_rsaPrivateKey != null) ? RsaKeyHelper.encodePrivateKeyToPemPKCS1(_rsaPrivateKey) : "- NA -"; + _updateRsaKeysStatus(); + } + else { + AppAlert.showDialogResult(context, "Failed to transfer COVID-19 secret."); + } + } + }); + } + else { + AppAlert.showDialogResult(context, 'COVID-19 secret key does not match existing public RSA key.'); + } + } + }); + } + else { + AppAlert.showDialogResult(context, 'Invalid QR code.'); + } + } + + void _onGenerateAESKey() { + Analytics.instance.logSelect(target: "Generate AES Keys"); + _aesKeyController.text = AESCrypt.randomKey(); + } + + void _onEncrypt() { + Analytics.instance.logSelect(target: "Decript"); + if (_rsaPublicKey == null) { + AppAlert.showDialogResult(context, 'Missing Public RSA Key'); + return; + } + + if (_rsaPrivateKey == null) { + AppAlert.showDialogResult(context, 'Missing Private RSA Key'); + return; + } + + String aesKey = _aesKeyController.text; + if ((aesKey == null) || aesKey.isEmpty) { + AppAlert.showDialogResult(context, 'Missing AES Key'); + return; + } + + String blob = _blobController.text; + if ((blob == null) || blob.isEmpty) { + AppAlert.showDialogResult(context, 'Missing Blob'); + return; + } + + String encryptedBlob = AESCrypt.encrypt(blob, aesKey); + String encryptedAESKey = (encryptedBlob != null) ? RSACrypt.encrypt(aesKey, _rsaPublicKey) : null; + + _encryptedAesKeyController.text = encryptedAESKey ?? 'NA'; + _encryptedBlobController.text = encryptedBlob ?? 'NA'; + + } + + void _onDecrypt() { + Analytics.instance.logSelect(target: "Decript"); + String encryptedBlob = _encryptedBlobController.text; + if ((encryptedBlob == null) || (encryptedBlob == 'NA') /*|| encryptedBlob.isEmpty*/) { + AppAlert.showDialogResult(context, 'Missing Encrypted Blob'); + return; + } + + String encryptedAESKey = _encryptedAesKeyController.text; + if ((encryptedAESKey == null) || (encryptedAESKey == 'NA') || encryptedAESKey.isEmpty) { + AppAlert.showDialogResult(context, 'Missing Encrypted AES Key'); + return; + } + + + String decryptedAESKey = (encryptedAESKey != null) ? RSACrypt.decrypt(encryptedAESKey, _rsaPrivateKey) : null; + String decryptedBlob = ((decryptedAESKey != null) && (encryptedBlob != null)) ? AESCrypt.decrypt(encryptedBlob, decryptedAESKey) : null; + + _decryptedAesKeyController.text = decryptedAESKey ?? 'NA'; + _decryptedBlobController.text = decryptedBlob ?? 'NA'; + + } + +} + diff --git a/lib/ui/health/debug/Covid19DebugPendingEventsPanel.dart b/lib/ui/health/debug/Covid19DebugPendingEventsPanel.dart new file mode 100644 index 00000000..e61979f2 --- /dev/null +++ b/lib/ui/health/debug/Covid19DebugPendingEventsPanel.dart @@ -0,0 +1,274 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class Covid19DebugPendingEventsPanel extends StatefulWidget { + + Covid19DebugPendingEventsPanel(); + + @override + _Covid19DebugPendingEventsPanelState createState() => _Covid19DebugPendingEventsPanelState(); +} + +class _Covid19DebugPendingEventsPanelState extends State { + + List _events; + Map _providers; + bool _loadingEvents; + bool _clearingEvents; + bool _processingEvents; + String _status; + + @override + void initState() { + super.initState(); + _loadEvents(); + _loadProviders(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget content; + if (_loadingEvents == true) { + content = _buildLoading(); + } + else if (_status != null) { + content = _buildStatus(_status); + } + else if ((_events == null) || (_events.length == 0)) { + content = _buildStatus("No pending events"); + } + else { + content = _buildContent(); + } + + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text("COVID-19 Events", style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + content + ], + ), + ), + ), + ), + ], + ), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildLoading() { + return Padding(padding:EdgeInsets.symmetric(vertical: 200), child: + Align(alignment: Alignment.center, child: + Container(width: 42, height: 42, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary), strokeWidth: 2,) + ), + ), + ); + } + + Widget _buildStatus(String text) { + return Padding(padding: EdgeInsets.only(left: 32, right:32, top: 200), + child:Align(alignment: Alignment.center, child: + Text(text, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ), + ); + } + + Widget _buildContent() { + List results = []; + for (Covid19Event event in _events) { + results.add(_buildEvent(event)); + } + results.add(_buildProcess()); + results.add(_buildClear()); + return Column(children: results,); + } + + Widget _buildEvent(Covid19Event event) { + DateTime date = event.dateUpdated ?? event.dateCreated; + String displayDate = AppDateTime().formatDateTime(date, format: "MMM dd, HH:mma") ?? ''; + String providerName = (_providers != null) ? _providers[event.providerId]?.name : null; + String providerLabel = (providerName != null) ? Localization().getStringEx("panel.health.covid19.debug.test.label.provider","Provider: ") : + Localization().getStringEx("panel.health.covid19.debug.test.label.provider_id","Provider Id: "); + String providerValue = ((providerName != null) ? providerName : event.providerId) ?? ''; + + return Padding(padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Semantics(container: true, child: + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Styles().colors.white, + boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, spreadRadius: 1.0, blurRadius: 3.0, offset: Offset(1, 1))], + borderRadius: BorderRadius.all(Radius.circular(4)) + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding:EdgeInsets.only(bottom: 4), child: Row(children: [ + Text(providerLabel , style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + Text(providerValue, overflow: TextOverflow.ellipsis, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ],) + ), + Row(children: [ + Text(Localization().getStringEx("panel.health.covid19.debug.test.label.date","Date: "), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + Text(displayDate, overflow: TextOverflow.ellipsis, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ],), + Padding(padding:EdgeInsets.only(top: 8), child:Semantics(label: "blob", child:Text("${AppJson.encode(event?.blob?.toJson())}", style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface),),)), + ]), + ) + ) + ); + } + + Widget _buildProcess() { + return Padding(padding: EdgeInsets.only(top: 24, left: 24, right: 24), + child: Stack(children: [ + RoundedButton(label: Localization().getStringEx("panel.health.covid19.debug.test.button.process.title","Process"), hint: '', backgroundColor: Styles().colors.background, borderColor: Styles().colors.fillColorSecondary, textColor: Styles().colors.fillColorPrimary, onTap: () => _onProcess()), + Visibility(visible: (_processingEvents == true), + child: Container(height: 48, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + ],), + ); + } + + Widget _buildClear() { + return Padding(padding: EdgeInsets.only(top: 24, bottom: 24, left: 24, right: 24), + child: Stack(children: [ + RoundedButton(label: Localization().getStringEx("panel.health.covid19.debug.test.button.clear.title","Clear"), hint: '', backgroundColor: Styles().colors.background, borderColor: Styles().colors.fillColorSecondary, textColor: Styles().colors.fillColorPrimary, onTap: () => _onClear()), + Visibility(visible: (_clearingEvents == true), + child: Container(height: 48, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + ],), + ); + } + + Future _loadEvents() async { + setState(() { + _loadingEvents = true; + }); + + List events = await Health().loadCovid19Events(processed: false); + setState(() { + _loadingEvents = false; + _events = events; + if (events == null) { + _status = "Failed to load events"; + } + }); + } + + Future _loadProviders() async { + List providers = await Health().loadHealthServiceProviders(); + setState(() { + if (providers != null) { + _providers = Map(); + for (HealthServiceProvider provider in providers) { + _providers[provider.id] = provider; + } + } + }); + } + + Future _clearEvents() async { + setState(() { + _clearingEvents = true; + }); + bool result = await Health().clearCovid19Tests(); + setState(() { + _clearingEvents = false; + }); + if (result) { + await _loadEvents(); + } + else { + AppAlert.showDialogResult(context, "Failed to clear COVID-19 events."); + } + } + + void _onClear() { + Analytics.instance.logSelect(target: "Clear"); + _clearEvents(); + } + + Future _processEvents() async { + setState(() { + _processingEvents = true; + }); + await Health().currentCountyStatus.then((_){ + + setState(() { + _processingEvents = false; + _loadingEvents = true; + }); + + Health().loadCovid19Events(processed: false).then((List events) { + setState(() { + _loadingEvents = false; + _events = events; + if (events == null) { + _status = "Failed to load events"; + } + }); + //if ((events?.length ?? 0) == 0) { + // Navigator.pop(context); + //} + }); + }); + } + + void _onProcess() { + Analytics.instance.logSelect(target: "Process"); + _processEvents(); + } + +} diff --git a/lib/ui/health/debug/Covid19DebugSymptomsPanel.dart b/lib/ui/health/debug/Covid19DebugSymptomsPanel.dart new file mode 100644 index 00000000..e629afa9 --- /dev/null +++ b/lib/ui/health/debug/Covid19DebugSymptomsPanel.dart @@ -0,0 +1,310 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + +class Covid19DebugSymptomsPanel extends StatefulWidget { + + Covid19DebugSymptomsPanel({Key key}) : super(key: key); + + @override + _Covid19DebugSymptomsPanelState createState() => _Covid19DebugSymptomsPanelState(); +} + +class _Covid19DebugSymptomsPanelState extends State { + + DateTime _selectedDate; + List _symptomsGroups; + Set _selectedSymptoms = Set(); + bool _loadingSymptoms; + bool _submittingSymptoms; + + @override + void initState() { + super.initState(); + _loadingSymptoms = true; + Health().loadSymptomsGroups().then((List groups) { + setState(() { + _loadingSymptoms = false; + _symptomsGroups = groups; + }); + }); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + String title = Localization().getStringEx("panel.health.symptoms.heading.title","Are you experiencing any of these symptoms?"); + return Scaffold(backgroundColor: Styles().colors.background, + body:SafeArea( + child: Column(children: [ + Stack(children: [ + Align(alignment: Alignment.topLeft, + child: OnboardingBackButton(image: 'images/chevron-left-blue.png', padding: EdgeInsets.only(left: 4, top: 16, right: 20, bottom: 20), onTap: () => _goBack()), + ), + Align(alignment: Alignment.topCenter, + child: Padding(padding: EdgeInsets.only(left: 64, right: 64, bottom: 16, top: 20), + child: Text(title, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary),), + ), + ), + ],), + + Expanded( + child: SingleChildScrollView( + child: Padding(padding: EdgeInsets.all(24), + child: Column( + children: _buildContent() + ), + ), + ), + ), + ]), + ), + ); + } + + List _buildContent() { + if (_loadingSymptoms == true) { + return _buildLoadingContent(); + } + else if (_symptomsCount == 0) { + return _buildStatusContent(Localization().getStringEx("panel.health.symptoms.label.error.loading","Failed to load symptoms.")); + } + else { + return _buildSymptomsContent(); + } + } + + List _buildSymptomsContent() { + List result = []; + if (_symptomsGroups != null) { + for (HealthSymptomsGroup group in _symptomsGroups) { + result.addAll(_buildGroup(group)); + } + } + if (0 < result.length) { + result.add(_buildDatePicker()); + result.add(_bulldSubmit()); + } + return result; + } + + List _buildGroup(HealthSymptomsGroup group) { + List result = []; + if (group.symptoms != null) { + for (HealthSymptom symptom in group.symptoms) { + result.addAll(_buildSymptom(symptom)); + } + } + return result; + } + + List _buildSymptom(HealthSymptom symptom) { + bool _selected = _selectedSymptoms.contains(symptom.id); + String imageName = _selected ? 'images/icon-selected-checkbox.png' : 'images/icon-deselected-checkbox.png'; + return [ + Semantics( + label: symptom.name, + value: (_selected?Localization().getStringEx("toggle_button.status.checked", "checked",) : + Localization().getStringEx("toggle_button.status.unchecked", "unchecked")) + + ", "+ Localization().getStringEx("toggle_button.status.checkbox", "checkbox"), + button:true, + excludeSemantics: true, + child: Padding(padding: EdgeInsets.only(bottom: 10), child: + InkWell(onTap: () => _onTapSymptom(symptom), child: + Container(padding: EdgeInsets.all(16), color: Colors.white, child: + Row(children: [ + Image.asset(imageName), + Expanded(child: + Padding(padding: EdgeInsets.only(left: 16), child: + Text(symptom.name, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ), + ), + ],) + ), + ), + )), + ]; + } + + Widget _bulldSubmit() { + bool enabled = (0 < _selectedSymptoms.length); + return Padding(padding: EdgeInsets.only(top: 20, bottom: 20), child: + Stack(children: [ + RoundedButton(label: Localization().getStringEx("panel.health.symptoms.button.submit.title","Submit"), + backgroundColor: enabled ? Styles().colors.white : Styles().colors.whiteTransparent01, + textColor: enabled ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColorTwo, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + padding: EdgeInsets.symmetric(horizontal: 32, ), + borderColor: enabled ? Styles().colors.fillColorSecondary : Styles().colors.disabledTextColorTwo, + borderWidth: 2, + height: 48, + onTap:() { _onSubmit(); } + ), + Visibility(visible: (_submittingSymptoms == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 12), child: + Container(width: 24, height:24, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ],) + ); + } + + Widget _buildDatePicker() { + String dateText = _selectedDate != null ? AppDateTime().formatDateTime(_selectedDate, format: AppDateTime.scheduleServerQueryDateTimeFormat) : "-"; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(top: 10, bottom: 4), + child: Text(Localization().getStringEx("panel.health.covid19.debug.trace.label.date","Date"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ), + GestureDetector(onTap: _onTapPickDate, + child: Container(height: 48, + decoration: BoxDecoration( + border: Border.all(color: Styles().colors.surfaceAccent, width: 1), + borderRadius: BorderRadius.all(Radius.circular(4))), + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppString.getDefaultEmptyString(value: dateText, defaultValue: '-'), + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontSize: 16, + fontFamily: Styles().fontFamilies.medium), + ), + Image.asset('images/icon-down-orange.png') + ], + ), + ), + ), + ],); + + } + + List _buildLoadingContent() { + return [ + Padding(padding:EdgeInsets.symmetric(vertical: 200), child: + Align(alignment: Alignment.center, child: + Container(width: 42, height: 42, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary), strokeWidth: 2,) + ), + ), + )]; + } + + List _buildStatusContent(String text) { + return [Padding(padding: EdgeInsets.only(left: 32, right:32, top: 200), + child:Align(alignment: Alignment.center, child: + Text(text, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ), + )]; + } + + int get _symptomsCount { + int count = 0; + if (_symptomsGroups != null) { + for (HealthSymptomsGroup group in _symptomsGroups) { + count += (group.symptoms?.length ?? 0); + } + } + return count; + } + + void _goBack() { + Analytics.instance.logSelect(target: 'Back'); + Navigator.of(context).pop(); + } + + void _onTapPickDate() { + DateTime initialDate = (_selectedDate != null) ? _selectedDate : DateTime.now(); + DateTime firstDate = initialDate.subtract(new Duration(days: 365 * 5)); + DateTime lastDate = initialDate.add(new Duration(days: 365 * 5)); + showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + builder: (BuildContext context, Widget child) { + return Theme(data: ThemeData.light(), child: child,); + }, + ).then((DateTime result) { + if (mounted && (result != null)) { + setState(() { + _selectedDate = result; + }); + } + }); + } + + void _onTapSymptom(HealthSymptom symptom) { + setState(() { + if (_selectedSymptoms.contains(symptom.id)) { + _selectedSymptoms.remove(symptom.id); + } + else { + _selectedSymptoms.add(symptom.id); + } + AppSemantics.announceCheckBoxStateChange(context, _selectedSymptoms?.contains(symptom.id), symptom.name); + }); + } + + void _onSubmit() { + Analytics.instance.logSelect(target: "Submit"); + if (_submittingSymptoms == true) { + return; + } + setState(() { + _submittingSymptoms = true; + }); + + Health().processSymptoms(groups: _symptomsGroups, selected: _selectedSymptoms, dateUtc: _selectedDate?.toUtc()).then((dynamic result) { + if (mounted) { + setState(() { + _submittingSymptoms = false; + }); + if (result == null) { + AppAlert.showDialogResult(context, Localization().getStringEx("panel.health.symptoms.label.error.submit", "Failed to submit symptoms.")); + } + else if (result is Covid19History) { + AppAlert.showDialogResult(context,Localization().getStringEx("panel.health.symptoms.label.success.submit.message", "Your symptoms have been processed.")).then((_){ + Navigator.of(context).pop(); + }); + } + else if (result is Covid19Status) { + Navigator.of(context).pop(); // pop immidiately as status update panel will be pushed. + } + } + }); + } +} \ No newline at end of file diff --git a/lib/ui/health/debug/Covid19DebugTraceContactPanel.dart b/lib/ui/health/debug/Covid19DebugTraceContactPanel.dart new file mode 100644 index 00000000..57dad5d1 --- /dev/null +++ b/lib/ui/health/debug/Covid19DebugTraceContactPanel.dart @@ -0,0 +1,244 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import 'package:flutter/material.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + +class Covid19DebugTraceContactPanel extends StatefulWidget { + + Covid19DebugTraceContactPanel(); + + @override + _Covid19DebugTraceContactPanelState createState() => _Covid19DebugTraceContactPanelState(); +} + +class _Covid19DebugTraceContactPanelState extends State { + static const double kFieldWidth = 142; + + DateTime _selectedDate; + TextEditingController _durationController; + FocusNode _durationNode; + bool _submitting; + + @override + void initState() { + super.initState(); + _durationController = TextEditingController(text: ''); + _durationNode = FocusNode(); + } + + @override + void dispose() { + super.dispose(); + _durationController.dispose(); + _durationNode.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.health.covid19.debug.trace.heading.title","COVID-19 Contact Trace"), style: TextStyle(color: Colors.white, fontSize: 16, fontFamily: Styles().fontFamilies.extraBold),), + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeading(), + _buildContent(), + ], + ), + ), + ), + _buildSubmit(), + ], + ), + ), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildHeading() { + + return Semantics(container: true, child: + Container(color:Colors.white, + child: Padding(padding: EdgeInsets.all(32), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, + children:[ + Row(children: [ + Padding(padding: EdgeInsets.only(right: 4), child: Image.asset('images/campus-tools-blue.png',excludeFromSemantics: true,)), + Text(Localization().getStringEx("panel.health.covid19.debug.trace.label.contact","Trace COVID-19 Contact"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Styles().colors.fillColorPrimary),), + ],), + ]), + ), + )); + } + + + Widget _buildContent() { + String dateText = _selectedDate != null ? AppDateTime().formatDateTime(_selectedDate, format: AppDateTime.scheduleServerQueryDateTimeFormat) : "-"; + return Padding(padding: EdgeInsets.all(32), + child: Column(crossAxisAlignment:CrossAxisAlignment.start, children: [ + Semantics(container: true, child: + Padding(padding: EdgeInsets.symmetric(vertical: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text(Localization().getStringEx("panel.health.covid19.debug.trace.label.date","Date"), style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + GestureDetector(onTap: _onTapPickDate, + child: Container(height: 48, width: kFieldWidth, + decoration: BoxDecoration( + border: Border.all(color: Styles().colors.surfaceAccent, width: 1), + borderRadius: BorderRadius.all(Radius.circular(4))), + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppString.getDefaultEmptyString(value: dateText, defaultValue: '-'), + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontSize: 16, + fontFamily: Styles().fontFamilies.medium), + ), + Image.asset('images/icon-down-orange.png') + ], + ), + ), + ), + ],), + ), + ), + + + Padding(padding: EdgeInsets.symmetric(vertical: 8), child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: EdgeInsets.only(bottom: 4), + child: Text("Duration (mins)", style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 12, color: Styles().colors.fillColorPrimary),), + ), + Semantics(textField: true, child:Container(width: kFieldWidth, color: Styles().colors.white, + child: TextField( + controller: _durationController, + focusNode: _durationNode, + decoration: InputDecoration(border: OutlineInputBorder(borderSide: BorderSide(color: Colors.black, width: 1.0)), contentPadding: EdgeInsets.symmetric(horizontal: 8)), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground,), + ), + )), + ],), + ), + ]), + ); + } + + Widget _buildSubmit() { + return Padding(padding: EdgeInsets.all(16), + child: Stack(children: [ + Row(children: [ + Expanded(child: Container(),), + RoundedButton(label: "Submit Contact Trace", + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + padding: EdgeInsets.symmetric(horizontal: 32, ), + borderWidth: 2, + height: 42, + onTap:() { _onSubmit(); } + ), + Expanded(child: Container(),), + ],), + Visibility(visible: (_submitting == true), child: + Center(child: + Padding(padding: EdgeInsets.only(top: 10.5), child: + Container(width: 21, height:21, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ), + ],), + ); + } + + void _onTapPickDate() { + DateTime initialDate = (_selectedDate != null) ? _selectedDate : DateTime.now(); + DateTime firstDate = initialDate.subtract(new Duration(days: 365 * 5)); + DateTime lastDate = initialDate.add(new Duration(days: 365 * 5)); + showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + builder: (BuildContext context, Widget child) { + return Theme(data: ThemeData.light(), child: child,); + }, + ).then((DateTime result) { + if (mounted && (result != null)) { + setState(() { + _selectedDate = result; + }); + } + }); + } + + void _onSubmit() { + if (_selectedDate == null) { + AppAlert.showDialogResult(context, Localization().getStringEx("panel.health.covid19.debug.trace.message.date.text","Please select a date")).then((_) { + _onTapPickDate(); + }); + return; + } + DateTime dateUtc = _selectedDate.toUtc(); + + int duration = int.tryParse(_durationController.text); + if (duration == null) { + AppAlert.showDialogResult(context,Localization().getStringEx("panel.health.covid19.debug.trace.message.duration.text", "Please enter an integer duration")).then((_) { + _durationNode.requestFocus(); + }); + return; + } + + setState(() { + _submitting = true; + }); + Health().processContactTrace(dateUtc: dateUtc, duration: duration * 60 * 1000 /* in milliseconds */).then((bool result) { + if (mounted) { + setState(() { + _submitting = false; + }); + if (result != true) { + AppAlert.showDialogResult(context,Localization().getStringEx("panel.health.covid19.debug.trace.error.submit.text", "Failed to submit contact trace data.")); + } + else { + Navigator.of(context).pop(); + } + } + }); + } +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingConsentPanel.dart b/lib/ui/health/onboarding/Covid19OnBoardingConsentPanel.dart new file mode 100644 index 00000000..c9654937 --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingConsentPanel.dart @@ -0,0 +1,326 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/TrianglePainter.dart'; +import 'package:illinois/utils/Utils.dart'; + +class Covid19OnBoardingConsentPanel extends StatefulWidget with OnboardingPanel { + + final Map onboardingContext; + + Covid19OnBoardingConsentPanel({this.onboardingContext}); + + @override + State createState() { + return _Covid19OnBoardingConsentPanelState(); + } + +} + +class _Covid19OnBoardingConsentPanelState extends State{ + bool _loading = false; + bool _exposureNotification = false; + bool _consent = true; + bool _canContinue = false; + ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + _scrollController.addListener(_scrollListener); + //19.06 - 5.1 Covid setup flow consents should be off by default +// Health().loadUser().then((HealthUser user) { +// setState(() { +// _exposureNotification = user?.consent ?? false; +// _consent = user?.exposureNotification ?? false; +// }); +// }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.background, + body: _loading? _buildLoading() : _buildContent()); + } + + Widget _buildContent(){ + return SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Column( + children: [ + Container(height: 90,color: Styles().colors.surface,), + CustomPaint( + painter: InvertedTrianglePainter(painterColor: Styles().colors.surface, left : true, ), + child: Container( + height: 67, + ), + ), + ], + ), + Column( + children: [ + Row( + children: [ + Expanded(child: Image.asset('images/background-onboarding-squares-light.png', excludeFromSemantics: true,fit: BoxFit.fitWidth,)), + ], + ), + ], + ), + Container(margin: EdgeInsets.only(top: 80, bottom: 20),child: Center(child: Image.asset('images/icon-big-onboarding-privacy.png', excludeFromSemantics: true,))), + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton(padding: EdgeInsets.only(top: 24, left:12.5, right: 20, bottom: 20), onTap: () => _goBack(context)), + ) + ], + ), + ] + ), + Expanded( child: + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: SingleChildScrollView( + controller: _scrollController, + child: MeasureSize( + onChange: (Size size){ + determineContentCanScroll(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx('panel.health.onboarding.covid19.consent.label.title', 'Special consents for COVID-19 features'), + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color: Styles().colors.fillColorPrimary), + )), + Container(height: 11,), + Semantics( header: true, hint: Localization().getStringEx("app.common.heading.two.hint","Header 2"), + child: Text(Localization().getStringEx("panel.health.onboarding.covid19.consent.label.description", "Exposure Notifications"), + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color:Styles().colors.fillColorPrimary), + )), + Container(height: 4,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.consent.label.content1", "If you consent to exposure notifications, you allow your phone to send an anonymous Bluetooth signal to nearby Safer Illinois app users who are also using this feature. Your phone will receive and record a signal from their phones as well. If one of those users tests positive for COVID-19 in the next 14 days, the app will alert you to your potential exposure and advise you on next steps. Your identity and health status will remain anonymous, as will the identity and health status of all other users."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 8,), + ToggleRibbonButton( + label: Localization().getStringEx("panel.health.onboarding.covid19.consent.check_box.label.participate","I consent to participate in the Exposure Notification System (requires Bluetooth to be ON)."), + toggled: _exposureNotification, + onTap: _onParticipateTap, + context: context, + height: null), + Container(height: 24,), + Semantics( header: true, hint: Localization().getStringEx("app.common.heading.two.hint","Header 2"), + child: + Text( + Localization().getStringEx("panel.health.onboarding.covid19.consent.label.content2", "Automatic Test Results"), + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + ), + Container(height: 4,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.consent.label.content3", "Test results for your healthcare provider can be automatically provided to the Safer Illinois app. The test results are encrypted so only you can see the actual results."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 8,), + ToggleRibbonButton( + label: Localization().getStringEx("panel.health.onboarding.covid19.consent.check_box.label.allow","I consent to allow my healthcare provider to provide my test results."), + toggled: _consent, + context: context, + onTap: _onAllowTap, + height: null), + Container(height: 24,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.consent.label.content4", "Your participation is voluntary and you can stop at any time."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 12,), + ], + )) + ), + ),), + Container(color: Styles().colors.white, child: Padding( + padding: EdgeInsets.all(16), + child: ScalableRoundedButton( + enabled: _canContinue, + label:_canContinue? Localization().getStringEx('panel.health.onboarding.covid19.consent.button.consent.title', 'Next') : Localization().getStringEx('panel.health.onboarding.covid19.consent.button.scroll_to_continue.title', 'Scroll to Continue'), + hint: Localization().getStringEx('panel.health.onboarding.covid19.consent.button.consent.hint', ''), + borderColor: (_canContinue ? Styles().colors.lightBlue : Styles().colors.disabledTextColorTwo), + backgroundColor: (_canContinue ? Styles().colors.white : Styles().colors.background), + textColor: (_canContinue ? Styles().colors.fillColorPrimary : Styles().colors.disabledTextColorTwo), + onTap: () => _goNext(context), + ), + ),) + ], + ), + ); + } + + void determineContentCanScroll(){ + if(_scrollController.position.maxScrollExtent==0){ + //There is nothing for scrolling + setState(() { + _canContinue = true; + }); + } + } + + Widget _buildLoading(){ + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,),), + ) + ]); + } + + void _goBack(BuildContext context) { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } + + void _goNext(BuildContext context) { + if (!_canContinue) { + return; + } + Analytics.instance.logSelect(target: "Continue"); + + if (Auth().isLoggedIn) { + _handleLoggedIn(); + } else { // Not logged in yet + _finishConsent(); + } + } + + // Used only for Shibboleth logged in user!!!!! + void _handleLoggedIn() async{ + setState(() { + _loading = true; + }); + Health().loginUser(consent: _consent, exposureNotification: _exposureNotification).then((user){ + if(user==null){ + //Error + AppToast.show(Localization().getStringEx("panel.health.onboarding.covid19.consent.label.error.login","Unable to login in Health")); + } else { + _finishConsent(); + } + setState(() { + _loading = false; + }); + }).catchError((_){ + //Error + AppToast.show(Localization().getStringEx("panel.health.onboarding.covid19.consent.label.error.login","Unable to login in Health")); + setState(() { + _loading = false; + }); + }); + } + + void _finishConsent() { + if (Auth().isLoggedIn) { + widget.onboardingContext['shouldDisplayQrCode'] = true; + } else { + widget.onboardingContext['shouldDisplayQrCode'] = false; + } + Onboarding().next(context, widget); + } + + void _onParticipateTap(){ + Analytics.instance.logSelect(target: "concent to participate"); + setState(() { + _exposureNotification = !_exposureNotification; + }); + + } + + void _onAllowTap(){ + Analytics.instance.logSelect(target: "concent to allow"); + setState(() { + _consent = !_consent; + }); + } + + void _scrollListener() { + if (!_canContinue && (_scrollController.offset >= _scrollController.position.maxScrollExtent) && !_scrollController.position.outOfRange) { + // The user can continue only if he/she scrolls to the bottom of the page. + setState(() { + _canContinue = true; + }); + } + } +} + +typedef void OnWidgetSizeChange(Size size); + +class MeasureSize extends StatefulWidget { + final Widget child; + final OnWidgetSizeChange onChange; + + const MeasureSize({ + Key key, + @required this.onChange, + @required this.child, + }) : super(key: key); + + @override + _MeasureSizeState createState() => _MeasureSizeState(); +} + +class _MeasureSizeState extends State { + @override + Widget build(BuildContext context) { + SchedulerBinding.instance.addPostFrameCallback(postFrameCallback); + return Container( + key: widgetKey, + child: widget.child, + ); + } + + var widgetKey = GlobalKey(); + var oldSize; + + void postFrameCallback(_) { + var context = widgetKey.currentContext; + if (context == null) return; + + var newSize = context.size; + if (oldSize == newSize) return; + + oldSize = newSize; + widget.onChange(newSize); + } +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingFinalPanel.dart b/lib/ui/health/onboarding/Covid19OnBoardingFinalPanel.dart new file mode 100644 index 00000000..bb6ef38e --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingFinalPanel.dart @@ -0,0 +1,163 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; + +class Covid19OnBoardingFinalPanel extends StatelessWidget with OnboardingPanel { + + final Map onboardingContext; + + Covid19OnBoardingFinalPanel({this.onboardingContext}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.fillColorPrimary, + body: ScalableScrollView( + scrollableChild: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Stack( + children: [ + Column( + children: [ + Row( + children: [ + Expanded(child: Image.asset('images/background-onboarding-squares-light.png', excludeFromSemantics: true,fit: BoxFit.fitWidth,)), + ], + ), + ], + ), + Container(margin: EdgeInsets.only(top: 80, bottom: 20),child: Center(child: Image.asset('images/icon-all-set-header.png', excludeFromSemantics: true,))), + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton(padding: EdgeInsets.only(top: 24, left:12.5, right: 20, bottom: 20), onTap: () => _goBack(context)), + ) + ], + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child: Text(Localization().getStringEx("panel.health.onboarding.covid19.final.label.title", "You’re all set!"), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color: Styles().colors.white), + )) + ), + Container(height: 11,), + _buildMiddleContent(), + ]), + bottomNotScrollableWidget: Padding( + padding: EdgeInsets.only(top: 16, bottom: 22, left: 16, right: 16), + child: ScalableRoundedButton( + label: Localization().getStringEx("panel.health.onboarding.covid19.final.button.continue.title", "Get Started"), + hint: Localization().getStringEx("panel.health.onboarding.covid19.final.button.continue.hint", ""), + borderColor: Styles().colors.lightBlue, + backgroundColor: Styles().colors.fillColorPrimary, + textColor: Styles().colors.white, + onTap: () => _goNext(context), + ), + ) + )); + } + + Widget _buildMiddleContent(){ + if(Auth().isLoggedIn){ + if(Auth().isShibbolethLoggedIn){ + return _buildVerifiedMiddleContent(); + } + else if(Auth().isPhoneLoggedIn && (Auth()?.userPiiData?.hasPasportInfo ?? false)){ + return _buildVerifiedMiddleContent(); + } + } + return _buildUnverifiedMiddleContent(); + } + + Widget _buildVerifiedMiddleContent(){ + return Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + Localization().getStringEx( + "panel.health.onboarding.covid19.final.label.description", "You've been verified, and a status card has been added to your profile."), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.white), + ), + ), +// Expanded( +// child: Container(), +// ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + Localization().getStringEx( + "panel.health.onboarding.covid19.final.label.bottom.description","You can now use this app as your companion in the fight against COVID-19."), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.white), + ), + ), + ], + ); + } + + Widget _buildUnverifiedMiddleContent(){ + return Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + Localization().getStringEx( + "panel.health.onboarding.covid19.final.label.unverified.description", "You can now use this app as your companion in the fight against COVID-19."), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.white), + ), + ), +// Expanded( +// child: Container(), +// ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + Localization().getStringEx( + "panel.health.onboarding.covid19.final.label.unverified.bottom.description", "To access your COVID-19 status, you will need to upload a Government ID. You can add this any time in the COVID-19 settings."), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.white), + ), + ), + ], + ); + } + + + void _goBack(BuildContext context) { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } + + void _goNext(BuildContext context) { + Analytics.instance.logSelect(target: "Continue"); + Onboarding().next(context, this); + } +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingHowItWorks.dart b/lib/ui/health/onboarding/Covid19OnBoardingHowItWorks.dart new file mode 100644 index 00000000..d475900a --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingHowItWorks.dart @@ -0,0 +1,186 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/TrianglePainter.dart'; + +class Covid19OnBoardingHowItWorks extends StatelessWidget with OnboardingPanel { + + final Map onboardingContext; + + Covid19OnBoardingHowItWorks({this.onboardingContext}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.background, + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SafeArea(child: + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Column( + children: [ + Container(height: 90,color: Styles().colors.surface,), + CustomPaint( + painter: InvertedTrianglePainter(painterColor: Styles().colors.surface, left : true, ), + child: Container( + height: 67, + ), + ), + ], + ), + Column( + children: [ + Row( + children: [ + Expanded(child: Image.asset('images/background-onboarding-squares-dark.png', excludeFromSemantics: true,fit: BoxFit.fitWidth,)), + ], + ), + ], + ), + Container(margin: EdgeInsets.only(top: 80, bottom: 20),child: Center(child: Image.asset('images/icon-big-onboarding-health.png', excludeFromSemantics: true,))), + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton(padding: EdgeInsets.only(top: 24, left:12.5, right: 20, bottom: 20), onTap: () => _goBack(context)), + ) + ], + ), + + Container( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.heading.title", "How it works"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color:Styles().colors.fillColorPrimary), + )) + ), + ],) + ), + Container(height: 11,), + Expanded( child: + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: + SingleChildScrollView( + child:Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line1.title", "Testing and limiting exposure are key to slowing the spread of COVID-19."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 16,), + Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line2.title", "You can use this app to:"), + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 16,), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Bullet(), + Expanded( + child: Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line3.title", "Self-diagnose your COVID-19 symptoms and in doing so update your status."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + ), + ], + ), + Container(height: 16,), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Bullet(), + Expanded( + child: Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line4.title", "Automatically receive test results from your healthcare provider."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + ), + ], + ), + Container(height: 16,), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Bullet(), + Expanded( + child: Text( + Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.line5.title", "Allow your phone to send exposure notifications when you’ve been in proximity to people who test positive."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + ), + ], + ), + ], + ) + ), + ),), + Container(color: Styles().colors.white, child: Padding( + padding: const EdgeInsets.all(16), + child: ScalableRoundedButton( + label: Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.button.next.title", "Next"), + hint: Localization().getStringEx("panel.health.onboarding.covid19.how_it_works.button.next.hint", ""), + borderColor: Styles().colors.lightBlue, + backgroundColor: Styles().colors.surface, + textColor: Styles().colors.fillColorPrimary, + onTap: ()=>_goNext(context), + ), + ),) + ], + ), + ); + } + + void _goBack(BuildContext context) { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } + + void _goNext(BuildContext context) { + Analytics.instance.logSelect(target: "Continue") ; + return Onboarding().next(context, this); + } +} + + +class _Bullet extends StatelessWidget{ + @override + Widget build(BuildContext context) { + return new Container( + height: 5.0, + width: 5.0, + margin: EdgeInsets.only(right: 10, top: 7,), + decoration: new BoxDecoration( + color: Styles().colors.lightBlue, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingIndicator.dart b/lib/ui/health/onboarding/Covid19OnBoardingIndicator.dart new file mode 100644 index 00000000..c064d9ad --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingIndicator.dart @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; + +class Covid19OnBoardingIndicator extends StatelessWidget { + final double progress; + + Covid19OnBoardingIndicator({@required this.progress}); + + @override + Widget build(BuildContext context) { + return Padding(padding: EdgeInsets.only(left: 2, right: 2, top: 2), + child: LinearProgressIndicator( + value: progress, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), backgroundColor: Styles().colors.surfaceAccent, + semanticsLabel: Localization().getStringEx("widget.health.onboarding.indicator9.label.hint","Covid-19 Onboarding process"),),); + } +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingIntroPanel.dart b/lib/ui/health/onboarding/Covid19OnBoardingIntroPanel.dart new file mode 100644 index 00000000..04c0fdc9 --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingIntroPanel.dart @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; + +class Covid19OnBoardingIntroPanel extends StatelessWidget with OnboardingPanel { + + final Map onboardingContext; + + Covid19OnBoardingIntroPanel({this.onboardingContext}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.fillColorPrimary, + body: ScalableScrollView( + scrollableChild: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton(padding: EdgeInsets.only(top: 24, left: 12.5, right: 20, bottom: 20), onTap: () => _goBack(context)), + ), + ExcludeSemantics(child: Padding(padding: EdgeInsets.only(top: 34), child: Image.asset('images/covid19-header-blue.png'),)), + Padding( + padding: EdgeInsets.only(left: 24, right: 24, top: 32, bottom: 17), + child: Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx('panel.health.onboarding.covid19.intro.label.title', 'Join the fight against COVID-19'), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color: Styles().colors.white), + ))), + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + Localization().getStringEx( + 'panel.health.onboarding.covid19.intro.label.description', 'Track and manage your health to help keep our Illinois community safe'), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.white), + ), + ), + ]), + bottomNotScrollableWidget: + Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 22), + child: ScalableRoundedButton( + label: Localization().getStringEx('panel.health.onboarding.covid19.intro.button.continue.title', 'Continue'), + hint: Localization().getStringEx('panel.health.onboarding.covid19.intro.button.continue.hint', ''), + borderColor: Styles().colors.lightBlue, + backgroundColor: Styles().colors.fillColorPrimary, + textColor: Styles().colors.white, + onTap: () => _goNext(context), + ), + ) + )); + } + + void _goBack(BuildContext context) { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } + + void _goNext(BuildContext context) { + Analytics.instance.logSelect(target: "Continue") ; + return Onboarding().next(context, this); + } +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingLoginNetIdPanel.dart b/lib/ui/health/onboarding/Covid19OnBoardingLoginNetIdPanel.dart new file mode 100644 index 00000000..ff28607d --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingLoginNetIdPanel.dart @@ -0,0 +1,261 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingFinalPanel.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingQrCodePanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + + +class Covid19OnBoardingLoginNetIdPanel extends StatefulWidget { + + final bool exposureNotification; + final bool consent; + + Covid19OnBoardingLoginNetIdPanel({this.exposureNotification, this.consent}); + + @override + _Covid19OnBoardingLoginNetIdPanelState createState() => _Covid19OnBoardingLoginNetIdPanelState(); +} + +class _Covid19OnBoardingLoginNetIdPanelState extends State implements NotificationsListener{ + + bool _progress = false; + + @override + void initState() { + NotificationService().subscribe(this, [Auth.notifyLoginSucceeded, Auth.notifyLoginFailed, Auth.notifyStarted]); + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + String titleString = Localization().getStringEx('panel.health.onboarding.covid19.login.netid.label.title', 'Connect your NetID'); + String skipTitle = Localization().getStringEx('panel.health.onboarding.covid19.login.netid.button.dont_continue.title', 'Not right now'); + return SafeArea( + child: Scaffold( + backgroundColor: Styles().colors.background, + body: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Image.asset( + "images/login-header.png", + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + excludeFromSemantics: true, + ), + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton( + image: 'images/chevron-left-blue.png', padding: EdgeInsets.only(top: 16, right: 20, bottom: 20), onTap: () => _goBack(context)), + ), + ], + ), + Container( + height: 24, + ), + Semantics( + label: titleString, + hint: Localization().getStringEx('panel.health.onboarding.covid19.login.netid.label.title.hint', ''), + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 18), + child: Center( + child: Text(titleString, + textAlign: TextAlign.center, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 36, color: Styles().colors.fillColorPrimary)), + )), + ), + Container( + height: 24, + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text(Localization().getStringEx('panel.health.onboarding.covid19.login.netid.label.description', 'Log in with your NetID to see Illinois information specific to you, like your Illini Cash and meal plan.'), + textAlign: TextAlign.center, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 20, color: Styles().colors.fillColorPrimary))), + Container( + height: 32, + ), + Expanded( + child: Container(), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(24), + child: RoundedButton( + label: Localization().getStringEx('panel.health.onboarding.covid19.login.netid.button.continue.title', 'Log in with NetID'), + hint: Localization().getStringEx('panel.health.onboarding.covid19.login.netid.button.continue.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.background, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onLoginTapped()), + ), + ), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => _onSkipTapped(), + child: Semantics( + label: skipTitle, + hint: Localization().getStringEx('panel.health.onboarding.covid19.login.netid.button.dont_continue.hint', 'Skip verification'), + button: true, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.only(bottom: 24), + child: Text( + skipTitle, + textAlign: TextAlign.center, + style: TextStyle( + color: Styles().colors.fillColorPrimary, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.fillColorSecondary, + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + ), + ), + )), + )), + ], + ) + ], + ), + _progress + ? Container( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ) + : Container(), + ], + )), + ); + } + + Widget _buildDialogWidget(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx('app.title', 'Safer Illinois'), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + Localization().getStringEx('panel.health.onboarding.covid19.login.label.login_failed', 'Unable to login. Please try again later'), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Unable to login", selection: "Ok"); + Navigator.pop(context); + //_finish(); + }, + child: Text(Localization().getStringEx('dialog.ok.title', 'OK'))) + ], + ) + ], + ), + ), + ); + } + + void _onLoginTapped() { + Analytics.instance.logSelect(target: 'Log in with NetID'); + Auth().authenticateWithShibboleth(); + } + + void _onSkipTapped() { + Analytics.instance.logSelect(target: 'Not right now'); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19OnBoardingFinalPanel())); // not logged in so go directly to the final panel + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == Auth.notifyStarted) { + _onLoginStarted(); + } else if (name == Auth.notifyLoginSucceeded) { + onLoginResult(true); + } else if (name == Auth.notifyLoginFailed) { + onLoginResult(false); + } + } + + void _onLoginStarted() { + setState(() { _progress = true; }); + } + void onLoginResult(bool success) { + if (mounted) { + if (success) { + _handleFinish().then((_){ + setState(() { _progress = false; }); + Analytics.instance.logSelect(target: 'NetID Logged In'); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19OnBoardingQrCodePanel())); + }).catchError((error){ + AppToast.show(Localization().getStringEx("panel.health.onboarding.covid19.login.label.error.login","Unable to login in Health"),); + }); + } else { + setState(() { _progress = false; }); + showDialog(context: context, builder: (context) => _buildDialogWidget(context)); + } + } + } + + void _goBack(BuildContext context) { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } + + Future _handleFinish() async{ + await FlexUI().update(); + HealthUser user = await Health().loginUser(consent: widget.consent,exposureNotification: widget.exposureNotification); + if(user == null){ + throw Exception("Unable to login in Health"); + } + } + +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingLoginPhonePanel.dart b/lib/ui/health/onboarding/Covid19OnBoardingLoginPhonePanel.dart new file mode 100644 index 00000000..ad120c43 --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingLoginPhonePanel.dart @@ -0,0 +1,240 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingFinalPanel.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingQrCodePanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; + +class Covid19OnBoardingLoginPhonePanel extends StatefulWidget { + @override + _Covid19OnBoardingLoginPhonePanelState createState() => _Covid19OnBoardingLoginPhonePanelState(); +} + +class _Covid19OnBoardingLoginPhonePanelState extends State implements NotificationsListener{ + + bool _progress = false; + + @override + void initState() { + NotificationService().subscribe(this, [Auth.notifyLoginSucceeded, Auth.notifyLoginFailed, Auth.notifyStarted]); + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + String titleString = Localization().getStringEx('panel.health.onboarding.covid19.login.netid.label.title', 'Connect your NetID'); + String skipTitle = Localization().getStringEx('panel.health.onboarding.covid19.login.netid.button.dont_continue.title', 'Not right now'); + return SafeArea( + child: Scaffold( + backgroundColor: Styles().colors.background, + body: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Image.asset( + "images/login-header.png", + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + excludeFromSemantics: true, + ), + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton( + image: 'images/chevron-left-blue.png', padding: EdgeInsets.only(top: 16, right: 20, bottom: 20), onTap: () => _goBack(context)), + ), + ], + ), + Container( + height: 24, + ), + Semantics( + label: titleString, + hint: Localization().getStringEx('panel.health.onboarding.covid19.login.netid.label.title.hint', ''), + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 18), + child: Center( + child: Text(titleString, + textAlign: TextAlign.center, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 36, color: Styles().colors.fillColorPrimary)), + )), + ), + Container( + height: 24, + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text(Localization().getStringEx('panel.health.onboarding.covid19.login.netid.label.description', 'Log in with your NetID to see Illinois information specific to you, like your Illini Cash and meal plan.'), + textAlign: TextAlign.center, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 20, color: Styles().colors.fillColorPrimary))), + Container( + height: 32, + ), + Expanded( + child: Container(), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(24), + child: RoundedButton( + label: Localization().getStringEx('panel.health.onboarding.covid19.login.netid.button.continue.title', 'Log in with NetID'), + hint: Localization().getStringEx('panel.health.onboarding.covid19.login.netid.button.continue.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.background, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onLoginTapped()), + ), + ), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => _onSkipTapped(), + child: Semantics( + label: skipTitle, + hint: Localization().getStringEx('panel.health.onboarding.covid19.login.netid.button.dont_continue.hint', 'Skip verification'), + button: true, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.only(bottom: 24), + child: Text( + skipTitle, + textAlign: TextAlign.center, + style: TextStyle( + color: Styles().colors.fillColorPrimary, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.fillColorSecondary, + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + ), + ), + )), + )), + ], + ) + ], + ), + _progress + ? Container( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ) + : Container(), + ], + )), + ); + } + + Widget _buildDialogWidget(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx('app.title', 'Safer Illinois'), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + Localization().getStringEx('panel.health.onboarding.covid19.login.label.login_failed', 'Unable to login. Please try again later'), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Unable to login", selection: "Ok"); + Navigator.pop(context); + //_finish(); + }, + child: Text(Localization().getStringEx('dialog.ok.title', 'OK'))) + ], + ) + ], + ), + ), + ); + } + + void _onLoginTapped() { + Analytics.instance.logSelect(target: 'Log in with NetID'); + Auth().authenticateWithShibboleth(); + } + + void _onSkipTapped() { + Analytics.instance.logSelect(target: 'Not right now'); + Navigator.push(context, CupertinoPageRoute(builder: (context)=>Covid19OnBoardingFinalPanel()));// Not logged in so skip qrcode panel + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == Auth.notifyStarted) { + _onLoginStarted(); + } else if (name == Auth.notifyLoginSucceeded) { + onLoginResult(true); + } else if (name == Auth.notifyLoginFailed) { + onLoginResult(false); + } + } + + void _onLoginStarted() { + setState(() { _progress = true; }); + } + void onLoginResult(bool success) { + if (mounted) { + if (success) { + FlexUI().update().then((_){ + setState(() { _progress = false; }); + Analytics.instance.logSelect(target: 'Phone Logged In'); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19OnBoardingQrCodePanel())); + }); + } else { + setState(() { _progress = false; }); + showDialog(context: context, builder: (context) => _buildDialogWidget(context)); + } + } + } + + void _goBack(BuildContext context) { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingQrCodePanel.dart b/lib/ui/health/onboarding/Covid19OnBoardingQrCodePanel.dart new file mode 100644 index 00000000..40dbcb53 --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingQrCodePanel.dart @@ -0,0 +1,638 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:barcode_scan/barcode_scan.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/TrianglePainter.dart'; +import 'package:illinois/utils/Covid19.dart'; +import 'package:illinois/utils/Crypt.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:pointycastle/export.dart' as PointyCastle; + + +class Covid19OnBoardingQrCodePanel extends StatefulWidget with OnboardingPanel { + + final Map onboardingContext; + + Covid19OnBoardingQrCodePanel({this.onboardingContext}); + + @override + _Covid19OnBoardingQrCodePanelState createState() => _Covid19OnBoardingQrCodePanelState(); + + @override + bool get onboardingCanDisplay { + return (onboardingContext != null) && onboardingContext['shouldDisplayQrCode'] == true; + } +} + +class _Covid19OnBoardingQrCodePanelState extends State { + + + PointyCastle.PublicKey _userHealthPublicKey; + PointyCastle.PrivateKey _userHealthPrivateKey; + bool _userHealthKeysLoading, _userHealthKeysPaired, _userHealthPublicKeyLoaded, _userHealthPrivateKeyLoaded; + Uint8List _qrCodeBytes; + bool _saving = false; + + bool _isRefreshing = false; + + @override + void initState() { + _userHealthKeysLoading = true; + _loadHealthRSAPublicKey(); + _loadHealthRSAPrivateKey(); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + void _loadHealthRSAPublicKey() { + Health().loadRSAPublicKey().then((publicKey) { + if (mounted) { + _userHealthPublicKey = publicKey; + _userHealthPublicKeyLoaded = true; + _verifyHealthRSAKeys(); + } + }); + } + + void _loadHealthRSAPrivateKey() { + Health().loadRSAPrivateKey().then((privateKey) { + if (mounted) { + _userHealthPrivateKey = privateKey; + _userHealthPrivateKeyLoaded = true; + _verifyHealthRSAKeys(); + } + }); + } + + void _verifyHealthRSAKeys() { + + if ((_userHealthPrivateKey != null) && (_userHealthPublicKey != null)) { + RsaKeyHelper.verifyRsaKeyPair(PointyCastle.AsymmetricKeyPair(_userHealthPublicKey, _userHealthPrivateKey)).then((bool result) { + if (mounted) { + _userHealthKeysPaired = result; + _buildHealthRSAQRCode(); + } + }); + } + else if ((_userHealthPrivateKeyLoaded == true) && (_userHealthPublicKeyLoaded == true)) { + _finishHealthRSAKeysLoading(); + } + } + + void _buildHealthRSAQRCode() { + Uint8List privateKeyData = (_userHealthKeysPaired && (_userHealthPrivateKey != null)) ? RsaKeyHelper.encodePrivateKeyToPEMDataPKCS1(_userHealthPrivateKey) : null; + List privateKeyCompressedData = (privateKeyData != null) ? GZipEncoder().encode(privateKeyData) : null; + String privateKeyString = (privateKeyData != null) ? base64.encode(privateKeyCompressedData) : null; + if (privateKeyString != null) { + NativeCommunicator().getBarcodeImageData({ + 'content': privateKeyString, + 'format': 'qrCode', + 'width': 1024, + 'height': 1024, + }).then((Uint8List qrCodeBytes) { + if (mounted) { + _qrCodeBytes = qrCodeBytes; + _finishHealthRSAKeysLoading(); + } + }); + } + else { + _finishHealthRSAKeysLoading(); + } + } + + void _finishHealthRSAKeysLoading() { + setState(() { + _userHealthKeysLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + backgroundColor: Styles().colors.background, + body: + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Column( + children: [ + Container(height: 90,color: Styles().colors.surface,), + CustomPaint( + painter: InvertedTrianglePainter(painterColor: Styles().colors.surface, left : true, ), + child: Container( + height: 67, + ), + ), + ], + ), + Column( + children: [ + Row( + children: [ + Expanded(child: Image.asset('images/background-onboarding-squares.png', excludeFromSemantics: true,fit: BoxFit.fitWidth,)), + ], + ), + ], + ), + Container(margin: EdgeInsets.only(top: 80, bottom: 20),child: Center(child: Image.asset('images/group-25.png', excludeFromSemantics: true,))), + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton( + image: 'images/chevron-left-blue.png', padding: EdgeInsets.only(top: 16, right: 20, bottom: 20), onTap: () => _goBack(context)), + ), + _saving ? Column( + children: [ + Expanded(child: Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,),)), + ], + ) : Container(), + ], + ), + Expanded( child: + Padding(padding: EdgeInsets.symmetric(horizontal: 24), + child: (_userHealthKeysLoading == true) ? _buildWatingContent() : _buildPrivateKeyContent() + )), + Padding( + padding: const EdgeInsets.symmetric(vertical: 22, horizontal: 16), + child: Visibility(visible: (_userHealthKeysLoading != true), + child: ScalableRoundedButton( + label: _getContinueButtonTitle, + hint: Localization().getStringEx("panel.health.covid19.qr_code.button.continue.hint", ""), + borderColor: Styles().colors.fillColorSecondaryVariant, + backgroundColor: Styles().colors.surface, + textColor: Styles().colors.fillColorPrimary, + onTap: _goNext, + ) + ), + ) + ], + ), + ), + ); + } + + Widget _buildWatingContent(){ + return Center(child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ); + } + + Widget _buildPrivateKeyContent(){ + return SingleChildScrollView( + child: (_qrCodeBytes != null) ? _buildQrCodeContent() : _buildNoQrCodeContent(), + ); + } + + Widget _buildQrCodeContent(){ + return Container( + child: Column( children: [ + Container(height: 15,), + Container( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx("panel.health.covid19.qr_code.primary.heading.title", "Your COVID-19 Encryption Key"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color:Styles().colors.fillColorPrimary), + )) + ), + Container(height: 15,), + Text(Localization().getStringEx("panel.health.covid19.qr_code.primary.description.1", "For your privacy, your healthcare data used for COVID-19 features is encrypted. The encryption key is stored locally on your phone to keep it secure. \n\nTo use the COVID-19 features on another device, you will need to manually transfer this encryption key using the QR code below."), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 30,), + _buildQrCode(), + Container(height: 20,), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: ScalableRoundedButton( + label: Localization().getStringEx("panel.health.covid19.qr_code.primary.button.save.title", "Save Your Encryption Key"), + hint: Localization().getStringEx("panel.health.covid19.qr_code.primary.button.save.hint", ""), + borderColor: Styles().colors.fillColorSecondaryVariant, + backgroundColor: Styles().colors.surface, + fontSize: 16, + padding: EdgeInsets.symmetric(vertical: 5), + textColor: Styles().colors.fillColorPrimary, + onTap: _onSaveImage, + ), + ), + Container(height: 30,), + Text(Localization().getStringEx("panel.health.covid19.qr_code.primary.description.2", "In the event your current device is lost or damaged, we suggest you save a copy of this QR code to a cloud photo storage service, so that it can be retrieved on your replacement device. \n\nYou can access and save this key on this device at any time by accessing \"Transfer Your COVID-19 Encryption Key\" from the COVID-19 info center."), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 40,) + ],) + ); + } + + Widget _buildNoQrCodeContent(){ + return Container( + child: Column( children: [ + Container(height: 15,), + Container( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx("panel.health.covid19.qr_code.secondary.heading.title", "Looks like you’ve used this feature before on another device"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color:Styles().colors.fillColorPrimary), + )) + ), + Container(height: 15,), + Text(Localization().getStringEx("panel.health.covid19.qr_code.secondary.description.1", "Do you want to transfer your QR encyrption key to this device to retreive your previous health information?\n\nSelect which one applies to you below. You can always transfer a QR encryption key to this device at a later time using the “Transfer Your COVID-19 Encyrption Key” in the COVID-19 info center or in your app settings."), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color:Styles().colors.fillColorPrimary), + ), + Container(height: 18,), + _buildAction( + heading: Localization().getStringEx("panel.health.covid19.qr_code.secondary.button.scan.heading", "If you are adding a second device:"), + description: Localization().getStringEx("panel.health.covid19.qr_code.secondary.button.scan.description", "If you still have access to your primary device, you can directly scan the COVID-19 Encryption Key QR code from that device."), + title: Localization().getStringEx("panel.health.covid19.qr_code.secondary.button.scan.title", "Scan Your QR Code"), + iconRes: "images/fill-1.png", + onTap: _onScan + ), + Container(height: 12,), + _buildAction( + heading: Localization().getStringEx("panel.health.covid19.qr_code.secondary.button.retrieve.heading", "If you are using a replacement device:"), + description: Localization().getStringEx("panel.health.covid19.qr_code.secondary.button.retrieve.description", "If you no longer have access to your primary device, but saved your QR code to a cloud photo service, you can transfer your COVID-19 Encryption Key by retrieving it from your photos."), + title: Localization().getStringEx("panel.health.covid19.qr_code.secondary.button.retrieve.title", "Retrieve Your QR Code"), + iconRes: "images/group-10.png", + onTap: _onRetrieve + ), + Container(height: 12,), + _buildAction( + heading: Localization().getStringEx("panel.health.covid19.qr_code.reset.button.heading", "Reset my COVID-19 Secret QRcode:"), + title: Localization().getStringEx("panel.health.covid19.qr_code.reset.button.title", "Reset my COVID-19 Secret QRcode"), + iconRes: "images/group-10.png", + onTap: _onRefreshQrCodeTapped + ), + Container(height: 12,), + Container(height: 40,) + ],) + ); + } + + Widget _buildQrCode(){ + return Container( + decoration: BoxDecoration( + color: Styles().colors.white, + borderRadius: BorderRadius.all( Radius.circular(5))), + padding: EdgeInsets.all(1), + child: Semantics(child: + Image.memory(_qrCodeBytes, fit: BoxFit.fitWidth, semanticLabel: Localization().getStringEx("panel.health.covid19.qr_code.primary.heading.title", "Your COVID-19 Encryption Key"), + )), + ); + } + + Widget _buildAction({String heading, String description = "", String title, String iconRes, Function onTap}){ + return Semantics(container: true, child:Container( + decoration: BoxDecoration( + color: Styles().colors.white, + borderRadius: BorderRadius.all( Radius.circular(5))), + child: Column( + children: [ + Container(height: 18,), + Container( padding: EdgeInsets.symmetric(horizontal: 20), + child: Text(heading, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color:Styles().colors.fillColorPrimary))), + Container(height: AppString.isStringNotEmpty(description) ? 9 : 0,), + AppString.isStringNotEmpty(description) ? Container( padding: EdgeInsets.symmetric(horizontal: 20), + child: Text(description, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 14, color:Styles().colors.fillColorPrimary))) : Container(), + Container(height: 14,), + Semantics( + explicitChildNodes: true, + child: Container(child: + GestureDetector( + onTap: onTap, + child:Container( + decoration: BoxDecoration( + color: Styles().colors.background, + borderRadius: BorderRadius.only( bottomLeft: Radius.circular(5), bottomRight: Radius.circular(5)), + border: Border.all(color: Styles().colors.surfaceAccent,) + ), + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 15), + child: Row( + children: [ + Image.asset(iconRes, excludeFromSemantics: true,), + Container(width: 7,), + Expanded(child: + Container( + alignment: Alignment.centerLeft, + child: Semantics(button: true, excludeSemantics:false, child: + Text(title, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 14, color:Styles().colors.fillColorPrimary))), + )), + Image.asset('images/chevron-right.png',excludeFromSemantics: true,), + ], + ))))), + ], + ) + )); + } + + Widget _buildRefreshQrCodeDialog(BuildContext context) { + return StatefulBuilder( + builder: (context, setState){ + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child:Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Styles().colors.fillColorPrimary, + borderRadius: BorderRadius.vertical(top: Radius.circular(8)), + ), + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Center( + child: Text( + Localization().getStringEx("panel.health.covid19.qr_code.dialog.refresh_qr_code.title", "Reset my COVID-19 Secret QRcode"), + style: TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Styles().colors.white, width: 2), + ), + child: Center( + child: Text( + '\u00D7', + style: TextStyle( + fontSize: 24, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + Localization().getStringEx("panel.health.covid19.qr_code.dialog.refresh_qr_code.description", "Doing this will provide you a new COVID-19 Secret QRcode but your previous COVID-19 event history will be lost, continue?"), + + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Container( + height: 26, + ), + Text( + Localization().getStringEx("panel.health.covid19.qr_code.dialog.refresh_qr_code.confirm", "Are you sure?"), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Colors.black), + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Stack( + alignment: Alignment.center, + children: [ + ScalableRoundedButton( + onTap: () => _onConfirmRefreshQrCode(context, setState), + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("app.common.yes", "Yes")), + _isRefreshing ? Center(child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary),)) : Container() + ], + ), + ), + Container( + width: 10, + ), + Expanded( + child: ScalableRoundedButton( + onTap: () { + Analytics.instance.logAlert(text: "Refresh QR Code", selection: "No"); + Navigator.pop(context); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("app.common.no", "No")), + ) + ], + ), + ), + ], + ) + ), + ), + ); + }, + ); + } + + //Actions + + void _goNext() { + Onboarding().next(context, widget); + } + + void _onSaveImage(){ + if(!_saving) { + setState(() { + _saving = true; + }); + _saveQrImage().whenComplete(() { + setState(() { + _saving = false; + }); + }); + } + } + + Future _saveQrImage() async{ + Analytics.instance.logSelect(target: "Save Your Encryption Key"); + if (_qrCodeBytes == null) { + AppAlert.showDialogResult(context, Localization().getStringEx("panel.health.covid19.qr_code.alert.no_qr_code.msg", "There is no QR Code")).then((_) { + _goNext(); + }); + } + else { + bool result = await Covid19Utils.saveQRCodeImageToPictures(qrCodeBytes: _qrCodeBytes, title: Localization().getStringEx("panel.covid19.transfer.label.qr_image_label", "Safer Illinois COVID-19 Code")); + String platformTargetText = (defaultTargetPlatform == TargetPlatform.android)?Localization().getStringEx("panel.health.covid19.alert.save.success.pictures", "Pictures"): Localization().getStringEx("panel.health.covid19.alert.save.success.gallery", "gallery"); + String message = result + ? (Localization().getStringEx("panel.covid19.transfer.alert.save.success.msg", "Successfully saved qr code in ") + platformTargetText) + : Localization().getStringEx("panel.covid19.transfer.alert.save.fail.msg", "Failed to save qr code in ") + platformTargetText; + AppAlert.showDialogResult(context, message).then((_) { + _goNext(); + }); + } + } + + void _onScan(){ + BarcodeScanner.scan().then((result) { + // barcode_scan plugin returns 8 digits when it cannot read the qr code. Prevent it from storing such values + if (AppString.isStringEmpty(result) || (result.length <= 8)) { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.health.covid19.alert.qr_code.scan.failed.msg', 'Failed to read QR code.')); + } + else { + _onCovid19QrCodeScanSucceeded(result); + } + }); + } + + void _goBack(BuildContext context) { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } + + void _onRetrieve() { + Analytics.instance.logSelect(target: "Retrieve Your QR Code"); + Covid19Utils.loadQRCodeImageFromPictures().then((String qrCodeString) { + _onCovid19QrCodeScanSucceeded(qrCodeString); + }); + } + + void _onCovid19QrCodeScanSucceeded(String result) { + + PointyCastle.PrivateKey privateKey; + try { + Uint8List pemCompressedData = (result != null) ? base64.decode(result) : null; + List pemData = (pemCompressedData != null) ? GZipDecoder().decodeBytes(pemCompressedData) : null; + privateKey = (pemData != null) ? RsaKeyHelper.parsePrivateKeyFromPemData(pemData) : null; + } + catch (e) { print(e?.toString()); } + + if (privateKey != null) { + RsaKeyHelper.verifyRsaKeyPair(PointyCastle.AsymmetricKeyPair(_userHealthPublicKey, privateKey)).then((bool result) { + if (mounted) { + if (result == true) { + Health().setUserRSAPrivateKey(privateKey).then((success) { + if (mounted) { + String resultMessage = success ? + Localization().getStringEx("panel.health.covid19.qr_code.alert.qr_code.transfer.succeeded.msg", "COVID-19 secret transferred successfully.") : + Localization().getStringEx("panel.health.covid19.alert.qr_code.transfer.failed.msg", "Failed to transfer COVID-19 secret."); + AppAlert.showDialogResult(context, resultMessage).then((_){ + if(success) { + Navigator.pop(context); + _goNext(); + } + }); + } + }); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.health.covid19.alert.qr_code.not_match.msg', 'COVID-19 secret key does not match existing public RSA key.')); + } + } + }); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.health.covid19.alert.qr_code.invalid.msg', 'Invalid QR code.')); + } + } + + void _onConfirmRefreshQrCode(BuildContext context, Function setStateEx){ + setStateEx(() { + _isRefreshing = true; + }); + + Health().refreshRSAKeys().then((PointyCastle.AsymmetricKeyPair rsaKeys) { + if (mounted) { + setStateEx((){ + _isRefreshing = false; + }); + + if(rsaKeys != null) { + Navigator.pop(context); + + _userHealthPrivateKey = rsaKeys.privateKey; + _userHealthPublicKey = rsaKeys.publicKey; + _userHealthPrivateKeyLoaded = _userHealthPublicKeyLoaded = true; + + _verifyHealthRSAKeys(); + } + else{ + AppAlert.showDialogResult(context, Localization().getStringEx("panel.health.covid19.debug.keys.label.error.refres.title","Refresh Failed")); + } + } + }); + } + + void _onRefreshQrCodeTapped() { + showDialog(context: context, builder: (context) => _buildRefreshQrCodeDialog(context)); + } + + String get _getContinueButtonTitle { + if (_userHealthKeysLoading == true) { + return ''; + } + else if (_qrCodeBytes != null) { + return Localization().getStringEx("panel.health.covid19.qr_code.button.continue.title", "Continue"); + } + else { + return Localization().getStringEx("panel.health.covid19.qr_code.button.transfer_later.title", "Transfer Later"); + } + + } +} \ No newline at end of file diff --git a/lib/ui/health/onboarding/Covid19OnBoardingResidentInfoPanel.dart b/lib/ui/health/onboarding/Covid19OnBoardingResidentInfoPanel.dart new file mode 100644 index 00000000..8a808f8a --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingResidentInfoPanel.dart @@ -0,0 +1,187 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/UserPiiData.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingIndicator.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; + +class Covid19OnBoardingResidentInfoPanel extends StatelessWidget with OnboardingPanel { + + final Map onboardingContext; + final Function(Map) onSucceed; + final Function onCancel; + + Covid19OnBoardingResidentInfoPanel({this.onboardingContext, this.onSucceed, this.onCancel}); + + @override + bool get onboardingCanDisplay { + return (onboardingContext != null) && onboardingContext['shouldDisplayResidentInfo'] == true; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.background, + body: ScalableScrollView( + scrollableChild: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container(color: Styles().colors.white, child: Stack(children: [ + Covid19OnBoardingIndicator(progress: 0.50,), + Align(alignment: Alignment.topLeft, + child: OnboardingBackButton(image: 'images/chevron-left-blue.png', padding: EdgeInsets.only(top: 16, right: 20, bottom: 20), onTap: () => _goBack(context)), + ), + Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding( + padding: EdgeInsets.only(left: 24, right: 24, top: 24, bottom: 12), + child: Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text( Localization().getStringEx('panel.health.onboarding.covid19.resident_info.label.title', 'Verify your identity with a government-issued ID',), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary), + ))), + Padding( + padding: EdgeInsets.only(left: 24, right: 24, bottom: 19), + child: Text( + Localization().getStringEx('panel.health.onboarding.covid19.resident_info.label.description', 'After verifying you will receive a color-coded health status based on your county guidelines, symptoms, and any COVID-19 related tests.'), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.fillColorPrimary), + )), + ],) + ],),),]), + bottomNotScrollableWidget: Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding(padding: EdgeInsets.symmetric(vertical: 17, horizontal: 16), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Expanded(child:ScalableRoundedButton( + label: Localization().getStringEx('panel.health.onboarding.covid19.resident_info.button.passport.title', 'Passport'), + hint: Localization().getStringEx('panel.health.onboarding.covid19.resident_info.button.passport.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + textColor: Styles().colors.fillColorPrimary, + padding: EdgeInsets.symmetric(horizontal: 22), + onTap: () => _doScan(context, UserDocumentType.passport), + )), + Container(width: 16,), + Expanded(child: ScalableRoundedButton( + label: Localization().getStringEx('panel.health.onboarding.covid19.resident_info.button.drivers_license.title', "Driver's License"), + hint: Localization().getStringEx('panel.health.onboarding.covid19.resident_info.button.drivers_license.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _doScan(context, UserDocumentType.drivingLicense), + ),) + ],),), + GestureDetector( + onTap: () => _onTapVerifyLater(context), + behavior: HitTestBehavior.translucent, + child: Container( + child: Padding( + padding: EdgeInsets.only(bottom: 20), + child: Text( + Localization().getStringEx('panel.health.onboarding.covid19.resident_info.button.verify_later.title', "Verify later"), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.fillColorPrimary, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.fillColorSecondary, + decorationThickness: 1, + decorationStyle: TextDecorationStyle.solid), + )), + ), + ) + ], + ),) + )); + } + + void _goBack(BuildContext context) { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } + + void _doScan(BuildContext context, UserDocumentType documentType) { + + String analyticsScanType; + List recognizers; + if (documentType == UserDocumentType.drivingLicense) { + Analytics.instance.logSelect(target: "Driver's License") ; + analyticsScanType = Analytics.LogDocumentScanDrivingLicenseType; + recognizers = ['combined']; + } + else if (documentType == UserDocumentType.passport) { + Analytics.instance.logSelect(target: 'Passport') ; + analyticsScanType = Analytics.LogDocumentScanPassportType; + recognizers = ['passport']; + } + + NativeCommunicator().microBlinkScan(recognizers: recognizers).then((dynamic result) { + Analytics().logDocumentScan(type: analyticsScanType, result: (result != null)); + if (result != null) { + _didScan(context, documentType, result); + } + }); + } + + void _didScan(BuildContext context, UserDocumentType documentType, Map scanData) { + if(onboardingContext != null) { + onboardingContext['shouldDisplayReviewScan'] = true; + onboardingContext['userDocumentType'] = documentType; + onboardingContext['scanData'] = scanData; + Onboarding().next(context, this); + } + else if(onSucceed != null){ + onSucceed({ + 'userDocumentType': documentType, + 'scanData': scanData + }); + } + else{ + Navigator.pop(context); + } + } + + void _onTapVerifyLater(BuildContext context) { + if(onboardingContext != null) { + Analytics.instance.logSelect(target: 'Verify later'); + onboardingContext['shouldDisplayReviewScan'] = false; + if (Auth().isLoggedIn) { + onboardingContext['shouldDisplayQrCode'] = true; + } else { + onboardingContext['shouldDisplayQrCode'] = false; + } + Onboarding().next(context, this); + } + else if(onCancel != null){ + onCancel(); + } + else{ + Navigator.pop(context); + } + } +} diff --git a/lib/ui/health/onboarding/Covid19OnBoardingReviewScanPanel.dart b/lib/ui/health/onboarding/Covid19OnBoardingReviewScanPanel.dart new file mode 100644 index 00000000..f8fd1e39 --- /dev/null +++ b/lib/ui/health/onboarding/Covid19OnBoardingReviewScanPanel.dart @@ -0,0 +1,491 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/UserPiiData.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingIndicator.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + +class Covid19OnBoardingReviewScanPanel extends StatefulWidget with OnboardingPanel { + + final Map onboardingContext; + + Covid19OnBoardingReviewScanPanel({this.onboardingContext}); + + _Covid19OnBoardingReviewScanPanelState createState() => _Covid19OnBoardingReviewScanPanelState(); + + @override + bool get onboardingCanDisplay { + return (onboardingContext != null) && onboardingContext['shouldDisplayReviewScan'] == true; + } +} + +class _Covid19OnBoardingReviewScanPanelState extends State { + + static const String kFirstNameFieldName = 'firstName'; + static const String kMiddleNameFieldName = 'middleName'; + static const String kLastNameFieldName = 'lastName'; + static const String kFullNameFieldName = 'fullName'; + + static const String kBirthYearFieldName = 'birthYear'; + + static const String kAddressFieldName = 'address'; + static const String kStateFieldName = 'state'; + static const String kZipFieldName = 'zip'; + static const String kCountryFieldName = 'country'; + static const String kFullAddressFieldName = 'fullAddress'; + + /*static const String kHomeCountyFieldName = 'homeCounty'; + static const String kWorkCountyFieldName = 'workCounty'; + static const String kProvidersFieldName = 'providers'; + static const String kConsentFieldName = 'consent';*/ + + static const String kFaceImageFieldName = 'faceImage'; + static const String kFaceBase64FieldName = 'faceBase64'; + + Map _scanResult; + bool _processingScanResult; + bool _applyingScanResult; + UserDocumentType _documenType; + Map _scanData; + + @override + void initState() { + _processingScanResult = true; + _documenType = widget.onboardingContext['userDocumentType']; + _scanData = widget.onboardingContext['scanData']; + compute(_buildScanResult, _scanData).then((Map scanResult) { + setState(() { + _processingScanResult = false; + _scanResult = scanResult; + }); + }); + + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.background, + body: SafeArea(child: Column(mainAxisAlignment: MainAxisAlignment.start, children: [ + Container(color: Styles().colors.white, child: Stack(children: [ + Covid19OnBoardingIndicator(progress: 0.75,), + Align(alignment: Alignment.topLeft, + child: OnboardingBackButton(image: 'images/chevron-left-blue.png', padding: EdgeInsets.only(top: 16, right: 20, bottom: 20), onTap: () => _goBack()), + ), + Align(alignment: Alignment.topCenter, child: + Padding(padding: EdgeInsets.only(left: 24, right: 24, top: 24, bottom: 12), child: + Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx('panel.health.onboarding.covid19.review_scan.label.title', 'Review your scan',), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary), + )) + ), + ), + ],),), + Expanded(child: + SingleChildScrollView(child: + Column(children: [ + _buildPreviewWidget(), + ],) + ), + ), + Container(height: 12,), + Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: + RoundedButton( + label: Localization().getStringEx('panel.health.onboarding.covid19.review_scan.button.rescan.title', 'Re-scan'), + hint: Localization().getStringEx('panel.health.onboarding.covid19.review_scan.button.rescan.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + textColor: Styles().colors.fillColorPrimary, + padding: EdgeInsets.symmetric(horizontal: 22), + onTap: () => _onRescan(), + height: 48, + ),), + Container(height: 12,), + Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: + Stack(children: [ + RoundedButton( + label: Localization().getStringEx('panel.health.onboarding.covid19.review_scan.button.use_scan.title', "Use This Scan"), + hint: Localization().getStringEx('panel.health.onboarding.covid19.review_scan.button.use_scan.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onUseScan(), + height: 48, + ), + Visibility(visible: (_applyingScanResult == true), + child: Container(height: 48, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + ],), + ), + Container(height: 24,), + ],),), + ); + } + + Widget _buildPreviewWidget() { + MemoryImage faceImage = (_scanResult != null) ? _scanResult[kFaceImageFieldName] : null; + + String nameText = ((_scanResult != null) ? _scanResult[kFullNameFieldName] : null) ?? ''; + String nameLabel = nameText.isNotEmpty ? Localization().getStringEx('panel.health.onboarding.covid19.review_scan.label.name.title', 'Name',) : ''; + + String birthYearText = ((_scanResult != null) ? _scanResult[kBirthYearFieldName] : null) ?? ''; + String birthYearLabel = birthYearText.isNotEmpty ? Localization().getStringEx('panel.health.onboarding.covid19.review_scan.label.birth_year.title', 'Birth Year',) : ''; + + return Padding(padding: EdgeInsets.symmetric(horizontal: 32, vertical: 32), + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Styles().colors.white, + borderRadius: BorderRadius.all(Radius.circular(4)), + boxShadow: [BoxShadow(color: Styles().colors.fillColorPrimaryTransparent015, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(0, 2))], + ), + child: Stack(children: [ + Visibility(visible: (_processingScanResult != true), child: + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container(width: 55, height: 70, + decoration: BoxDecoration( + color: Styles().colors.fillColorPrimaryTransparent03, + borderRadius: BorderRadius.all(Radius.circular(2)), + image: (faceImage != null) ? DecorationImage(fit: BoxFit.cover, alignment: Alignment.center, image: faceImage) : null, + ), + ), + Expanded( + child: Padding(padding: EdgeInsets.only(left: 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(nameLabel, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface)), + Text(nameText, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary)), + Container(height: 16,), + Text(birthYearLabel, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface)), + Text(birthYearText, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary)), + ],), + ), + ), + ],), + ), + Visibility(visible: (_processingScanResult == true), + child: Container( + height: 70, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + ],) + + + ), + ); + } + + static Map _buildScanResult(Map rawResult) { + + Map rawMrz = rawResult['mrz']; + + String rawFirstName = rawResult['firstName']; // "WILLIAM C III" + if ((rawFirstName == null) && (rawMrz != null)) { + rawFirstName = rawMrz['secondaryID']; // "PETER MARK" + } + String firstName = _buildName(rawFirstName); + String middleName = _buildName(rawFirstName, index: 1); + + String rawLastName = rawResult['lastName']; // "SULLIVAN" + if ((rawLastName == null) && (rawMrz != null)) { + rawLastName = rawMrz['primaryID']; // "HENNESSY" + } + String lastName = _buildName(rawLastName); + + String dateOfBirth = rawResult['dateOfBirth']; // "09/30/1958" + if ((dateOfBirth == null) && (rawMrz != null)) { + dateOfBirth = rawMrz['dateOfBirth']; // "11/22/1960" + } + String birthYear = ((dateOfBirth != null) && RegExp('[0-9]{2}/[0-9]{2}/[0-9]{4}').hasMatch(dateOfBirth)) ? dateOfBirth.substring(6, 10) : null; + + String country; + if (rawMrz != null) { + for (String key in ['sanitizedNationality', 'nationality', 'sanitizedIssuer', 'issuer']) { + String entry = rawMrz[key]; + if ((entry != null) && (0 < entry.length)) { + country = entry; + break; + } + } + } + + String rawAddress = rawResult['address']; // "1804 PLEASANT ST, URBANA, IL, 618010000" + String address = rawAddress, state, zip; + if (rawAddress != null) { + List addressComponents = rawAddress.split(','); + int componentsCount = addressComponents.length; + if ((addressComponents != null) && (1 < componentsCount)) { + + String aZip = addressComponents[componentsCount - 1].trim(); + bool hasZip = RegExp('[0-9]{5,}').hasMatch(aZip); + + String aState = addressComponents[componentsCount - 2].trim(); + bool hasState = RegExp('[a-zA-Z]{2,}').hasMatch(aState); + + if (hasZip && hasState) { + zip = aZip.substring(0, 5); + state = aState; + if (country == null) { + country = 'USA'; + } + + address = ''; + for (int index = 0; (index + 2) < componentsCount; index++) { + if (0 < index) { + address += ','; + } + address += addressComponents[index]; + } + } + } + } + + String fullName = ''; + if ((firstName != null) && (0 < firstName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$firstName"; + } + if ((middleName != null) && (0 < middleName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$middleName"; + } + if ((lastName != null) && (0 < lastName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$lastName"; + } + + String fullAddress = address ?? ''; + if ((state != null) && (zip != null)) { + fullAddress += "${(0 < fullAddress.length) ? ', ' : ''}$state $zip"; + } + else if (state != null) { + fullAddress += "${(0 < fullAddress.length) ? ', ' : ''}$state"; + } + else if (zip != null) { + fullAddress += "${(0 < fullAddress.length) ? ', ' : ''}$zip"; + } + if (country != null) { + fullAddress += "${(0 < fullAddress.length) ? ', ' : ''}$country"; + } + + String base64FaceImage = rawResult['base64FaceImage']; + Uint8List faceImageData = (base64FaceImage != null) ? base64Decode(base64FaceImage) : null; + MemoryImage faceImage = (faceImageData != null) ? MemoryImage(faceImageData) : null; + + return { + // These should go to PII + kFirstNameFieldName : firstName, + kMiddleNameFieldName : middleName, + kLastNameFieldName : lastName, + kBirthYearFieldName : birthYear, + kAddressFieldName : address, + kStateFieldName : state, + kZipFieldName : zip, + kCountryFieldName : country, + kFaceBase64FieldName : base64FaceImage, + + // These are for display purpose only + kFullNameFieldName : fullName, + kFullAddressFieldName : fullAddress, + kFaceImageFieldName : faceImage, + }; + } + + static String _buildName(String rawName, {int index = 0}) { + String resultName; + if (rawName != null) { + List firstNameComponents = rawName.split(' '); + if ((firstNameComponents != null) && (0 <= index) && (index < firstNameComponents.length)) { + resultName = firstNameComponents[index]; + } + else if (index == 0) { + resultName = rawName; + } + resultName = (resultName != null) ? AppString.capitalize(resultName) : null; + } + return resultName; + } + + Future _applyScan() async { + + UserPiiData updatedUserPiiData; + UserPiiData userPiiData = UserPiiData.fromObject(await Auth().reloadUserPiiData()); + if (userPiiData != null) { + _applyScanResult(userPiiData); + updatedUserPiiData = await Auth().storeUserPiiData(userPiiData); + } + + return (updatedUserPiiData != null); + } + + void _applyScanResult(UserPiiData userPiiData) { + + String photoBase64 = _scanResult[kFaceBase64FieldName]; + if (photoBase64 != null) { + userPiiData.photoBase64 = photoBase64; + } + + String firstName = _scanResult[kFirstNameFieldName]; + if (firstName != null) { + userPiiData.firstName = firstName; + } + + String middleName = _scanResult[kMiddleNameFieldName]; + if (middleName != null) { + userPiiData.middleName = middleName; + } + + String lastName = _scanResult[kLastNameFieldName]; + if (lastName != null) { + userPiiData.lastName = lastName; + } + + String birthYearString = _scanResult[kBirthYearFieldName]; + int birthYear = ((birthYearString != null) && (0 < birthYearString.length)) ? int.tryParse(birthYearString) : null; + if (birthYear != null) { + userPiiData.birthYear = birthYear; + } + + //Don't store this data in PiiData for now +/* String address = _scanResult[kAddressFieldName]; + if ((address != null) && (0 < address.length)) { + userPiiData.address = address; + } + + String state = _scanResult[kStateFieldName]; + if ((state != null) && (0 < state.length)) { + userPiiData.state = state; + } + + String zip = _scanResult[kZipFieldName]; + if ((zip != null) && (0 < zip.length)) { + userPiiData.zip = zip; + } + + String country = _scanResult[kCountryFieldName]; + if ((country != null) && (0 < country.length)) { + userPiiData.country = country; + }*/ + + if (_documenType != null) { + userPiiData.documentType = _documenType; + } +} + + void _goBack() { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } + + void _goNext() { + if (Auth().isLoggedIn) { + widget.onboardingContext['shouldDisplayQrCode'] = true; + } else { + widget.onboardingContext['shouldDisplayQrCode'] = false; + } + Onboarding().next(context, widget); + } + + void _onRescan() { + Analytics.instance.logSelect(target: 'Re-scan') ; + + String analyticsScanType; + List recognizers; + if (_documenType == UserDocumentType.drivingLicense) { + analyticsScanType = Analytics.LogDocumentScanDrivingLicenseType; + recognizers = ['combined']; + } + else if (_documenType == UserDocumentType.passport) { + analyticsScanType = Analytics.LogDocumentScanPassportType; + recognizers = ['passport']; + } + + NativeCommunicator().microBlinkScan(recognizers: recognizers).then((dynamic result) { + Analytics().logDocumentScan(type: analyticsScanType, result: (result != null)); + if (result != null) { + _didRescan(result); + } + }); + } + + void _didRescan(Map scanData) { + setState(() { + _processingScanResult = true; + }); + compute(_buildScanResult, _scanData).then((Map scanResult) { + setState(() { + _processingScanResult = false; + _scanResult = scanResult; + }); + }); + + } + + void _onUseScan() { + Analytics.instance.logSelect(target: 'Use This Scan') ; + + if (_scanResult == null) { + return; + } + + setState(() { + _applyingScanResult = true; + }); + + _applyScan().then((bool result){ + + setState(() { + _applyingScanResult = false; + }); + + if (result) { + _goNext(); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.health.onboarding.covid19.review_scan.message.failed', 'Failed to apply scanned data',)); + } + }); + } + +} \ No newline at end of file diff --git a/lib/ui/onboarding/OnboardingAuthBluetoothPanel.dart b/lib/ui/onboarding/OnboardingAuthBluetoothPanel.dart new file mode 100644 index 00000000..63a0b171 --- /dev/null +++ b/lib/ui/onboarding/OnboardingAuthBluetoothPanel.dart @@ -0,0 +1,246 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/BluetoothServices.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; +import 'package:illinois/ui/widgets/SwipeDetector.dart'; +import 'package:illinois/ui/widgets/TrianglePainter.dart'; + +class OnboardingAuthBluetoothPanel extends StatefulWidget with OnboardingPanel { + final Map onboardingContext; + OnboardingAuthBluetoothPanel({this.onboardingContext}); + + _OnboardingAuthBluetoothPanelState createState() => _OnboardingAuthBluetoothPanelState(); +} + +class _OnboardingAuthBluetoothPanelState extends State { + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + String notRightNow = Localization().getStringEx( + 'panel.onboarding.bluetooth.button.dont_allow.title', + 'Not right now'); + return Scaffold( + backgroundColor: Styles().colors.background, + body: SwipeDetector( + onSwipeLeft: () => _goNext(), + onSwipeRight: () => _goBack(), + child: + ScalableScrollView( + scrollableChild: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Stack( + children: [ + Column( + children: [ + Container(height: 90,color: Styles().colors.surface,), + CustomPaint( + painter: InvertedTrianglePainter(painterColor: Styles().colors.surface, left : true, ), + child: Container( + height: 67, + ), + ), + ], + ), + Column( + children: [ + Row( + children: [ + Expanded(child: Image.asset('images/background-onboarding-squares-dark.png', excludeFromSemantics: true,fit: BoxFit.fitWidth,)), + ], + ), + ], + ), + Container(margin: EdgeInsets.only(top: 80, bottom: 20),child: Center(child: Image.asset('images/enable-bluetooth-header.png', excludeFromSemantics: true,))), + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton(padding: EdgeInsets.only(top: 24, left:12.5, right: 20, bottom: 20), onTap: () => _goBack()), + ) + ], + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Semantics( header: true, hint: Localization().getStringEx("app.common.heading.one.hint","Header 1"), + child:Text(Localization().getStringEx('panel.onboarding.bluetooth.label.title', "Enable Bluetooth"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 28, color:Styles().colors.fillColorPrimary), + )) + ), + Container(height: 12, ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.topCenter, + child: Text( + Localization().getStringEx( + 'panel.onboarding.bluetooth.label.description', + "Use Bluetooth to alert you to potential exposure to COVID-19."), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 20, + color: Styles().colors.fillColorPrimary), + ), + )) + ]), + bottomNotScrollableWidget: + Container(color: Styles().colors.white, child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16,vertical: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ScalableRoundedButton( + label: Localization().getStringEx( + 'panel.onboarding.bluetooth.button.allow.title', + 'Enable Bluetooth'), + hint: Localization().getStringEx( + 'panel.onboarding.bluetooth.button.allow.hint', + ''), + borderColor: Styles().colors.lightBlue, + backgroundColor: Styles().colors.white, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _requestBluetooth(context), + ), + GestureDetector( + onTap: () { + Analytics.instance.logSelect(target: 'Not right now') ; + return _goNext(); + }, + child: Semantics( + label: notRightNow, + hint: Localization().getStringEx( + 'panel.onboarding.bluetooth.button.dont_allow.hint', + ''), + button: true, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.only(top: 15), + child: Text( + notRightNow, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + color: Styles().colors.fillColorPrimary, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.lightBlue, + decorationThickness: 1, + decorationStyle: + TextDecorationStyle.solid), + ))), + ) + ], + ), + ) + )))); + } + + void _requestBluetooth(BuildContext context) { + + Analytics.instance.logSelect(target: 'Enable Bluetooth') ; + + BluetoothStatus authStatus = BluetoothServices().status; + if (authStatus == BluetoothStatus.PermissionNotDetermined) { + BluetoothServices().requestStatus().then((_){ + _goNext(); + }); + } + else if (authStatus == BluetoothStatus.PermissionDenied) { + String message = Localization().getStringEx('panel.onboarding.bluetooth.label.access_denied', 'You have already denied access to this app.'); + showDialog(context: context, builder: (context) => _buildDialogWidget(context, message: message, pushNext: false)); + } + else if (authStatus == BluetoothStatus.PermissionAllowed) { + String message = Localization().getStringEx('panel.onboarding.bluetooth.label.access_granted', 'You have already granted access to this app.'); + showDialog(context: context, builder: (context) => _buildDialogWidget(context, message: message, pushNext: true)); + } + } + + Widget _buildDialogWidget(BuildContext context, {String message, bool pushNext}) { + String okTitle = Localization().getStringEx('dialog.ok.title', 'OK'); + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx('app.title', 'Safer Illinois'), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + message, + textAlign: TextAlign.left, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: message, selection:okTitle); + if (pushNext) { + _goNext(replace : true); + } + else { + _closeDialog(context); + } + }, + child: Text(okTitle)) + ], + ) + ], + ), + ), + ); + } + + void _closeDialog(BuildContext context) { + Navigator.pop(context, true); + } + + void _goNext({bool replace = false}) { + Onboarding().next(context, widget, replace: replace); + } + + void _goBack() { + Analytics.instance.logSelect(target: "Back"); + Navigator.of(context).pop(); + } +} diff --git a/lib/ui/onboarding/OnboardingAuthLocationPanel.dart b/lib/ui/onboarding/OnboardingAuthLocationPanel.dart new file mode 100644 index 00000000..93380f47 --- /dev/null +++ b/lib/ui/onboarding/OnboardingAuthLocationPanel.dart @@ -0,0 +1,227 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/LocationServices.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; +import 'package:illinois/ui/widgets/SwipeDetector.dart'; + +class OnboardingAuthLocationPanel extends StatelessWidget with OnboardingPanel { + final Map onboardingContext; + OnboardingAuthLocationPanel({this.onboardingContext}); + + @override + Widget build(BuildContext context) { + String titleText = Localization().getStringEx('panel.onboarding.location.label.title', "Turn on Location Services"); + String notRightNow = Localization().getStringEx( + 'panel.onboarding.location.button.dont_allow.title', + 'Not right now'); + return Scaffold( + backgroundColor: Styles().colors.background, + body: SwipeDetector( + onSwipeLeft: () => _goNext(context), + onSwipeRight: () => _goBack(context), + child:ScalableScrollView( scrollableChild: + Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Stack(children: [ + Image.asset( + 'images/share-location-header.png', + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + excludeFromSemantics: true, + ), + OnboardingBackButton( + padding: const EdgeInsets.only(left: 10, top: 30, right: 20, bottom: 20), + onTap:() { + Analytics.instance.logSelect(target: "Back"); + _goBack(context); + }), + ]), + Semantics( + label: titleText, + hint: Localization().getStringEx('panel.onboarding.location.label.title.hint', 'Header 1'), + excludeSemantics: true, + child: + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.center, + child: Text(titleText, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 32, + color: Styles().colors.fillColorPrimary), + )), + )), + Container( + height: 12, + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.topCenter, + child: Text( + Localization().getStringEx( + 'panel.onboarding.location.label.description', + "Required for exposure notifications to work on your phone"), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 20, + color: Styles().colors.fillColorPrimary), + ), + )), + ]), + bottomNotScrollableWidget: + Padding( + padding: EdgeInsets.symmetric(horizontal: 24,vertical: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ScalableRoundedButton( + label: Localization().getStringEx( + 'panel.onboarding.location.button.allow.title', + 'Share my Location'), + hint: Localization().getStringEx( + 'panel.onboarding.location.button.allow.hint', + ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.background, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _requestLocation(context), + ), + GestureDetector( + onTap: () { + Analytics.instance.logSelect(target: 'Not right now') ; + return _goNext(context); + }, + child: Semantics( + label: notRightNow, + hint: Localization().getStringEx( + 'panel.onboarding.location.button.dont_allow.hint', + ''), + button: true, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Text( + notRightNow, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + color: Styles().colors.fillColorPrimary, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.fillColorSecondary, + decorationThickness: 1, + decorationStyle: + TextDecorationStyle.solid), + ))), + ) + ], + ), + ) + ))); + } + + void _requestLocation(BuildContext context) async { + Analytics.instance.logSelect(target: 'Share My locaiton') ; + await LocationServices.instance.status.then((LocationServicesStatus status){ + if (status == LocationServicesStatus.ServiceDisabled) { + LocationServices.instance.requestService(); + } + else if (status == LocationServicesStatus.PermissionNotDetermined) { + LocationServices.instance.requestPermission().then((LocationServicesStatus status) { + _goNext(context); + }); + } + else if (status == LocationServicesStatus.PermissionDenied) { + String message = Localization().getStringEx('panel.onboarding.location.label.access_denied', 'You have already denied access to this app.'); + showDialog(context: context, builder: (context) => _buildDialogWidget(context, message:message, pushNext : false )); + } + else if (status == LocationServicesStatus.PermissionAllowed) { + String message = Localization().getStringEx('panel.onboarding.location.label.access_granted', 'You have already granted access to this app.'); + showDialog(context: context, builder: (context) => _buildDialogWidget(context, message:message, pushNext : true )); + } + }); + } + + Widget _buildDialogWidget(BuildContext context, {String message, bool pushNext}) { + String okTitle = Localization().getStringEx('dialog.ok.title', 'OK'); + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx('app.title', 'Safer Illinois'), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + message, + textAlign: TextAlign.left, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: message, selection:okTitle); + if (pushNext) { + _goNext(context, replace : true); + } + else { + _closeDialog(context); + } + }, + child: Text(okTitle)) + ], + ) + ], + ), + ), + ); + } + + void _closeDialog(BuildContext context) { + Navigator.pop(context, true); + } + + void _goNext(BuildContext context, {bool replace = false}) { + Onboarding().next(context, this, replace: replace); + } + + void _goBack(BuildContext context) { + Navigator.of(context).pop(); + } +} diff --git a/lib/ui/onboarding/OnboardingAuthNotificationsPanel.dart b/lib/ui/onboarding/OnboardingAuthNotificationsPanel.dart new file mode 100644 index 00000000..25c1f615 --- /dev/null +++ b/lib/ui/onboarding/OnboardingAuthNotificationsPanel.dart @@ -0,0 +1,216 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/LocalNotifications.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; +import 'package:illinois/ui/widgets/SwipeDetector.dart'; +import 'dart:io' show Platform; + +class OnboardingAuthNotificationsPanel extends StatelessWidget with OnboardingPanel { + final Map onboardingContext; + OnboardingAuthNotificationsPanel({this.onboardingContext}); + + @override + Widget build(BuildContext context) { + String titleText = Localization().getStringEx('panel.onboarding.notifications.label.title', 'Info when you need it'); + String notRightNow = Localization().getStringEx( + 'panel.onboarding.notifications.button.dont_allow.title', + 'Not right now'); + return Scaffold( + backgroundColor: Styles().colors.background, + body: SwipeDetector( + onSwipeLeft: () => _goNext(context) , + onSwipeRight: () => _goBack(context), + child: + ScalableScrollView( + scrollableChild: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Stack(children: [ + Image.asset( + 'images/allow-notifications-header.png', + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + excludeFromSemantics: true, + ), + OnboardingBackButton( + padding: const EdgeInsets.only(left: 10, top: 30, right: 20, bottom: 20), + onTap:() { + Analytics.instance.logSelect(target: "Back"); + _goBack(context); + }), + ]), + Semantics( + label: titleText, + hint: Localization().getStringEx('panel.onboarding.notifications.label.title.hint', 'Header 1'), + excludeSemantics: true, + child: + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.center, + child: Text( + titleText, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 32, + color: Styles().colors.fillColorPrimary), + ), + ))), + Container(height: 12,), + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.topCenter, + child: Text( + Localization().getStringEx('panel.onboarding.notifications.label.description', 'Get notified about COVID-19 info'), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 20, + color: Styles().colors.fillColorPrimary), + )), + ), + ], + ), + bottomNotScrollableWidget: Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ScalableRoundedButton( + label: Localization().getStringEx('panel.onboarding.notifications.button.allow.title', 'Receive Notifications'), + hint: Localization().getStringEx('panel.onboarding.notifications.button.allow.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.background, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onReceiveNotifications(context), + ), + GestureDetector( + onTap: () { + Analytics.instance.logSelect(target: 'Not right now') ; + return _goNext(context); + }, + child: Semantics( + label:notRightNow, + hint:Localization().getStringEx('panel.onboarding.notifications.button.dont_allow.hint', ''), + button: true, + excludeSemantics: true, + child:Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Text( + notRightNow, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + color: Styles().colors.fillColorPrimary, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.fillColorSecondary, + decorationThickness: 1, + decorationStyle: TextDecorationStyle.solid), + ))), + ) + ], + ), + ), + ), + ) + ); + } + + void _onReceiveNotifications(BuildContext context) { + Analytics.instance.logSelect(target: 'Receive Notifications') ; + + //Android does not need for permission for user notifications + if (Platform.isAndroid) { + _goNext(context); + } else if (Platform.isIOS) { + _requestAuthorization(context); + } + } + +void _requestAuthorization(BuildContext context) async { + bool notificationsAuthorized = await NativeCommunicator().queryNotificationsAuthorization("query"); + if (notificationsAuthorized) { + showDialog(context: context, builder: (context) => _buildDialogWidget(context)); + } else { + bool granted = await NativeCommunicator().queryNotificationsAuthorization("request"); + if (granted) { + LocalNotifications().initPlugin(); + Analytics.instance.updateNotificationServices(); + } + print('Notifications granted: $granted'); + _goNext(context); + } + } + +Widget _buildDialogWidget(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx('app.title', 'Safer Illinois'), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + Localization().getStringEx('panel.onboarding.notifications.label.access_granted', 'Your settings have been changed.'), + textAlign: TextAlign.left, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text:"Already have access", selection: "Ok"); + _goNext(context, replace : true); + }, + child: Text(Localization().getStringEx('dialog.ok.title', 'OK'))) + ], + ) + ], + ), + ), + ); + } + + void _goNext(BuildContext context, {bool replace = false}) { + Onboarding().next(context, this, replace: replace); + } + + void _goBack(BuildContext context) { + Navigator.of(context).pop(); + } +} diff --git a/lib/ui/onboarding/OnboardingBackButton.dart b/lib/ui/onboarding/OnboardingBackButton.dart new file mode 100644 index 00000000..4ddd400b --- /dev/null +++ b/lib/ui/onboarding/OnboardingBackButton.dart @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; + +class OnboardingBackButton extends StatelessWidget { + final EdgeInsetsGeometry padding; + final GestureTapCallback onTap; + final String image; + + OnboardingBackButton({this.padding, this.onTap, this.image = 'images/onboarding-back-btn.png'}); + + @override + Widget build(BuildContext context) { + return Semantics( + label: Localization().getStringEx('headerbar.back.title', 'Back'), + hint: Localization().getStringEx('headerbar.back.hint', ''), + button: true, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.translucent, + child: Padding( + padding: padding, + child: Container( + height: 44, + width: 44, + child: Image.asset(image) + ), + ), + ) + ); + } +} \ No newline at end of file diff --git a/lib/ui/onboarding/OnboardingGetStartedPanel.dart b/lib/ui/onboarding/OnboardingGetStartedPanel.dart new file mode 100644 index 00000000..b29e449b --- /dev/null +++ b/lib/ui/onboarding/OnboardingGetStartedPanel.dart @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/SwipeDetector.dart'; +import 'package:illinois/service/Styles.dart'; + +class OnboardingGetStartedPanel extends StatelessWidget with OnboardingPanel { + + final Map onboardingContext; + OnboardingGetStartedPanel({this.onboardingContext}); + + @override + Widget build(BuildContext context) { + + Analytics().accessibilityState = MediaQuery.of(context).accessibleNavigation; + + String strWelcome = Localization().getStringEx( + 'panel.onboarding.get_started.image.welcome.title', + 'Welcome to Illinois'); + + return Scaffold(body: SwipeDetector( + onSwipeLeft: () => _goNext(context), + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Image.asset('images/background-image.png', fit: BoxFit.cover, semanticLabel: strWelcome, + height: double.infinity, + width: double.infinity,), + Container(color: Styles().colors.fillColorPrimaryTransparent80, child: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ + Semantics( + label: Localization().getStringEx("panel.onboarding.get_started.image.safer_in_illinois.title","Safer in Illinois"), + image: true, + excludeSemantics:true, + child: Image.asset('images/safer-illinois.png') + ), + Semantics( + label: Localization().getStringEx("panel.onboarding.get_started.image.powered.title","Powered by Rokwire"), + image: true, + excludeSemantics:true, + child: Padding(padding: EdgeInsets.only(top: 17), child: Image.asset('images/powered-by.png'),) + ) + ],),),), + Column(children: [Expanded(child: Container(),), Padding( + padding: EdgeInsets.all(16), + child: ScalableRoundedButton( + label: Localization().getStringEx( + 'panel.onboarding.get_started.button.get_started.title', + 'Get Started'), + hint: Localization().getStringEx( + 'panel.onboarding.get_started.button.get_started.hint', + ''), + backgroundColor: Styles().colors.fillColorPrimary, + textColor: Styles().colors.white, + onTap: () => _goNext(context), + borderColor: Styles().colors.white, + ), + ) + ],) + ]))); + } + + void _goNext(BuildContext context) { + Analytics.instance.logSelect(target: "Get Started") ; + return Onboarding().next(context, this); + } +} diff --git a/lib/ui/onboarding/OnboardingLoginNetIdPanel.dart b/lib/ui/onboarding/OnboardingLoginNetIdPanel.dart new file mode 100644 index 00000000..d4726c45 --- /dev/null +++ b/lib/ui/onboarding/OnboardingLoginNetIdPanel.dart @@ -0,0 +1,243 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; + +class OnboardingLoginNetIdPanel extends StatefulWidget with OnboardingPanel { + final Map onboardingContext; + OnboardingLoginNetIdPanel({this.onboardingContext}); + _OnboardingLoginNetIdPanelState createState() => _OnboardingLoginNetIdPanelState(); +} + +class _OnboardingLoginNetIdPanelState extends State implements NotificationsListener { + bool _progress = false; + + @override + void initState() { + NotificationService().subscribe(this, [Auth.notifyLoginSucceeded, Auth.notifyLoginFailed, Auth.notifyStarted]); + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + String titleString = Localization().getStringEx('panel.onboarding.login.netid.label.title', 'Connect your NetID'); + String skipTitle = Localization().getStringEx('panel.onboarding.login.netid.button.dont_continue.title', 'Not right now'); + return Scaffold( + backgroundColor: Styles().colors.background, + body: Stack( + children: [ + + ScalableScrollView( + scrollableChild: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Stack( + children: [ + Column( + children: [ + Row( + children: [ + Expanded(child: Image.asset('images/background-onboarding-squares-dark.png', excludeFromSemantics: true,fit: BoxFit.fitWidth,)), + ], + ), + ], + ), + Container(margin: EdgeInsets.only(top: 80, bottom: 20),child: Center(child: Image.asset('images/icon-orange-i.png', excludeFromSemantics: true,))), + Align( + alignment: Alignment.topLeft, + child: OnboardingBackButton(padding: EdgeInsets.only(top: 24, left:12.5, right: 20, bottom: 20), + onTap: () { + Analytics.instance.logSelect(target: "Back"); + Navigator.pop(context); + }), + ) + ], + ), + Container( + height: 24, + ), + Semantics( + label: titleString, + hint: Localization().getStringEx('panel.onboarding.login.netid.label.title.hint', ''), + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 18), + child: Center( + child: Text(titleString, + textAlign: TextAlign.center, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 36, color: Styles().colors.fillColorPrimary)), + )), + ), + Container( + height: 24, + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text(Localization().getStringEx('panel.onboarding.login.netid.label.description', 'Log in with your NetID'), + textAlign: TextAlign.center, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 20, color: Styles().colors.fillColorPrimary))), + Container( + height: 32, + ), + ]), + bottomNotScrollableWidget: + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: EdgeInsets.all(24), + child: ScalableRoundedButton( + label: Localization().getStringEx('panel.onboarding.login.netid.button.continue.title', 'Log in with NetID'), + hint: Localization().getStringEx('panel.onboarding.login.netid.button.continue.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.background, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onLoginTapped()), + ), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => _onSkipTapped(), + child: Semantics( + label: skipTitle, + hint: Localization().getStringEx('panel.onboarding.login.netid.button.dont_continue.hint', 'Skip verification'), + button: true, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.only(bottom: 24), + child: Text( + skipTitle, + textAlign: TextAlign.center, + style: TextStyle( + color: Styles().colors.fillColorPrimary, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.fillColorSecondary, + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + ), + ), + )), + )), + ], + ) + ])), + _progress + ? Container( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ) + : Container(), + ], + )); + } + + Widget _buildDialogWidget(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx('app.title', 'Safer Illinois'), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + Localization().getStringEx('panel.onboarding.login.label.login_failed', 'Unable to login. Please try again later'), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Unable to login", selection: "Ok"); + Navigator.pop(context); + //_finish(); + }, + child: Text(Localization().getStringEx('dialog.ok.title', 'OK'))) + ], + ) + ], + ), + ), + ); + } + + void _onLoginTapped() { + Analytics.instance.logSelect(target: 'Log in with NetID'); + Auth().authenticateWithShibboleth(); + } + + void _onSkipTapped() { + Analytics.instance.logSelect(target: 'Not right now'); + if (Auth().isShibbolethLoggedIn) { + widget.onboardingContext["shouldDisplayQrCode"] = true; + } + Onboarding().next(context, widget); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == Auth.notifyStarted) { + _onLoginStarted(); + } else if (name == Auth.notifyLoginSucceeded) { + onLoginResult(true); + } else if (name == Auth.notifyLoginFailed) { + onLoginResult(false); + } + } + + void _onLoginStarted() { + setState(() { _progress = true; }); + } + void onLoginResult(bool success) { + if (mounted) { + if (success) { + FlexUI().update().then((_){ + setState(() { _progress = false; }); + Onboarding().next(context, widget); + }); + } else { + setState(() { _progress = false; }); + showDialog(context: context, builder: (context) => _buildDialogWidget(context)); + } + } + } + +} diff --git a/lib/ui/onboarding/OnboardingLoginPhoneConfirmPanel.dart b/lib/ui/onboarding/OnboardingLoginPhoneConfirmPanel.dart new file mode 100644 index 00000000..7f9c68a0 --- /dev/null +++ b/lib/ui/onboarding/OnboardingLoginPhoneConfirmPanel.dart @@ -0,0 +1,259 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +import 'package:sprintf/sprintf.dart'; + +class OnboardingLoginPhoneConfirmPanel extends StatefulWidget with OnboardingPanel { + + final Map onboardingContext; + final String phoneNumber; + final ValueSetter onFinish; + + OnboardingLoginPhoneConfirmPanel({this.onboardingContext, this.phoneNumber, this.onFinish}); + + @override + _OnboardingLoginPhoneConfirmPanelState createState() => _OnboardingLoginPhoneConfirmPanelState(); + + @override + bool get onboardingCanDisplay { + return (onboardingContext != null) && onboardingContext['shouldVerifyPhone'] == true; + } +} + +class _OnboardingLoginPhoneConfirmPanelState extends State { + TextEditingController _codeController = TextEditingController(); + String _verificationErrorMsg; + + bool _isLoading = false; + + @override + Widget build(BuildContext context) { + String phoneNumber = Auth().phoneToken?.phone; + String maskedPhoneNumber = AppString.getMaskedPhoneNumber(phoneNumber); + String description = sprintf( + Localization().getStringEx( + 'panel.onboarding.confirm_phone.description.send', 'A one time code has been sent to %s. Enter your code below to continue.'), + [maskedPhoneNumber]); + return Scaffold( + resizeToAvoidBottomInset: false, + body: GestureDetector( + excludeFromSemantics: true, + behavior: HitTestBehavior.translucent, + onTap: ()=>FocusScope.of(context).requestFocus(new FocusNode()), + child: + ScalableScrollView( + scrollableChild: + Stack(children: [ + Padding( + padding: EdgeInsets.only(left: 18, right: 18, top: 24, bottom: 24), + child: + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 64, right: 64, bottom: 12), + child: Text( + Localization().getStringEx("panel.onboarding.confirm_phone.title", + "Confirm your code"), + textAlign: TextAlign.center, + style: + TextStyle(fontSize: 36, color: Styles().colors.fillColorPrimary), + ), + ), + Container( + height: 48, + ), + Padding( + padding: EdgeInsets.only(left: 12, right: 12, bottom: 32), + child: Text( + description, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + color: Styles().colors.fillColorPrimary, + fontFamily: Styles().fontFamilies.regular), + ), + ), + Container( + height: 26, + ), + Padding( + padding: EdgeInsets.only(left: 12, right: 12, bottom: 6), + child: Text( + Localization().getStringEx( + "panel.onboarding.confirm_phone.code.label", + "One-time code"), + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 16, + color: Styles().colors.fillColorPrimary, + fontFamily: Styles().fontFamilies.bold), + ), + ), + Padding( + padding: EdgeInsets.only(left: 12, right: 12, bottom: 12), + child: Semantics( + excludeSemantics: true, + label: Localization().getStringEx("panel.onboarding.confirm_phone.code.label", "One-time code"), + hint: Localization().getStringEx("panel.onboarding.confirm_phone.code.hint", ""), + value: _codeController.text, + child: TextField( + controller: _codeController, + autofocus: false, + onSubmitted: (_) => _clearErrorMsg, + cursorColor: Styles().colors.textBackground, + keyboardType: TextInputType.number, + style: TextStyle( + fontSize: 16, + fontFamily: Styles().fontFamilies.regular, + color: Styles().colors.textBackground), + decoration: InputDecoration( + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.black, + width: 2.0, + style: BorderStyle.solid), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.black, width: 2.0), + ), + ), + ), + )), + Visibility( + visible: AppString.isStringNotEmpty(_verificationErrorMsg), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Text( + AppString.getDefaultEmptyString( + value: _verificationErrorMsg), + style: TextStyle( + color: Colors.red, + fontSize: 14, + fontFamily: Styles().fontFamilies.medium), + ), + ), + ), + ])), + Visibility( + visible: _isLoading, + child: Center( + child: CircularProgressIndicator(), + ), + ), + OnboardingBackButton( + padding: const EdgeInsets.only(left: 10, top: 30, right: 20, bottom: 20), + onTap:() { + Analytics.instance.logSelect(target: "Back"); + Navigator.pop(context); + }), + ]), + bottomNotScrollableWidget: + Padding( + padding: EdgeInsets.symmetric(horizontal: 18, vertical: 8), + child: ScalableRoundedButton( + label: Localization().getStringEx( + "panel.onboarding.confirm_phone.button.confirm.label", + "Confirm phone number"), + hint: Localization().getStringEx( + "panel.onboarding.confirm_phone.button.confirm.hint", ""), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.background, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onTapConfirm())) + ),), + + ); + } + + void _onTapConfirm() { + + if(_isLoading){ + return; + } + + Analytics.instance.logSelect(target: "Confirm phone number"); + _clearErrorMsg(); + _validateCode(); + if (AppString.isStringNotEmpty(_verificationErrorMsg)) { + return; + } + String phoneNumber = (widget.onboardingContext != null) ? widget.onboardingContext["phone"] : widget.phoneNumber; + setState(() { + _isLoading = true; + }); + + Auth() + .validatePhoneNumber(_codeController.text, phoneNumber) + .then((success) => { + _onPhoneVerified(success) + }).whenComplete((){ + if(mounted) { + setState(() { + _isLoading = false; + }); + } + }); + } + + void _onPhoneVerified(bool success) { + if (!success) { + setState(() { + _verificationErrorMsg = Localization().getStringEx( + "panel.onboarding.confirm_phone.validation.server_error.text", + "Failed to verify code"); + }); + } + else if (widget.onboardingContext != null) { + widget.onboardingContext['shouldDisplayResidentInfo'] = true; + Onboarding().next(context, widget); + } + else if (widget.onFinish != null) { + widget.onFinish(widget); + } + } + + void _validateCode() { + String phoneNumberValue = _codeController.text; + if (AppString.isStringEmpty(phoneNumberValue)) { + setState(() { + _verificationErrorMsg = Localization().getStringEx( + "panel.onboarding.confirm_phone.validation.phone_number.text", + "Please, fill your code"); + }); + return; + } + } + + void _clearErrorMsg() { + setState(() { + _verificationErrorMsg = null; + }); + } +} diff --git a/lib/ui/onboarding/OnboardingLoginPhonePanel.dart b/lib/ui/onboarding/OnboardingLoginPhonePanel.dart new file mode 100644 index 00000000..a2f91747 --- /dev/null +++ b/lib/ui/onboarding/OnboardingLoginPhonePanel.dart @@ -0,0 +1,182 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/ui/onboarding/OnboardingLoginPhoneVerifyPanel.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/ScalableScrollView.dart'; + +class OnboardingLoginPhonePanel extends StatefulWidget with OnboardingPanel { + + final Map onboardingContext; + final ValueSetter onFinish; + + OnboardingLoginPhonePanel({this.onboardingContext, this.onFinish}); + + _OnboardingLoginPhonePanelState createState() => _OnboardingLoginPhonePanelState(); +} + +class _OnboardingLoginPhonePanelState extends State { + bool _progress = false; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + String titleString = Localization().getStringEx('panel.onboarding.login.phone.label.title', 'Verify your phone number'); + String skipTitle = Localization().getStringEx('panel.onboarding.login.phone.button.dont_continue.title', 'Not right now'); + return Scaffold( + backgroundColor: Styles().colors.background, + body: Stack( + children: [ + ScalableScrollView( + scrollableChild: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Image.asset( + "images/login-header.png", + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + excludeFromSemantics: true, + ), + OnboardingBackButton( + padding: const EdgeInsets.only(left: 10, top: 30, right: 20, bottom: 20), + onTap: () { + Analytics.instance.logSelect(target: "Back"); + Navigator.pop(context); + }), + ], + ), + Container( + height: 24, + ), + Semantics( + label: titleString, + hint: Localization().getStringEx('panel.onboarding.login.phone.label.title.hint', ''), + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 18), + child: Center( + child: Text(titleString, + textAlign: TextAlign.center, style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 36, color: Styles().colors.fillColorPrimary)), + )), + ), + Container( + height: 24, + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text(Localization().getStringEx('panel.onboarding.login.phone.label.description', 'This saves your preferences so you can have the same experience on more than one device.'), + textAlign: TextAlign.center, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 20, color: Styles().colors.fillColorPrimary))), + Container( + height: 32, + ), + ]), + bottomNotScrollableWidget: Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(24), + child: ScalableRoundedButton( + label: Localization().getStringEx('panel.onboarding.login.phone.button.continue.title', 'Verify My Phone Number'), + hint: Localization().getStringEx('panel.onboarding.login.phone.button.continue.hint', ''), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.background, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onLoginTapped()), + ), + ), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => _onSkipTapped(), + child: Semantics( + label: skipTitle, + hint: Localization().getStringEx('panel.onboarding.login.phone.button.dont_continue.hint', 'Skip verification'), + button: true, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.only(bottom: 24), + child: Text( + skipTitle, + textAlign: TextAlign.center, + style: TextStyle( + color: Styles().colors.fillColorPrimary, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.fillColorSecondary, + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + ), + ), + )), + )), + ], + ) + ]) + )), + _progress + ? Container( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ) + : Container(), + ], + )); + } + + void _onLoginTapped() { + Analytics.instance.logSelect(target: 'Verify My Phone Number'); + if (widget.onboardingContext != null) { + widget.onboardingContext['shouldVerifyPhone'] = true; + Onboarding().next(context, widget); + } + else { + Navigator.push(context, CupertinoPageRoute(builder: (context) => OnboardingLoginPhoneVerifyPanel(onFinish: widget.onFinish))); + } + } + + void _onSkipTapped() { + Analytics.instance.logSelect(target: 'Not right now'); + if (widget.onboardingContext != null) { + widget.onboardingContext['shouldVerifyPhone'] = false; + Onboarding().next(context, widget); + } + else if (widget.onFinish != null) { + widget.onFinish(null); + } + } +} diff --git a/lib/ui/onboarding/OnboardingLoginPhoneVerifyPanel.dart b/lib/ui/onboarding/OnboardingLoginPhoneVerifyPanel.dart new file mode 100644 index 00000000..eca10171 --- /dev/null +++ b/lib/ui/onboarding/OnboardingLoginPhoneVerifyPanel.dart @@ -0,0 +1,300 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/ui/onboarding/OnboardingLoginPhoneConfirmPanel.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class OnboardingLoginPhoneVerifyPanel extends StatefulWidget with OnboardingPanel { + + final Map onboardingContext; + final ValueSetter onFinish; + + OnboardingLoginPhoneVerifyPanel({this.onboardingContext, this.onFinish}); + + @override + _OnboardingLoginPhoneVerifyPanelState createState() => + _OnboardingLoginPhoneVerifyPanelState(); + + @override + bool get onboardingCanDisplay { + return (onboardingContext != null) && onboardingContext['shouldVerifyPhone'] == true; + } +} + +class _OnboardingLoginPhoneVerifyPanelState + extends State { + TextEditingController _phoneNumberController = TextEditingController(); + VerificationMethod _verificationMethod = VerificationMethod.sms; + String _validationErrorMsg; + + bool _isLoading = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Styles().colors.background, + body: GestureDetector( + excludeFromSemantics: true, + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).requestFocus(new FocusNode()), + child: Stack(children: [ + Padding( + padding: EdgeInsets.only(left: 18, right: 18, top: 28, bottom: 24), + child: SafeArea(child: SingleChildScrollView(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 36), + child: Text( + Localization().getStringEx( + 'panel.onboarding.verify_phone.title', + 'Connect to Illinois'), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 36, + color: Styles().colors.fillColorPrimary))), + Container( + height: 48, + ), + Padding( + padding: EdgeInsets.only(left: 12, right: 12, bottom: 32), + child: Text( + Localization().getStringEx( + "panel.onboarding.verify_phone.description", + "To verify your phone number, choose your preferred contact channel, and we'll send you a one-time authentication code."), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 18, + color: Styles().colors.fillColorPrimary))), + Padding( + padding: EdgeInsets.only(left: 12, top: 12, bottom: 6), + child: Text( + Localization().getStringEx( + "panel.onboarding.verify_phone.phone_number.label", + "Phone number"), + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 16, + color: Styles().colors.fillColorPrimary, + fontFamily: Styles().fontFamilies.bold), + ), + ), + Padding( + padding: EdgeInsets.only(left: 12, right: 12, bottom: 12), + child: Semantics( + label: Localization().getStringEx( + "panel.onboarding.verify_phone.phone_number.label", + "Phone number"), + hint: Localization().getStringEx( + "panel.onboarding.verify_phone.phone_number.hint", + ""), + textField: true, + excludeSemantics: true, + value: _phoneNumberController.text, + child: TextField( + controller: _phoneNumberController, + autofocus: false, + onSubmitted: (_) => _clearErrorMsg, + cursorColor: Styles().colors.textBackground, + keyboardType: TextInputType.phone, + style: TextStyle( + fontSize: 16, + fontFamily: Styles().fontFamilies.regular, + color: Styles().colors.textBackground), + decoration: InputDecoration( + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Colors.black, + width: 2.0, + style: BorderStyle.solid), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.black, width: 2.0), + ), + ), + ), + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Semantics( + excludeSemantics: true, + label: Localization().getStringEx("panel.onboarding.verify_phone.text_me.label", "Text me"), + hint: Localization().getStringEx("panel.onboarding.verify_phone.text_me.hint", ""), + selected: _verificationMethod == VerificationMethod.sms, + button: true, + child: Radio( + activeColor: Styles().colors.fillColorSecondary, + value: VerificationMethod.sms, + groupValue: _verificationMethod, + onChanged: _onMethodChanged, + ), + ), + Text( + Localization().getStringEx( + "panel.onboarding.verify_phone.text_me.label", + "Text me"), + style: TextStyle( + fontSize: 16, fontFamily: Styles().fontFamilies.regular), + ) + ], + ), + ], + ), + Visibility( + visible: AppString.isStringNotEmpty(_validationErrorMsg), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text( + AppString.getDefaultEmptyString(value: _validationErrorMsg), + style: TextStyle( + color: Colors.red, + fontSize: 14, + fontFamily: Styles().fontFamilies.medium), + ), + ), + ), + Container( + height: 48, + ), + ], + ),),), + ), + Visibility( + visible: _isLoading, + child: Center( + child: CircularProgressIndicator(), + ), + ), + OnboardingBackButton( + padding: const EdgeInsets.only(left: 10, top: 30, right: 20, bottom: 20), + onTap: () { + Analytics.instance.logSelect(target: "Back"); + Navigator.pop(context); + }), Align(alignment: Alignment.bottomCenter, child: + Padding(padding: EdgeInsets.only(left: 18, right: 18, bottom: 24),child: ScalableRoundedButton( + label: Localization().getStringEx( + "panel.onboarding.verify_phone.button.next.label", + "Next"), + hint: Localization().getStringEx( + "panel.onboarding.verify_phone.button.next.hint", ""), + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.background, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onTapNext()),),) + ],), + )); + } + + void _onTapNext() { + if (_isLoading) { + return; + } + + Analytics.instance.logSelect(target: "Next"); + _clearErrorMsg(); + _validateUserInput(); + if (AppString.isStringNotEmpty(_validationErrorMsg)) { + return; + } + String phoneNumber = _phoneNumberController.text; + if (AppString.isStringNotEmpty(phoneNumber) && + !phoneNumber.startsWith("+1") && + kReleaseMode) { + phoneNumber = '+1$phoneNumber'; + } + setState(() { + _isLoading = true; + }); + Auth() + .initiatePhoneNumber(phoneNumber, _verificationMethod) + .then((success) => {_onPhoneInitiated(phoneNumber, success)}) + .whenComplete(() { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + }); + } + + void _onMethodChanged(VerificationMethod method) { + Analytics.instance.logSelect(target: method?.toString()); + FocusScope.of(context).requestFocus(new FocusNode()); + setState(() { + _verificationMethod = method; + }); + } + + void _onPhoneInitiated(String phoneNumber, bool success) { + if (!success) { + setState(() { + _validationErrorMsg = Localization().getStringEx( + "panel.onboarding.verify_phone.validation.server_error.text", + "Please enter a valid phone number"); + }); + } + else if(widget.onboardingContext != null) { + widget.onboardingContext["phone"] = phoneNumber; + Onboarding().next(context, widget); + } + else { + Navigator.push(context, CupertinoPageRoute(builder: (context) => OnboardingLoginPhoneConfirmPanel(phoneNumber: phoneNumber, onFinish: widget.onFinish))); + } + } + + void _validateUserInput() { + String phoneNumberValue = _phoneNumberController.text; + if (AppString.isStringEmpty(phoneNumberValue)) { + setState(() { + _validationErrorMsg = Localization().getStringEx( + 'panel.onboarding.verify_phone.validation.phone_number.text', + "Please, type your phone number"); + }); + return; + } + if (_verificationMethod == null) { + setState(() { + _validationErrorMsg = Localization().getStringEx( + "panel.onboarding.verify_phone.validation.channel_selection.text", + "Please, select verification method"); + }); + return; + } + } + + void _clearErrorMsg() { + setState(() { + _validationErrorMsg = null; + }); + } +} diff --git a/lib/ui/onboarding/OnboardingRolesPanel.dart b/lib/ui/onboarding/OnboardingRolesPanel.dart new file mode 100644 index 00000000..a3df1795 --- /dev/null +++ b/lib/ui/onboarding/OnboardingRolesPanel.dart @@ -0,0 +1,189 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/ui/widgets/RoleGridButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/onboarding/OnboardingBackButton.dart'; +import 'package:illinois/service/Styles.dart'; + +class OnboardingRolesPanel extends StatefulWidget with OnboardingPanel { + final Map onboardingContext; + OnboardingRolesPanel({this.onboardingContext}); + + @override + _OnboardingRoleSelectionPanelState createState() => + _OnboardingRoleSelectionPanelState(); +} + +class _OnboardingRoleSelectionPanelState extends State { + Set _selectedRoles; + bool _updating = false; + + bool get _allowNext => _selectedRoles != null && _selectedRoles.isNotEmpty; + + @override + void initState() { + _selectedRoles = User().roles ?? Set(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final double gridSpacing = 5; + return Scaffold( + backgroundColor: Styles().colors.background, + body: SafeArea(child: Column( children: [ + Container(color: Styles().colors.white, child: Padding(padding: EdgeInsets.only(top: 10, bottom: 10), + child: Row(children: [ + OnboardingBackButton(image: 'images/chevron-left.png', padding: const EdgeInsets.only(left: 10,), + onTap:() { + Analytics.instance.logSelect(target: "Back"); + Navigator.pop(context); + }), + Expanded(child: Column(children: [ + Semantics( + label: Localization().getStringEx('panel.onboarding.roles.label.title', 'Who are you?').toLowerCase(), + hint: Localization().getStringEx('panel.onboarding.roles.label.title.hint', 'Header 1').toLowerCase(), + excludeSemantics: true, + child: Text(Localization().getStringEx('panel.onboarding.roles.label.title', 'Who are you?'), + style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 24, color: Styles().colors.fillColorPrimary), + ), + ), + Padding(padding: EdgeInsets.only(top: 8), + child: Text(Localization().getStringEx('panel.onboarding.roles.label.description', 'Select all that apply'), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground), + ), + ) + ],),), + Padding(padding: EdgeInsets.only(left: 42),), + ],), + ),), + + Expanded(child: SingleChildScrollView(child: Padding(padding: EdgeInsets.only(left: 16, right: 8, ), child: + Column(children: [ + Row(children: [ + Flexible(flex: 1, child: RoleGridButton( + title: Localization().getStringEx('panel.onboarding.roles.button.student.title', 'University Student'), + hint: Localization().getStringEx('panel.onboarding.roles.button.student.hint', ''), + iconPath: 'images/icon-persona-student-normal.png', + selectedIconPath: 'images/icon-persona-student-selected.png', + selectedBackgroundColor: Styles().colors.fillColorSecondary, + selected: (_selectedRoles.contains(UserRole.student)), + data: UserRole.student, + sortOrder: 1, + onTap: _onRoleGridButton, + ),), + Container(height: gridSpacing,), + Flexible(flex: 1, child: RoleGridButton( + title: Localization().getStringEx('panel.onboarding.roles.button.employee.title', 'Employee/Affiliate'), + hint: Localization().getStringEx('panel.onboarding.roles.button.employee.hint', ''), + iconPath: 'images/icon-persona-employee-normal.png', + selectedIconPath: 'images/icon-persona-employee-selected.png', + selectedBackgroundColor: Styles().colors.accentColor3, + selected: (_selectedRoles.contains(UserRole.employee)), + data: UserRole.employee, + sortOrder: 4, + onTap: _onRoleGridButton, + ),) + ],), + /*Row(children: [Expanded(child: RoleGridButton( + title: Localization().getStringEx('panel.onboarding.roles.button.resident.title', 'Illinois Resident'), + hint: Localization().getStringEx('panel.onboarding.roles.button.resident.hint', ''), + iconPath: 'images/icon-persona-resident-normal.png', + selectedIconPath: 'images/icon-persona-resident-selected.png', + selectedBackgroundColor: Styles().colors.fillColorPrimary, + selectedTextColor: Colors.white, + selected:(_selectedRoles.contains(UserRole.resident)), + data: UserRole.resident, + sortOrder: 7, + onTap: _onRoleGridButton, + ),)],)*/ + ],),),),), + + Container(color: Styles().colors.white, child: Padding(padding: EdgeInsets.only(left: 24, right: 24, top: 10, bottom: 20), + child: Stack(children:[ + ScalableRoundedButton( + label: _allowNext ? Localization().getStringEx('panel.onboarding.roles.button.continue.enabled.title', 'Confirm') : Localization().getStringEx('panel.onboarding.roles.button.continue.disabled.title', 'Select one'), + hint: Localization().getStringEx('panel.onboarding.roles.button.continue.hint', ''), + enabled: _allowNext, + backgroundColor: (_allowNext ? Styles().colors.white : Styles().colors.background), + borderColor: (_allowNext + ? Styles().colors.fillColorSecondary + : Styles().colors.fillColorPrimaryTransparent03), + textColor: (_allowNext + ? Styles().colors.fillColorPrimary + : Styles().colors.fillColorPrimaryTransparent03), + onTap: () => _onExploreClicked()), + Visibility( + visible: _updating, + child: Container( + height: 48, + child: Align( + alignment:Alignment.center, + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary),),),),),), + ]), + ),) + + ],),), + ); + } + + void _onRoleGridButton(RoleGridButton button) { + + if (button != null) { + + UserRole role = button.data as UserRole; + + Analytics.instance.logSelect(target: "Role: " + role.toString()); + + if (_selectedRoles.contains(role)) { + _selectedRoles.remove(role); + } else { + _selectedRoles.add(role); + } + + setState(() {}); + + } + } + + void _onExploreClicked() { + Analytics.instance.logSelect(target:"Confirm"); + if (_selectedRoles != null && _selectedRoles.isNotEmpty && !_updating) { + User().roles = _selectedRoles; + setState(() { _updating = true; }); + FlexUI().update().then((_){ + if (mounted) { + setState(() { _updating = false; }); + Onboarding().next(context, widget); + } + }); + } + } +} diff --git a/lib/ui/onboarding/OnboardingUpgradePanel.dart b/lib/ui/onboarding/OnboardingUpgradePanel.dart new file mode 100644 index 00000000..1c721c8e --- /dev/null +++ b/lib/ui/onboarding/OnboardingUpgradePanel.dart @@ -0,0 +1,196 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:sprintf/sprintf.dart'; +import 'package:url_launcher/url_launcher.dart' as url_launcher; + +class OnboardingUpgradePanel extends StatefulWidget { + final String requiredVersion; + final String availableVersion; + OnboardingUpgradePanel({Key key, this.requiredVersion, this.availableVersion}) + : super(key: key); + + @override + _OnboardingUpgradePanelState createState() => _OnboardingUpgradePanelState(); +} + +class _OnboardingUpgradePanelState extends State { + + @override + Widget build(BuildContext context) { + + Analytics().accessibilityState = MediaQuery.of(context).accessibleNavigation; + + String appName = Localization().getStringEx('app.title', 'Safer Illinois'); + String appVersion = Config().appVersion; + String title, message; + if (widget.requiredVersion != null) { + title = Localization().getStringEx('panel.onboarding.upgrade.required.label.title', 'Upgrade Required'); + message = sprintf(Localization().getStringEx('panel.onboarding.upgrade.required.label.description', '%s app version %s requires an upgrade to version %s or later.'), [appName, appVersion, widget.requiredVersion]) + ; + } else if (widget.availableVersion != null) { + title = Localization().getStringEx('panel.onboarding.upgrade.available.label.title', 'Upgrade Available'); + message = sprintf(Localization().getStringEx('panel.onboarding.upgrade.available.label.description', '%s app version %s has newer version %s available.'), [appName, appVersion, widget.availableVersion]); + } + String notNow = Localization().getStringEx('panel.onboarding.upgrade.button.not_now.title', 'Not right now'); + String dontShow = Localization().getStringEx('panel.onboarding.upgrade.button.dont_show.title', 'Don\'t show again'); + bool canSkip = (widget.requiredVersion == null); + + return Scaffold( + backgroundColor: Styles().colors.background, + body: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Image.asset( + 'images/login-header.png', + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + excludeFromSemantics: true, + ), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 0), + child: Align( + alignment: Alignment.center, + child: Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 32, + color: Styles().colors.fillColorPrimary), + )), + )), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 40), + child: Align( + alignment: Alignment.topCenter, + child: Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 20, + color: Styles().colors.fillColorPrimary), + ), + ))), + Padding( + padding: EdgeInsets.symmetric(horizontal: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RoundedButton( + label: Localization().getStringEx('panel.onboarding.upgrade.button.upgrade.title', 'Upgrade'), + hint: Localization().getStringEx('panel.onboarding.upgrade.button.upgrade.hint', ''), + backgroundColor: Styles().colors.fillColorSecondary, + onTap: () => _onUpgradeClicked(context), + ), + canSkip + ? Row( + children: [ + GestureDetector( + onTap: () => _onDontShowAgainClicked(context), + child: Semantics( + label: dontShow, + hint: Localization().getStringEx('panel.onboarding.upgrade.button.dont_show.hint', ''), + button: true, + excludeSemantics: true, + child: Padding( + padding: + EdgeInsets.symmetric(vertical: 20), + child: Text( + dontShow, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + color: Styles().colors.fillColorPrimary, + decoration: + TextDecoration.underline, + decorationColor: + Styles().colors.fillColorSecondary, + decorationThickness: 1, + decorationStyle: + TextDecorationStyle.solid), + ))), + ), + Expanded(child: Container()), + GestureDetector( + onTap: () => _onNotRightNowClicked(context), + child: Semantics( + label: notNow, + hint: Localization().getStringEx('panel.onboarding.upgrade.button.not_now.hint', ''), + button: true, + excludeSemantics: true, + child: Padding( + padding: + EdgeInsets.symmetric(vertical: 20), + child: Text( + notNow, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + color: Styles().colors.fillColorPrimary, + decoration: + TextDecoration.underline, + decorationColor: + Styles().colors.fillColorSecondary, + decorationThickness: 1, + decorationStyle: + TextDecorationStyle.solid), + ))), + ), + ], + ) + : Padding( + padding: EdgeInsets.symmetric(vertical: 28), + ), + ], + ), + ), + ], + ))); + } + + void _onUpgradeClicked(BuildContext context) async { + String upgradeUrl = Config().upgradeUrl; + if ((upgradeUrl != null) && await url_launcher.canLaunch(upgradeUrl)) { + await url_launcher.launch(upgradeUrl, forceSafariVC: false); + } + } + + void _onNotRightNowClicked(BuildContext context) { + if (widget.availableVersion != null) { + Config().setUpgradeAvailableVersionReported(widget.availableVersion, + permanent: false); + } + } + + void _onDontShowAgainClicked(BuildContext context) { + if (widget.availableVersion != null) { + Config().setUpgradeAvailableVersionReported(widget.availableVersion, + permanent: true); + } + } +} diff --git a/lib/ui/settings/SettingsConsentPanel.dart b/lib/ui/settings/SettingsConsentPanel.dart new file mode 100644 index 00000000..b231442e --- /dev/null +++ b/lib/ui/settings/SettingsConsentPanel.dart @@ -0,0 +1,245 @@ + + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; + +class SettingsConsentPanel extends StatefulWidget{ + SettingsConsentPanel(); + _SettingsConsentPanelState createState() => _SettingsConsentPanelState(); +} + +class _SettingsConsentPanelState extends State implements NotificationsListener{ + + bool _isDisabling = false; + bool _isEnabling = false; + + @override + void initState() { + super.initState(); + NotificationService().subscribe(this, [Health.notifyUserUpdated]); + } + + @override + void dispose() { + super.dispose(); + NotificationService().unsubscribe(this); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + 'Automatic Test Results', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: Styles().fontFamilies.extraBold, + letterSpacing: 1.0, + ), + textAlign: TextAlign.center, + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(22), + child: Column( + children: [ + Text('This feature allows you to receive COVID-19 test results from your healthcare provider directly in the app. Results are encrypted, so only you can see them.', + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + ), + ), + Container(height: 18,), + Stack( + alignment: Alignment.center, + children: [ + ToggleRibbonButton( + label: "I consent to connect test results from my healthcare provider with the Safer Illinois app.", + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontFamily: Styles().fontFamilies.medium, + fontSize: 14 + ), + height: null, + border: Border.all(width: 1, color: Styles().colors.surfaceAccent), + borderRadius: BorderRadius.all(Radius.circular(4)), + toggled: Health().healthUser.consent, + onTap: (){ + if(!Health().healthUser.consent){ + _onConsentEnabled(); + } + else{ + showDialog(context: context, builder: (context) => _buildConsentDialog(context)); + } + }, + ), + _isEnabling ? Align(alignment: Alignment.center, child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary), strokeWidth: 2,)) : Container() + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildConsentDialog(BuildContext context) { + return StatefulBuilder( + builder: (context, setState){ + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)) + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Styles().colors.fillColorPrimary, + borderRadius: BorderRadius.vertical(top: Radius.circular(8)) + ), + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Center( + child: Text( + "Automatic Test Results", + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 24, color: Colors.white), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Styles().colors.white, width: 2), + ), + child: Center( + child: Text( + '\u00D7', + style: TextStyle( + fontSize: 24, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + "By removing your consent, you will no longer receive automatic test results from your health provider.\n\nPrevious test results will remain in your COVID-19 event history. You can delete them by accessing Your COVID-19 Event History in the Privacy Center.", + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: RoundedButton( + onTap: () { + Analytics.instance.logAlert(text: "Consent", selection: "No"); + Navigator.pop(context); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorPrimary, + textColor: Styles().colors.fillColorPrimary, + label: "No"), + ), + Container( + width: 10, + ), + Expanded( + child: Stack( + alignment: Alignment.center, + children: [ + RoundedButton( + onTap: () => _onConsentDisabled(context, setState), + backgroundColor: Styles().colors.fillColorSecondaryVariant, + borderColor: Styles().colors.fillColorSecondaryVariant, + textColor: Styles().colors.surface, + label: 'Remove Consent'), + _isDisabling ? Align(alignment: Alignment.center, child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary), strokeWidth: 2,)) : Container() + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + void _onConsentDisabled(BuildContext context, StateSetter _setState){ + if(_isDisabling){ + return; + } + _setState((){ + _isDisabling = true; + }); + Health().loginUser(consent: false, exposureNotification: (Health()?.healthUser?.exposureNotification ?? false)).whenComplete((){ + _setState((){ + _isDisabling = false; + }); + Navigator.pop(context); + }); + } + + void _onConsentEnabled(){ + if(_isEnabling){ + return; + } + setState((){ + _isEnabling = true; + }); + Health().loginUser(consent: true , exposureNotification: (Health()?.healthUser?.exposureNotification ?? false)).whenComplete((){ + setState((){ + _isEnabling = false; + }); + }); + } + + @override + void onNotification(String name, param) { + if(name == Health.notifyUserUpdated){ + setState(() {}); + } + } +} \ No newline at end of file diff --git a/lib/ui/settings/SettingsDebugPanel.dart b/lib/ui/settings/SettingsDebugPanel.dart new file mode 100644 index 00000000..44cdfef6 --- /dev/null +++ b/lib/ui/settings/SettingsDebugPanel.dart @@ -0,0 +1,492 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/ui/health/debug/Covid19DebugActionPanel.dart'; +import 'package:illinois/ui/health/debug/Covid19DebugCreateEventPanel.dart'; +import 'package:illinois/ui/health/debug/Covid19DebugExposureLogsPanel.dart'; +import 'package:illinois/ui/health/debug/Covid19DebugExposurePanel.dart'; +import 'package:illinois/ui/health/debug/Covid19DebugKeysPanel.dart'; +import 'package:illinois/ui/health/debug/Covid19DebugSymptomsPanel.dart'; +import 'package:illinois/ui/health/debug/Covid19DebugPendingEventsPanel.dart'; +import 'package:illinois/ui/health/debug/Covid19DebugTraceContactPanel.dart'; +import 'package:illinois/ui/settings/debug/MessagingPanel.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; + +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class SettingsDebugPanel extends StatefulWidget { + @override + _SettingsDebugPanelState createState() => _SettingsDebugPanelState(); +} + +class _SettingsDebugPanelState extends State { + + DateTime _offsetDate; + ConfigEnvironment _selectedEnv; + + final TextEditingController _mapThresholdDistanceController = TextEditingController(); + + @override + void initState() { + + _offsetDate = Storage().offsetDate; + + _mapThresholdDistanceController.text = '${Storage().debugMapThresholdDistance}'; + + _selectedEnv = Config().configEnvironment; + + super.initState(); + } + + @override + void dispose() { + + // Map Threshold Distance + int mapThresholdDistance = (_mapThresholdDistanceController.text != null) ? int.tryParse(_mapThresholdDistanceController.text) : null; + if (mapThresholdDistance != null) { + Storage().debugMapThresholdDistance = mapThresholdDistance; + } + _mapThresholdDistanceController.dispose(); + + + super.dispose(); + } + + String get _userDebugData{ + String userDataText = prettyPrintJson((User()?.data?.toJson())); + String authInfoText = prettyPrintJson(Auth()?.authInfo?.toJson()); + String userData = "UserData: " + (userDataText ?? "unknown") + "\n\n" + + "AuthInfo: " + (authInfoText ?? "unknown"); + return userData; + } + + @override + Widget build(BuildContext context) { + String userUuid = User().uuid; + String pid = Storage().userPid; + String firebaseProjectId = FirebaseMessaging().projectID; + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + Localization().getStringEx("panel.debug.header.title", "Debug"), + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w900, letterSpacing: 1.0), + ), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: SafeArea( + child: Container( + color: Styles().colors.background, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 5), + child: Text(AppString.isStringNotEmpty(userUuid) ? 'Uuid: $userUuid' : "unknown uuid"), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 5), + child: Text(AppString.isStringNotEmpty(pid) ? 'PID: $pid' : "unknown pid"), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 5), + child: Text('Firebase: $firebaseProjectId'), + ), + + Container(height: 1, color: Styles().colors.surfaceAccent), + ToggleRibbonButton(label: 'Display all times in Central Time', toggled: !Storage().debugUseDeviceLocalTimeZone, onTap: _onUseDeviceLocalTimeZoneToggled), + ToggleRibbonButton(label: 'Show map location source', toggled: Storage().debugMapLocationProvider, onTap: _onMapLocationProvider), + ToggleRibbonButton(label: 'Show map levels', toggled: !Storage().debugMapHideLevels, onTap: _onMapShowLevels), + Container(height: 1, color: Styles().colors.surfaceAccent), + Container(color: Colors.white, child: Padding(padding: EdgeInsets.only(top: 5), child: Container(height: 1, color: Styles().colors.surfaceAccent))), + Container( + color: Colors.white, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: TextFormField( + controller: _mapThresholdDistanceController, + keyboardType: TextInputType.number, + validator: _validateThresoldDistance, + decoration: InputDecoration( + border: OutlineInputBorder(), hintText: "Enter map threshold distance in meters", labelText: 'Threshold Distance (meters)')), + )), + Container(color: Colors.white, child: Padding(padding: EdgeInsets.only(top: 5), child: Container(height: 1, color: Styles().colors.surfaceAccent))), + Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Padding( + padding: EdgeInsets.only(left: 16), child: Text('Config Environment: '),), ListView.separated( + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + separatorBuilder: (context, index) => Divider(color: Colors.transparent), + itemCount: ConfigEnvironment.values.length, + itemBuilder: (context, index) { + ConfigEnvironment environment = ConfigEnvironment.values[index]; + RadioListTile widget = RadioListTile( + title: Text(configEnvToString(environment)), value: environment, groupValue: _selectedEnv, onChanged: _onConfigChanged); + return widget; + }, + ) + ],),), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "Clear Offset", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: () { + _clearDateOffset(); + }, + )), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: Text(_offsetDate != null ? AppDateTime().formatDateTime(_offsetDate, format: AppDateTime.gameResponseDateTimeFormat2) : "None", + textAlign: TextAlign.end), + )) + ], + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "Sports Offset", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: () { + _changeDate(); + }, + )), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "Messaging", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onMessagingClicked())), + Visibility( + visible: true, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "User Profile Info", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onUserProfileInfoClicked(context))), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "COVID-19: Keys", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapCovid19Keys)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "COVID-19 Create Event", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapCreateCovid19Event)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "COVID-19 Pending Events", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapCovid19PendingEvents)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "COVID-19 Trace Contact", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapTraceCovid19Contact)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "COVID-19 Report Symptoms", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapReportCovid19Symptoms)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "COVID-19 Create Action", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapCreateCovid19Action)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "COVID-19 Exposures", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapCovid19Exposures)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: RoundedButton( + label: "COVID-19 Exposure Logs", + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapCovid19ExposureLogs)), + Padding(padding: EdgeInsets.only(top: 5), child: Container()), + ], + ), + ), + ), + ), + ), + ], + ), + backgroundColor: Styles().colors.background, + ); + } + + // Helpers + + String _validateThresoldDistance(String value) { + return (int.tryParse(value) == null) ? 'Please enter a number.' : null; + } + + _clearDateOffset() { + setState(() { + Storage().offsetDate = _offsetDate = null; + }); + } + + _changeDate() async { + DateTime offset = _offsetDate ?? DateTime.now(); + + DateTime firstDate = DateTime.fromMillisecondsSinceEpoch(offset.millisecondsSinceEpoch).add(Duration(days: -365)); + DateTime lastDate = DateTime.fromMillisecondsSinceEpoch(offset.millisecondsSinceEpoch).add(Duration(days: 365)); + + DateTime date = await showDatePicker( + context: context, + firstDate: firstDate, + lastDate: lastDate, + initialDate: offset, + builder: (BuildContext context, Widget child) { + return Theme( + data: ThemeData.light(), + child: child, + ); + }, + ); + + if (date == null) return; + + TimeOfDay time = await showTimePicker(context: context, initialTime: new TimeOfDay(hour: date.hour, minute: date.minute)); + if (time == null) return; + + int endHour = time != null ? time.hour : date.hour; + int endMinute = time != null ? time.minute : date.minute; + offset = new DateTime(date.year, date.month, date.day, endHour, endMinute); + + setState(() { + Storage().offsetDate = _offsetDate = offset; + }); + } + + void _onMapLocationProvider() { + setState(() { + Storage().debugMapLocationProvider = !Storage().debugMapLocationProvider; + }); + } + + void _onMapShowLevels() { + setState(() { + Storage().debugMapHideLevels = !Storage().debugMapHideLevels; + }); + } + + void _onUseDeviceLocalTimeZoneToggled() { + setState(() { + Storage().debugUseDeviceLocalTimeZone = !Storage().debugUseDeviceLocalTimeZone; + }); + } + + Function _onMessagingClicked() { + return () { + Navigator.push(context, CupertinoPageRoute(builder: (context) => MessagingPanel())); + }; + } + + Function _onUserProfileInfoClicked(BuildContext context) { + return () { + showDialog( + context: context, + builder: (_) => Material( + type: MaterialType.transparency, + borderRadius: BorderRadius.only(topLeft: Radius.circular(5), topRight: Radius.circular(5)), + child: + Dialog( + //backgroundColor: Color(0x00ffffff), + child:Container( + child: Column( + children: [ + Container( + color: Styles().colors.fillColorPrimary, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container(width: 20,), + Expanded( + child: RoundedButton( + label: "Copy to clipboard", + borderColor: Styles().colors.fillColorSecondary, + onTap: _onTapCopyToClipboard, + ), + ), + Container(width: 20,), + GestureDetector( + onTap: ()=>Navigator.of(context).pop(), + child: Padding( + padding: EdgeInsets.only(right: 10, top: 10), + child: Text('\u00D7', + style: TextStyle( + color: Colors.white, + fontFamily: Styles().fontFamilies.medium, + fontSize: 50 + ), + ), + ), + ) + ], + ), + ), + Expanded( + child: Container( child: + SingleChildScrollView( + child: Container(color: Styles().colors.background, child:Text(_userDebugData)) + ) + ) + ) + ] + ) + ) + ) + ) + ); + }; + } + + void _onTapCopyToClipboard(){ + Clipboard.setData(ClipboardData(text:_userDebugData)).then((_){ + AppToast.show("User data has been copied to the clipboard!"); + }); + } + + void _onTapCovid19Keys() { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19DebugKeysPanel())); + } + + void _onTapCreateCovid19Event() { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19DebugCreateEventPanel())); + } + + void _onTapCovid19PendingEvents() { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19DebugPendingEventsPanel())); + } + + void _onTapTraceCovid19Contact() { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19DebugTraceContactPanel())); + } + + void _onTapReportCovid19Symptoms() { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19DebugSymptomsPanel())); + } + + void _onTapCreateCovid19Action() { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19DebugActionPanel())); + } + + void _onTapCovid19Exposures() { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19DebugExposurePanel())); + } + + void _onTapCovid19ExposureLogs() { + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19DebugExposureLogsPanel())); + } + + String prettyPrintJson(var input){ + if(input == null) + return input; + + JsonEncoder encoder = JsonEncoder.withIndent(' '); + var prettyString = encoder.convert(input); + + return prettyString; + } + + void _onConfigChanged(dynamic env) { + if (env is ConfigEnvironment) { + setState(() { + Config().configEnvironment = env; + _selectedEnv = Config().configEnvironment; + }); + } + } + + // SettingsListenerMixin + + void onDateOffsetChanged() { + setState(() { + _offsetDate = Storage().offsetDate; + }); + } +} diff --git a/lib/ui/settings/SettingsExposureNotificationsPanel.dart b/lib/ui/settings/SettingsExposureNotificationsPanel.dart new file mode 100644 index 00000000..90de780d --- /dev/null +++ b/lib/ui/settings/SettingsExposureNotificationsPanel.dart @@ -0,0 +1,248 @@ + + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; + +class SettingsExposureNotificationsPanel extends StatefulWidget{ + SettingsExposureNotificationsPanel(); + _SettingsExposureNotificationsPanelState createState() => _SettingsExposureNotificationsPanelState(); +} + +class _SettingsExposureNotificationsPanelState extends State implements NotificationsListener{ + + bool _isDisabling = false; + bool _isEnabling = false; + + @override + void initState() { + super.initState(); + NotificationService().subscribe(this, [Health.notifyUserUpdated]); + } + + @override + void dispose() { + super.dispose(); + NotificationService().unsubscribe(this); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + 'Exposure Notificaitons', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: Styles().fontFamilies.extraBold, + letterSpacing: 1.0, + ), + textAlign: TextAlign.center, + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(22), + child: Column( + children: [ + Text('If you opt in to exposure notifications, you allow your phone to send an anonymous Bluetooth signal to nearby Safer Illinois app users who are also using this feature. Your phone will receive and record a signal from their phones as well. If one of those users tests positive for COVID-19 in the next 14 days, the app will alert you to your potential exposure and advise you on next steps.\n\nYour identity and health status will remain anonymous, as will the identity and health status of all other users.', + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + ), + ), + Container(height: 18,), + Stack( + alignment: Alignment.center, + children: [ + ToggleRibbonButton( + label: "I opt in to participate in the Exposure Notification System (requires Bluetooth to be ON)", + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontFamily: Styles().fontFamilies.medium, + fontSize: 14 + ), + height: null, + border: Border.all(width: 1, color: Styles().colors.surfaceAccent), + borderRadius: BorderRadius.all(Radius.circular(4)), + toggled: Health().healthUser.exposureNotification, + onTap: (){ + if(!Health().healthUser.exposureNotification){ + _onConsentEnabled(); + } + else{ + showDialog(context: context, builder: (context) => _buildConsentDialog(context)); + } + }, + ), + _isEnabling ? Align(alignment: Alignment.center, child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary), strokeWidth: 2,)) : Container() + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildConsentDialog(BuildContext context) { + return StatefulBuilder( + builder: (context, setState){ + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)) + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Styles().colors.fillColorPrimary, + borderRadius: BorderRadius.vertical(top: Radius.circular(8)) + ), + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Center( + child: Text( + "Exposure Notifications", + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 24, color: Colors.white), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Styles().colors.white, width: 2), + ), + child: Center( + child: Text( + '\u00D7', + style: TextStyle( + fontSize: 24, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + "By opting out of exposure notifications, your phone will no longer send and recieve anonymous Bluetooth signals to alert you or others of potential exposure to COVID-19.", + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: RoundedButton( + onTap: () { + Analytics.instance.logAlert(text: "Remove My Information", selection: "No"); + Navigator.pop(context); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorPrimary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("panel.profile_info.dialog.remove_my_information.no.title", "No")), + ), + Container( + width: 10, + ), + Expanded( + child: Stack( + alignment: Alignment.center, + children: [ + RoundedButton( + onTap: () => _onConsentDisabled(context, setState), + backgroundColor: Styles().colors.fillColorSecondaryVariant, + borderColor: Styles().colors.fillColorSecondaryVariant, + textColor: Styles().colors.surface, + label: 'Opt-Out'), + _isDisabling ? Align(alignment: Alignment.center, child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorPrimary), strokeWidth: 2,)) : Container() + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + void _onConsentDisabled(BuildContext context, StateSetter _setState){ + if(_isDisabling){ + return; + } + _setState((){ + _isDisabling = true; + }); + Health().loginUser(consent: (Health()?.healthUser?.consent ?? false), exposureNotification: false).whenComplete((){ + _setState((){ + _isDisabling = false; + }); + Navigator.pop(context); + }); + } + + void _onConsentEnabled(){ + if(_isEnabling){ + return; + } + setState((){ + _isEnabling = true; + }); + Health().loginUser(consent: (Health()?.healthUser?.consent ?? false) , exposureNotification: true).whenComplete((){ + setState((){ + _isEnabling = false; + }); + }); + } + + @override + void onNotification(String name, param) { + if(name == Health.notifyUserUpdated){ + setState(() { + + }); + } + } +} \ No newline at end of file diff --git a/lib/ui/settings/SettingsGovernmentIdPanel.dart b/lib/ui/settings/SettingsGovernmentIdPanel.dart new file mode 100644 index 00000000..a0499196 --- /dev/null +++ b/lib/ui/settings/SettingsGovernmentIdPanel.dart @@ -0,0 +1,692 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/UserPiiData.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Exposure.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/NativeCommunicator.dart'; +import 'package:illinois/service/Onboarding.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + +class SettingsGovernmentIdPanel extends StatefulWidget with OnboardingPanel { + + final Map initialData; + + SettingsGovernmentIdPanel({this.initialData}); + + _SettingsGovernmentIdPanelPanelState createState() => _SettingsGovernmentIdPanelPanelState(); + + @override + bool get onboardingCanDisplay { + return (onboardingContext != null) && onboardingContext['shouldDisplayReviewScan'] == true; + } +} + +class _SettingsGovernmentIdPanelPanelState extends State { + + static const String kFirstNameFieldName = 'firstName'; + static const String kMiddleNameFieldName = 'middleName'; + static const String kLastNameFieldName = 'lastName'; + static const String kFullNameFieldName = 'fullName'; + + static const String kBirthYearFieldName = 'birthYear'; + + static const String kAddressFieldName = 'address'; + static const String kStateFieldName = 'state'; + static const String kZipFieldName = 'zip'; + static const String kCountryFieldName = 'country'; + static const String kFullAddressFieldName = 'fullAddress'; + + static const String kFaceImageFieldName = 'faceImage'; + static const String kFaceBase64FieldName = 'faceBase64'; + + Map _scanResult; + bool _processingScanResult; + bool _applyingScanResult; + UserDocumentType _documenType; + Map _scanData; + + String _fullName; + String _birthYear; + MemoryImage _photoImage; + + bool _isDeleting = false; + + @override + void initState() { + super.initState(); + + _processingScanResult = false; + _documenType = Auth()?.userPiiData?.documentType; + _scanData = {}; + + _loadInitialData(); + } + + void _loadInitialData(){ + if(widget.initialData != null){ + _scanData = widget.initialData['scanData']; + _documenType = widget.initialData['userDocumentType']; + _loadScanResult(); + } + else{ + _fullName = Auth()?.userPiiData?.fullName; + _birthYear = Auth()?.userPiiData?.birthYear?.toString() ?? ''; + _loadAsyncPhotoBytes(); + } + + } + + Future _deleteUserData() async{ + Analytics.instance.logAlert(text: "Remove My Information", selection: "Yes"); + + await Health().deleteUser(); + await Exposure().deleteUser(); + await Auth().deleteUserPiiData(); + await User().deleteUser(); + Auth().logout(); + } + + Future _loadAsyncPhotoBytes() async { + Uint8List photoBytes = await Auth()?.userPiiData?.photoBytes; + if(AppCollection.isCollectionNotEmpty(photoBytes)){ + _photoImage = await compute(AppImage.memoryImageWithBytes, photoBytes); + setState(() {}); + } + } + + Future _loadScanResult() async{ + compute(_buildScanResult, _scanData).then((Map scanResult) { + setState(() { + _processingScanResult = false; + _scanResult = scanResult; + }); + }); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + 'Your Government ID', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: Styles().fontFamilies.extraBold, + letterSpacing: 1.0, + ), + textAlign: TextAlign.center, + ), + ), + backgroundColor: Styles().colors.background, + body: SafeArea(child: Column(mainAxisAlignment: MainAxisAlignment.start, children: [ + Expanded(child: + SingleChildScrollView(child: + Column(children: [ + Container(height: 20,), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text('You provided this information to verify your identity during COVID-19 onboarding. You may delete or replace the information below.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + color: Styles().colors.textSurface, + fontSize: 16 + ), + ), + ), + _buildPreviewWidget(), + ],) + ), + ), + Container(height: 12,), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: RoundedButton( + label: 'Re-scan', + hint: '', + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + textColor: Styles().colors.fillColorPrimary, + padding: EdgeInsets.symmetric(horizontal: 22), + onTap: () => _onRescan(), + height: 48, + ), + ), + Container(width: 12,), + Expanded( + child: Stack(children: [ + RoundedButton( + label: "Use This Scan", + hint: '', + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + textColor: Styles().colors.fillColorPrimary, + onTap: () => _onUseScan(), + height: 48, + ), + Visibility(visible: (_applyingScanResult == true), + child: Container(height: 48, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + ],), + ), + ], + ), + ), + Container(height: 16,), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: RoundedButton( + label: 'Delete my COVID-19 Information', + hint: '', + backgroundColor: Styles().colors.surface, + fontSize: 16.0, + textColor: Styles().colors.fillColorSecondary, + borderColor: Styles().colors.surfaceAccent, + onTap: _onRemoveMyInfoClicked, + + ), + ), + Container(height: 16,) + ],),), + ); + } + + Widget _buildPreviewWidget() { + MemoryImage faceImage = (_scanResult != null) ? _scanResult[kFaceImageFieldName] : _photoImage; + + String nameText = ((_scanResult != null) ? _scanResult[kFullNameFieldName] : _fullName) ?? ''; + String nameLabel = nameText.isNotEmpty ? 'Name' : ''; + + String birthYearText = ((_scanResult != null) ? _scanResult[kBirthYearFieldName] : _birthYear) ?? ''; + String birthYearLabel = birthYearText.isNotEmpty ? 'Birth Year' : ''; + + return Padding(padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Container( + decoration: BoxDecoration( + color: Styles().colors.white, + borderRadius: BorderRadius.all(Radius.circular(4)), + boxShadow: [BoxShadow(color: Styles().colors.fillColorPrimaryTransparent015, spreadRadius: 2.0, blurRadius: 6.0, offset: Offset(0, 2))], + ), + child: Stack(children: [ + Visibility(visible: (_processingScanResult != true), child: + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container(width: 55, height: 70, + decoration: BoxDecoration( + color: Styles().colors.fillColorPrimaryTransparent03, + borderRadius: BorderRadius.all(Radius.circular(2)), + image: (faceImage != null) ? DecorationImage(fit: BoxFit.cover, alignment: Alignment.center, image: faceImage) : null, + ), + ), + Expanded( + child: Padding(padding: EdgeInsets.only(left: 16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(nameLabel, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface)), + Text(nameText, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary)), + Container(height: 16,), + Text(birthYearLabel, style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textSurface)), + Text(birthYearText, style: TextStyle(fontFamily: Styles().fontFamilies.extraBold, fontSize: 20, color: Styles().colors.fillColorPrimary)), + ],), + ), + ), + ],), + ), + Visibility(visible: (_processingScanResult == true), + child: Container( + height: 70, + child: Align(alignment: Alignment.center, + child: SizedBox(height: 24, width: 24, + child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), ) + ), + ), + ), + ), + ],) + + + ), + ); + } + + static Map _buildScanResult(Map rawResult) { + + Map rawMrz = rawResult['mrz']; + + String rawFirstName = rawResult['firstName']; // "WILLIAM C III" + if ((rawFirstName == null) && (rawMrz != null)) { + rawFirstName = rawMrz['secondaryID']; // "PETER MARK" + } + String firstName = _buildName(rawFirstName); + String middleName = _buildName(rawFirstName, index: 1); + + String rawLastName = rawResult['lastName']; // "SULLIVAN" + if ((rawLastName == null) && (rawMrz != null)) { + rawLastName = rawMrz['primaryID']; // "HENNESSY" + } + String lastName = _buildName(rawLastName); + + String dateOfBirth = rawResult['dateOfBirth']; // "09/30/1958" + if ((dateOfBirth == null) && (rawMrz != null)) { + dateOfBirth = rawMrz['dateOfBirth']; // "11/22/1960" + } + String birthYear = ((dateOfBirth != null) && RegExp('[0-9]{2}/[0-9]{2}/[0-9]{4}').hasMatch(dateOfBirth)) ? dateOfBirth.substring(6, 10) : null; + + String country; + if (rawMrz != null) { + for (String key in ['sanitizedNationality', 'nationality', 'sanitizedIssuer', 'issuer']) { + String entry = rawMrz[key]; + if ((entry != null) && (0 < entry.length)) { + country = entry; + break; + } + } + } + + String rawAddress = rawResult['address']; // "1804 PLEASANT ST, URBANA, IL, 618010000" + String address = rawAddress, state, zip; + if (rawAddress != null) { + List addressComponents = rawAddress.split(','); + int componentsCount = addressComponents.length; + if ((addressComponents != null) && (1 < componentsCount)) { + + String aZip = addressComponents[componentsCount - 1].trim(); + bool hasZip = RegExp('[0-9]{5,}').hasMatch(aZip); + + String aState = addressComponents[componentsCount - 2].trim(); + bool hasState = RegExp('[a-zA-Z]{2,}').hasMatch(aState); + + if (hasZip && hasState) { + zip = aZip.substring(0, 5); + state = aState; + if (country == null) { + country = 'USA'; + } + + address = ''; + for (int index = 0; (index + 2) < componentsCount; index++) { + if (0 < index) { + address += ','; + } + address += addressComponents[index]; + } + } + } + } + + String fullName = ''; + if ((firstName != null) && (0 < firstName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$firstName"; + } + if ((middleName != null) && (0 < middleName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$middleName"; + } + if ((lastName != null) && (0 < lastName.length)) { + fullName += "${(0 < fullName.length) ? ' ' : ''}$lastName"; + } + + String fullAddress = address ?? ''; + if ((state != null) && (zip != null)) { + fullAddress += "${(0 < fullAddress.length) ? ', ' : ''}$state $zip"; + } + else if (state != null) { + fullAddress += "${(0 < fullAddress.length) ? ', ' : ''}$state"; + } + else if (zip != null) { + fullAddress += "${(0 < fullAddress.length) ? ', ' : ''}$zip"; + } + if (country != null) { + fullAddress += "${(0 < fullAddress.length) ? ', ' : ''}$country"; + } + + String base64FaceImage = rawResult['base64FaceImage']; + Uint8List faceImageData = (base64FaceImage != null) ? base64Decode(base64FaceImage) : null; + MemoryImage faceImage = (faceImageData != null) ? MemoryImage(faceImageData) : null; + + return { + // These should go to PII + kFirstNameFieldName : firstName, + kMiddleNameFieldName : middleName, + kLastNameFieldName : lastName, + kBirthYearFieldName : birthYear, + kAddressFieldName : address, + kStateFieldName : state, + kZipFieldName : zip, + kCountryFieldName : country, + kFaceBase64FieldName : base64FaceImage, + + // These are for display purpose only + kFullNameFieldName : fullName, + kFullAddressFieldName : fullAddress, + kFaceImageFieldName : faceImage, + }; + } + + static String _buildName(String rawName, {int index = 0}) { + String resultName; + if (rawName != null) { + List firstNameComponents = rawName.split(' '); + if ((firstNameComponents != null) && (0 <= index) && (index < firstNameComponents.length)) { + resultName = firstNameComponents[index]; + } + else if (index == 0) { + resultName = rawName; + } + resultName = (resultName != null) ? AppString.capitalize(resultName) : null; + } + return resultName; + } + + Widget _buildRemoveMyInfoDialog(BuildContext context) { + return StatefulBuilder( + builder: (context, setState){ + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + color: Styles().colors.fillColorPrimary, + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Center( + child: Text( + "Remove My Info", + style: TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Styles().colors.white, width: 2), + ), + child: Center( + child: Text( + '\u00D7', + style: TextStyle( + fontSize: 24, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + "By answering YES all your personal information and preferences will be deleted from our systems. This action can not be recovered. After deleting the information we will return you to the first screen when you installed the app so you can start again or delete the app.", + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Container( + height: 26, + ), + Text( + "Are you sure?", + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Colors.black), + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + RoundedButton( + onTap: () => _onConfirmRemoveMyInfo(context, setState), + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("panel.profile_info.dialog.remove_my_information.yes.title", "Yes")), + _isDeleting ? Align(alignment: Alignment.center, child: CircularProgressIndicator()) : Container() + ], + ), + Container( + height: 10, + ), + RoundedButton( + onTap: () { + Analytics.instance.logAlert(text: "Remove My Information", selection: "No"); + Navigator.pop(context); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("panel.profile_info.dialog.remove_my_information.no.title", "No")) + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Future _applyScan() async { + + UserPiiData updatedUserPiiData; + UserPiiData userPiiData = UserPiiData.fromObject(await Auth().reloadUserPiiData()); + if (userPiiData != null) { + _applyScanResult(userPiiData); + updatedUserPiiData = await Auth().storeUserPiiData(userPiiData); + } + + return (updatedUserPiiData != null); + } + + void _applyScanResult(UserPiiData userPiiData) { + + String photoBase64 = _scanResult[kFaceBase64FieldName]; + if (photoBase64 != null) { + userPiiData.photoBase64 = photoBase64; + } + + String firstName = _scanResult[kFirstNameFieldName]; + if (firstName != null) { + userPiiData.firstName = firstName; + } + + String middleName = _scanResult[kMiddleNameFieldName]; + if (middleName != null) { + userPiiData.middleName = middleName; + } + + String lastName = _scanResult[kLastNameFieldName]; + if (lastName != null) { + userPiiData.lastName = lastName; + } + + String birthYearString = _scanResult[kBirthYearFieldName]; + int birthYear = ((birthYearString != null) && (0 < birthYearString.length)) ? int.tryParse(birthYearString) : null; + if (birthYear != null) { + userPiiData.birthYear = birthYear; + } + + //Don't store this data in PiiData for now +/* String address = _scanResult[kAddressFieldName]; + if ((address != null) && (0 < address.length)) { + userPiiData.address = address; + } + + String state = _scanResult[kStateFieldName]; + if ((state != null) && (0 < state.length)) { + userPiiData.state = state; + } + + String zip = _scanResult[kZipFieldName]; + if ((zip != null) && (0 < zip.length)) { + userPiiData.zip = zip; + } + + String country = _scanResult[kCountryFieldName]; + if ((country != null) && (0 < country.length)) { + userPiiData.country = country; + }*/ + + if (_documenType != null) { + userPiiData.documentType = _documenType; + } + } + + + + void _onRescan() { + Analytics.instance.logSelect(target: 'Re-scan') ; + + String analyticsScanType; + List recognizers; + if (_documenType == UserDocumentType.drivingLicense) { + analyticsScanType = Analytics.LogDocumentScanDrivingLicenseType; + recognizers = ['combined']; + } + else if (_documenType == UserDocumentType.passport) { + analyticsScanType = Analytics.LogDocumentScanPassportType; + recognizers = ['passport']; + } + + NativeCommunicator().microBlinkScan(recognizers: recognizers).then((dynamic result) { + Analytics().logDocumentScan(type: analyticsScanType, result: (result != null)); + if (result != null) { + _didRescan(result); + } + }); + } + + void _didRescan(Map scanData) { + setState(() { + _processingScanResult = true; + }); + compute(_buildScanResult, scanData).then((Map scanResult) { + setState(() { + _processingScanResult = false; + _scanResult = scanResult; + }); + }); + + } + + void _onUseScan() { + Analytics.instance.logSelect(target: 'Use This Scan') ; + + if (_scanResult == null) { + Navigator.pop(context); + } + + setState(() { + _applyingScanResult = true; + }); + + _applyScan().then((bool result){ + + setState(() { + _applyingScanResult = false; + }); + + if (result) { + Navigator.pop(context); + } + else { + AppAlert.showDialogResult(context, 'Failed to apply scanned data'); + } + }); + } + + void _onConfirmRemoveMyInfo(BuildContext context, Function setState){ + setState(() { + _isDeleting = true; + }); + _deleteUserData() + .then((_){ + Navigator.pop(context); + }) + .whenComplete((){ + setState(() { + _isDeleting = false; + }); + }) + .catchError((error){ + AppAlert.showDialogResult(context, error.toString()).then((_){ + Navigator.pop(context); + }); + }); + + + } + + void _onRemoveMyInfoClicked() { + showDialog(context: context, builder: (context) => _buildRemoveMyInfoDialog(context)); + } +} \ No newline at end of file diff --git a/lib/ui/settings/SettingsHomePanel.dart b/lib/ui/settings/SettingsHomePanel.dart new file mode 100644 index 00000000..54b33a8b --- /dev/null +++ b/lib/ui/settings/SettingsHomePanel.dart @@ -0,0 +1,1200 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/AppNavigation.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Connectivity.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/FlexUI.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/ui/WebPanel.dart'; +import 'package:illinois/ui/health/Covid19QrCodePanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingLoginPhoneVerifyPanel.dart'; +import 'package:illinois/ui/settings/SettingsRolesPanel.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; +import 'package:illinois/utils/Covid19.dart'; +import 'package:illinois/utils/Crypt.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:barcode_scan/barcode_scan.dart'; +import 'package:package_info/package_info.dart'; +import 'package:pointycastle/export.dart' as PointyCastle; + +import 'SettingsDebugPanel.dart'; +import 'SettingsPersonalInfoPanel.dart'; + +class SettingsHomePanel extends StatefulWidget { + @override + _SettingsHomePanelState createState() => _SettingsHomePanelState(); +} + +class _SettingsHomePanelState extends State implements NotificationsListener { + + static BorderRadius _bottomRounding = BorderRadius.only(bottomLeft: Radius.circular(5), bottomRight: Radius.circular(5)); + static BorderRadius _topRounding = BorderRadius.only(topLeft: Radius.circular(5), topRight: Radius.circular(5)); + static BorderRadius _allRounding = BorderRadius.all(Radius.circular(5)); + + String _versionName = ""; + // Covid19 + HealthUser _healthUser; + bool _loadingHealthUser; + + PointyCastle.PrivateKey _healthUserPrivateKey; + bool _loadingHealthUserPrivateKey; + + bool _healthUserKeysPaired; + bool _checkingHealthUserKeysPaired; + + bool _refreshingHealthUserKeys; + + @override + void initState() { + NotificationService().subscribe(this, [ + Auth.notifyUserPiiDataChanged, + User.notifyUserUpdated, + FirebaseMessaging.notifySettingUpdated, + FlexUI.notifyChanged, + ]); + _loadVersionInfo(); + + //TBD move to Health service + _initHealthUserData(); + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + super.dispose(); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == Auth.notifyUserPiiDataChanged) { + _updateState(); + } else if (name == User.notifyUserUpdated){ + _updateState(); + } else if (name == FirebaseMessaging.notifySettingUpdated) { + _updateState(); + } else if (name == FlexUI.notifyChanged) { + _updateState(); + } + } + + @override + Widget build(BuildContext context) { + + List contentList = []; + + List codes = FlexUI()['settings'] ?? []; + + for (String code in codes) { + if (code == 'user_info') { + contentList.add(_buildUserInfo()); + } + else if (code == 'connect') { + contentList.add(_buildConnect()); + } + else if (code == 'customizations') { + contentList.add(_buildCustomizations()); + } + else if (code == 'connected') { + contentList.add(_buildConnected()); + } + else if (code == 'notifications') { + contentList.add(_buildNotifications()); + } + else if (code == 'covid19') { + contentList.add(_buildCovid19Settings()); + } + else if (code == 'privacy') { + contentList.add(_buildPrivacy()); + } + else if (code == 'account') { + contentList.add(_buildAccount()); + } + else if (code == 'feedback') { + contentList.add(_buildFeedback(),); + } + } + + if (!kReleaseMode || (Config().configEnvironment == ConfigEnvironment.dev)) { + contentList.add(_buildDebug()); + } + + contentList.add(_buildVersionInfo()); + + contentList.add(Container(height: 12,),); + + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: _DebugContainer( + child: Container( + height: 40, + child: Padding( + //PS I know it is ugly.. + padding: EdgeInsets.only(top: 10), + child: Text( + Localization().getStringEx("panel.settings.home.settings.header", "Settings"), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 1.0, + ), + textAlign: TextAlign.center, + ), + ), + )), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: SafeArea( + child: Container( + color: Styles().colors.background, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: contentList, + ), + ), + ), + ), + ), + ], + ), + backgroundColor: Styles().colors.background, + ); + } + + // User Info + + Widget _buildUserInfo() { + String fullName = Auth()?.userPiiData?.fullName ?? ""; + bool hasFullName = AppString.isStringNotEmpty(fullName); + String welcomeMessage = AppString.isStringNotEmpty(fullName) + ? AppDateTime().getDayGreeting() + "," + : Localization().getStringEx("panel.settings.home.user_info.title.sufix", "Welcome to Illinois"); + return + Semantics( container: true, + child: Container( + width: double.infinity, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(welcomeMessage, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20)), + Visibility( + visible: hasFullName, + child: Text(fullName, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 28)) + ), + ])))); + } + + + // Connect + + Widget _buildConnect() { + List contentList = new List(); + contentList.add(Padding( + padding: EdgeInsets.only(left: 8, right: 8, top: 12, bottom: 2), + child: Text( + Localization().getStringEx("panel.settings.home.connect.not_logged_in.title", "Connect to Illinois"), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20), + ), + ), + ); + + List codes = FlexUI()['settings.connect'] ?? []; + for (String code in codes) { + if (code == 'netid') { + contentList.add(Padding( + padding: EdgeInsets.all(10), + child: new RichText( + textScaleFactor: MediaQuery.textScaleFactorOf(context), + text: new TextSpan( + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + children: [ + new TextSpan(text: Localization().getStringEx("panel.settings.home.connect.not_logged_in.netid.description.part_1", "Are you a ")), + new TextSpan( + text: Localization().getStringEx("panel.settings.home.connect.not_logged_in.netid.description.part_2", "student"), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontFamily: Styles().fontFamilies.bold)), + new TextSpan(text: Localization().getStringEx("panel.settings.home.connect.not_logged_in.netid.description.part_3", " or ")), + new TextSpan( + text: Localization().getStringEx("panel.settings.home.connect.not_logged_in.netid.description.part_4", "faculty member"), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontFamily: Styles().fontFamilies.bold)), + new TextSpan( + text: Localization().getStringEx("panel.settings.home.connect.not_logged_in.netid.description.part_5", + "? Log in with your NetID to see Illinois information specific to you, like your Illini Cash and meal plan.")) + ], + ), + )),); + contentList.add(RibbonButton( + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + borderRadius: _allRounding, + label: Localization().getStringEx("panel.settings.home.connect.not_logged_in.netid.title", "Connect your NetID"), + onTap: _onConnectNetIdClicked),); + } + else if (code == 'phone') { + contentList.add(Padding( + padding: EdgeInsets.all(10), + child: new RichText( + textScaleFactor: MediaQuery.textScaleFactorOf(context), + text: new TextSpan( + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + children: [ + new TextSpan( + text: Localization().getStringEx("panel.settings.home.connect.not_logged_in.phone.description.part_1", "Don't have a NetID"), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontFamily: Styles().fontFamilies.bold)), + new TextSpan( + text: Localization().getStringEx("panel.settings.home.connect.not_logged_in.phone.description.part_2", + "? Verify your phone number to save your preferences and have the same experience on more than one device.")), + ], + ), + )),); + contentList.add(RibbonButton( + borderRadius: _allRounding, + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: Localization().getStringEx("panel.settings.home.connect.not_logged_in.phone.title", "Verify Your Phone Number"), + onTap: _onPhoneVerClicked),); + } + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: contentList), + ); + } + + void _onConnectNetIdClicked() { + Analytics.instance.logSelect(target: "Connect netId"); + Auth().authenticateWithShibboleth(); + } + + void _onPhoneVerClicked() { + Analytics.instance.logSelect(target: "Phone Verification"); + if (Connectivity().isNotOffline) { + Navigator.push(context, CupertinoPageRoute(settings: RouteSettings(), builder: (context) => OnboardingLoginPhoneVerifyPanel(onFinish: _didPhoneVer,))); + } else { + AppAlert.showOfflineMessage(context, Localization().getStringEx('panel.settings.label.offline.phone_ver', 'Verify Your Phone Number is not available while offline.')); + } + } + + void _didPhoneVer(_) { + Navigator.of(context)?.popUntil((Route route){ + return AppNavigation.routeRootWidget(route, context: context)?.runtimeType == widget.runtimeType; + }); + } + + // Customizations + + Widget _buildCustomizations() { + List customizationOptions = new List(); + List codes = FlexUI()['settings.customizations'] ?? []; + for (int index = 0; index < codes.length; index++) { + String code = codes[index]; + + BorderRadius borderRadius = _borderRadiusFromIndex(index, codes.length); + + if (code == 'roles') { + customizationOptions.add(RibbonButton( + borderRadius: borderRadius, + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: Localization().getStringEx("panel.settings.home.customizations.role.title", "Who you are"), + onTap: _onWhoAreYouClicked)); + } + } + + return _OptionsSection( + title: Localization().getStringEx("panel.settings.home.customizations.title", "Customizations"), + widgets: customizationOptions,); + + } + + void _onWhoAreYouClicked() { + Analytics.instance.logSelect(target: "Who are you"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsRolesPanel())); + } + + // Connected + + Widget _buildConnected() { + List contentList = new List(); + + List codes = FlexUI()['settings.connected'] ?? []; + for (String code in codes) { + if (code == 'netid') { + contentList.add(_OptionsSection( + title: Localization().getStringEx("panel.settings.home.net_id.title", "Illinois NetID"), + widgets: _buildConnectedNetIdLayout())); + } + else if (code == 'phone') { + contentList.add(_OptionsSection( + title: Localization().getStringEx("panel.settings.home.phone_ver.title", "Phone Verification"), + widgets: _buildConnectedPhoneLayout())); + } + } + return Column(children: contentList,); + + } + + List _buildConnectedNetIdLayout() { + List contentList = List(); + + List codes = FlexUI()['settings.connected.netid'] ?? []; + for (int index = 0; index < codes.length; index++) { + String code = codes[index]; + BorderRadius borderRadius = _borderRadiusFromIndex(index, codes.length); + if (code == 'info') { + contentList.add( + Semantics( container: true, + child: Container( + width: double.infinity, + decoration: BoxDecoration(borderRadius: borderRadius, border: Border.all(color: Styles().colors.surfaceAccent, width: 0.5)), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(Localization().getStringEx("panel.settings.home.net_id.message", "Connected as "), + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16)), + Text(Auth().userPiiData?.fullName ?? "", + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20)), + ]))))); + } + else if (code == 'connect') { + contentList.add(RibbonButton( + borderRadius: borderRadius, + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: Localization().getStringEx("panel.settings.home.net_id.button.connect", "Connect your NetID"), + onTap: _onConnectNetIdClicked)); + } + else if (code == 'disconnect') { + contentList.add(RibbonButton( + borderRadius: borderRadius, + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: Localization().getStringEx("panel.settings.home.net_id.button.disconnect", "Disconnect your NetID"), + onTap: _onDisconnectNetIdClicked)); + } + } + + return contentList; + } + + List _buildConnectedPhoneLayout() { + List contentList = List(); + + String fullName = Auth()?.userPiiData?.fullName ?? ""; + bool hasFullName = AppString.isStringNotEmpty(fullName); + + List codes = FlexUI()['settings.connected.phone'] ?? []; + for (int index = 0; index < codes.length; index++) { + String code = codes[index]; + BorderRadius borderRadius = _borderRadiusFromIndex(index, codes.length); + if (code == 'info') { + contentList.add(Container( + width: double.infinity, + decoration: BoxDecoration(borderRadius: borderRadius, border: Border.all(color: Styles().colors.surfaceAccent, width: 0.5)), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(Localization().getStringEx("panel.settings.home.phone_ver.message", "Verified as "), + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16)), + Visibility(visible: hasFullName, child: Text(fullName ?? "", style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20)),), + Text(Auth().phoneToken?.phone ?? "", style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20)), + ])))); + } + else if (code == 'verify') { + contentList.add(RibbonButton( + borderRadius: borderRadius, + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: Localization().getStringEx("panel.settings.home.phone_ver.button.connect", "Verify Your Phone Number"), + onTap: _onPhoneVerClicked)); + } + else if (code == 'disconnect') { + contentList.add(RibbonButton( + borderRadius: borderRadius, + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: Localization().getStringEx("panel.settings.home.phone_ver.button.disconnect","Disconnect your Phone",), + onTap: _onDisconnectNetIdClicked)); + } + } + return contentList; + } + + void _onDisconnectNetIdClicked() { + if(Auth().isShibbolethLoggedIn) { + Analytics.instance.logSelect(target: "Disconnect netId"); + } else { + Analytics.instance.logSelect(target: "Disconnect phone"); + } + showDialog(context: context, builder: (context) => _buildLogoutDialog(context)); + } + + Widget _buildLogoutDialog(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx("app.title", "Safer Illinois"), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + Localization().getStringEx("panel.settings.home.logout.message", "Are you sure you want to sign out?"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Sign out", selection: "Yes"); + Navigator.pop(context); + Auth().logout(); + }, + child: Text(Localization().getStringEx("panel.settings.home.logout.button.yes", "Yes"))), + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Sign out", selection: "No"); + Navigator.pop(context); + }, + child: Text(Localization().getStringEx("panel.settings.home.logout.no", "No"))) + ], + ), + ], + ), + ), + ); + } + + // NotificationsOptions + + Widget _buildNotifications() { + List contentList = new List(); + + List codes = FlexUI()['settings.notifications'] ?? []; + for (int index = 0; index < codes.length; index++) { + String code = codes[index]; + BorderRadius borderRadius = _borderRadiusFromIndex(index, codes.length); + if (code == 'covid19') { + contentList.add(ToggleRibbonButton( + borderRadius: borderRadius, + label: Localization().getStringEx("panel.settings.home.notifications.covid19", "COVID-19 notifications"), + toggled: FirebaseMessaging().notifyCovid19, + context: context, + onTap: _onCovid19Toggled)); + } + } + + return _OptionsSection( + title: Localization().getStringEx("panel.settings.home.notifications.title", "Notifications"), + widgets: contentList); + } + + void _onCovid19Toggled() { + Analytics.instance.logSelect(target: "COVID-19 notifications"); + FirebaseMessaging().notifyCovid19 = !FirebaseMessaging().notifyCovid19; + } + + //TBD move to Health service + void _initHealthUserData(){ + if(Auth().isLoggedIn) { + _loadHealthUser(); + _loadHealthRSAPrivateKey(); + } + } + + void _loadHealthUser() { + setState(() { + _loadingHealthUser = true; + }); + Health().loginUser().then((HealthUser user) { + if (mounted) { + if (user != null) { + setState(() { + _healthUser = user; + _loadingHealthUser = false; + }); + _verifyHealthRSAKeys(); + } + else { + setState(() { + _loadingHealthUser = false; + }); + } + } + }); + } + + void _updateHealthUser({bool consent, bool exposureNotification}){ + setState(() { + _loadingHealthUser = true; + }); + Health().loginUser(consent: consent, exposureNotification: exposureNotification).then((user) { + if (mounted) { + if (user != null) { + setState(() { + _healthUser = user; + _loadingHealthUser = false; + }); + } + else { + setState(() { + _loadingHealthUser = false; + }); + AppToast.show("Unable to login in Health"); + } + } + }); + } + + void _loadHealthRSAPrivateKey() { + setState(() { + _loadingHealthUserPrivateKey = true; + }); + Health().loadRSAPrivateKey().then((privateKey) { + if (mounted) { + _healthUserPrivateKey = privateKey; + _verifyHealthRSAKeys(); + setState(() { + _loadingHealthUserPrivateKey = false; + }); + } + }); + } + + void _verifyHealthRSAKeys() { + if ((_healthUserPrivateKey != null) && (_healthUser?.publicKey != null)) { + setState(() { + _checkingHealthUserKeysPaired = true; + }); + RsaKeyHelper.verifyRsaKeyPair(PointyCastle.AsymmetricKeyPair(_healthUser?.publicKey, _healthUserPrivateKey)).then((bool result) { + if (mounted) { + setState(() { + _healthUserKeysPaired = result; + _checkingHealthUserKeysPaired = false; + }); + } + }); + } + } + + void _refreshHealthRSAKeys() { + setState(() { + _refreshingHealthUserKeys = true; + }); + Health().refreshRSAKeys().then((keyPair) { + if (mounted) { + if (keyPair != null) { + setState(() { + _healthUser = Health().healthUser; + _healthUserPrivateKey = keyPair.privateKey; + _refreshingHealthUserKeys = false; + }); + _verifyHealthRSAKeys(); + } + else { + setState(() { + _refreshingHealthUserKeys = false; + }); + AppAlert.showDialogResult(context, Localization().getStringEx('panel.settings.home.covid19.alert.reset.failed', 'Failed to reset the COVID-19 Secret QRcode')); + } + } + }); + + } + + + + Widget _buildCovid19Settings() { + List contentList = new List(); + + if (_loadingHealthUser == true) { + contentList.add(Container( + padding: EdgeInsets.all(16), + child: Center(child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ,), + )); + } + else if (_healthUser == null) { + contentList.add(Container( + padding: EdgeInsets.only(left: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(Localization().getStringEx('panel.settings.home.covid19.text.user.fail', 'Unable to retrieve user COVID-19 settings.') , style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16)), + Container(height: 4,), + Row(children: [ + RoundedButton( + label: Localization().getStringEx('panel.settings.home.covid19.button.retry.title', 'Retry'), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + padding: EdgeInsets.symmetric(horizontal: 24), + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapCovid19Login + ), + ],) + ],) + )); + } + else { + List codes = FlexUI()['settings.covid19'] ?? []; + for (int index = 0; index < codes.length; index++) { + String code = codes[index]; + BorderRadius borderRadius = _borderRadiusFromIndex(index, codes.length); + if (code == 'exposure_notifications') { + contentList.add(ToggleRibbonButton( + borderRadius: borderRadius, + label: Localization().getStringEx("panel.settings.home.covid19.exposure_notifications", "Exposure Notifications"), + toggled: (_healthUser?.exposureNotification == true), + context: context, + onTap: _onExposureNotifications)); + } + else if (code == 'provider_test_result') { + contentList.add(ToggleRibbonButton( + borderRadius: borderRadius, + label: Localization().getStringEx("panel.settings.home.covid19.provider_test_result", "Health Provider Test Results"), + toggled: (_healthUser?.consent == true), + context: context, + onTap: _onProviderTestResult)); + } + else if (code == 'qr_code') { + contentList.add(Padding(padding: EdgeInsets.only(left: 8, top: 16), child: _buildCovid19KeysSection(),)); + } + } + } + + return _OptionsSection( + title: Localization().getStringEx("panel.settings.home.covid19.title", "COVID-19"), + widgets: contentList); + } + + Widget _buildCovid19KeysSection() { + if ((_loadingHealthUserPrivateKey == true) || (_checkingHealthUserKeysPaired == true)) { + return Text(Localization().getStringEx('panel.settings.home.covid19.text.keys.checking', 'Checking COVID-19 keys...'), style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16),); + } + else { + String statusText, descriptionText; + List buttons; + if ((_healthUser?.publicKey == null)) { + statusText = Localization().getStringEx('panel.settings.home.covid19.text.keys.missing.public', 'Missing COVID-19 public key'); + descriptionText = Localization().getStringEx('panel.settings.home.covid19.text.keys.reset', 'Reset the COVID-19 keys pair.'); + buttons = [ + Expanded(child: Container(),), + _buildCovid19ResetButton(), + ]; + } + else if ((_healthUserPrivateKey == null) || (_healthUserKeysPaired != true)) { + statusText = (_healthUserPrivateKey == null) ? + Localization().getStringEx('panel.settings.home.covid19.text.keys.missing.private', 'Missing COVID-19 private key') : + Localization().getStringEx('panel.settings.home.covid19.text.keys.mismatch', 'COVID-19 keys not paired'); + descriptionText = Localization().getStringEx('panel.settings.home.covid19.text.keys.transfer_or_reset', 'Transfer the COVID-19 private key from your other phone or reset the COVID-19 keys pair.'); + buttons = [ + RoundedButton( + label: Localization().getStringEx('panel.settings.home.covid19.button.load.title', 'Load'), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + padding: EdgeInsets.symmetric(horizontal: 24), + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapLoadCovid19QrCode), + Container(width: 8,), + RoundedButton( + label: Localization().getStringEx('panel.settings.home.covid19.button.scan.title', 'Scan'), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + padding: EdgeInsets.symmetric(horizontal: 24), + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapScanCovid19QrCode), + Expanded(child: Container(),), + _buildCovid19ResetButton(), + ]; + } + else { + statusText = Localization().getStringEx('panel.settings.home.covid19.text.keys.paired', 'COVID-19 keys valid and paired'); + descriptionText = Localization().getStringEx('panel.settings.home.covid19.text.keys.qr_code', 'Show your COVID-19 secret QR code.'); + buttons = [ + Expanded(child: Container(),), + RoundedButton( + label: Localization().getStringEx('panel.settings.home.covid19.button.qr_code.title', 'QR Code'), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + padding: EdgeInsets.symmetric(horizontal: 24), + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapShowCovid19QrCode) + ]; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(statusText, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold)), + Container(height: 4,), + Text(descriptionText, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.regular)), + Container(height: 8,), + Row(children: buttons) + ], + ); + } + } + + Widget _buildCovid19ResetButton() { + double buttonWidth = 100, buttonHeight = 48, progressSize = 24; + return Stack(children: [ + RoundedButton( + label: Localization().getStringEx('panel.settings.home.covid19.button.reset.title', 'Reset'), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + width: buttonWidth, + height: buttonHeight, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorPrimary, + onTap: _onTapCovid19ResetKeys, + ), + Visibility(visible: (_refreshingHealthUserKeys == true), child: + Padding(padding: EdgeInsets.only(top: (buttonHeight - progressSize) / 2, left: (buttonWidth - progressSize) / 2), child: + Container(width: progressSize, height: progressSize, child: + CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,) + ), + ), + ), + ],); + } + + + void _onExposureNotifications() { + Analytics.instance.logSelect(target: "Exposure Notifications"); + bool exposureNotification = _healthUser?.exposureNotification ?? false; + _updateHealthUser(exposureNotification: !exposureNotification); + } + + void _onProviderTestResult() { + Analytics.instance.logSelect(target: "Health Provider Test Results"); + bool consent = _healthUser?.consent ?? false; + _updateHealthUser(consent: !consent); + } + + void _onTapCovid19Login() { + Analytics.instance.logSelect(target: "Retry"); + _loadHealthUser(); + } + + void _onTapCovid19ResetKeys() { + Analytics.instance.logSelect(target: "Reset"); + String message = Localization().getStringEx('panel.settings.home.covid19.alert.reset.prompt', 'Doing this will provide you a new COVID-19 Secret QRcode but your previous COVID-19 event history will be lost, continue?'); + if (_refreshingHealthUserKeys != true) { + showDialog( + context: context, + builder: (BuildContext buildContext) { + return AlertDialog( + content: Text(message, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold)), + actions: [ + FlatButton( + child: Text(Localization().getStringEx("dialog.yes.title", "Yes"), style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold)), + onPressed: () { + Analytics.instance.logAlert(text: message, selection: "Yes"); + Navigator.pop(buildContext, true); + } + ), + FlatButton( + child: Text(Localization().getStringEx("dialog.no.title", "No"), style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold)), + onPressed: () { + Analytics.instance.logAlert(text: message, selection: "No"); + Navigator.pop(buildContext, false); + } + ), + ], + ); + } + ).then((result) { + if (result == true) { + _refreshHealthRSAKeys(); + } + }); + } + } + + void _onTapShowCovid19QrCode() { + Analytics.instance.logSelect(target: "Show COVID-19 Secret QRcode"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19QrCodePanel())); + } + + void _onTapScanCovid19QrCode() { + Analytics.instance.logSelect(target: "Scan COVID-19 Secret QRcode"); + BarcodeScanner.scan().then((result) { + // barcode_scan plugin returns 8 digits when it cannot read the qr code. Prevent it from storing such values + if (AppString.isStringEmpty(result) || (result.length <= 8)) { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.settings.home.covid19.alert.qr_code.scan.failed.msg', 'Failed to read QR code.')); + } + else { + _onCovid19QrCodeScanSucceeded(result); + } + }); + } + + void _onTapLoadCovid19QrCode() { + Analytics.instance.logSelect(target: "Load COVID-19 Secret QRcode"); + Covid19Utils.loadQRCodeImageFromPictures().then((String qrCodeString) { + _onCovid19QrCodeScanSucceeded(qrCodeString); + }); + } + + void _onCovid19QrCodeScanSucceeded(String result) { + + PointyCastle.PrivateKey privateKey; + try { + Uint8List pemCompressedData = (result != null) ? base64.decode(result) : null; + List pemData = (pemCompressedData != null) ? GZipDecoder().decodeBytes(pemCompressedData) : null; + privateKey = (pemData != null) ? RsaKeyHelper.parsePrivateKeyFromPemData(pemData) : null; + } + catch (e) { + print(e?.toString()); + } + + if (privateKey != null) { + RsaKeyHelper.verifyRsaKeyPair(PointyCastle.AsymmetricKeyPair(_healthUser?.publicKey, privateKey)).then((bool result) { + if (mounted) { + if (result == true) { + Health().setUserRSAPrivateKey(privateKey).then((success) { + if (mounted) { + String resultMessage = success ? Localization().getStringEx( + 'panel.settings.home.covid19.alert.qr_code.transfer.succeeded.msg', 'COVID-19 secret transferred successfully.') : Localization() + .getStringEx('panel.settings.home.covid19.alert.qr_code.transfer.failed.msg', 'Failed to transfer COVID-19 secret.'); + AppAlert.showDialogResult(context, resultMessage).then((_){ + if (success) { + setState(() { + _healthUserPrivateKey = privateKey; + _healthUserKeysPaired = true; + }); + } + }); + } + }); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.health.covid19.alert.qr_code.not_match.msg', 'COVID-19 secret key does not match existing public RSA key.')); + } + } + }); + } + else { + AppAlert.showDialogResult(context, Localization().getStringEx('panel.health.covid19.alert.qr_code.invalid.msg', 'Invalid QR code.')); + } + + } + + // Privacy + + Widget _buildPrivacy() { + List contentList = new List(); + + List codes = FlexUI()['settings.privacy'] ?? []; + for (int index = 0; index < codes.length; index++) { + String code = codes[index]; + BorderRadius borderRadius = _borderRadiusFromIndex(index, codes.length); + if (code == 'statement') { + contentList.add(RibbonButton( + borderRadius: borderRadius, + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: Localization().getStringEx("panel.settings.home.privacy.privacy_statement.title", "Privacy Statement"), + onTap: _onPrivacyStatementClicked, + )); + } + } + + return _OptionsSection( + title: Localization().getStringEx("panel.settings.home.privacy.title", "Privacy"), + widgets: contentList); + } + + void _onPrivacyStatementClicked() { + Analytics.instance.logSelect(target: "Privacy Statement"); + if (Config().privacyPolicyUrl != null) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => WebPanel(url: Config().privacyPolicyUrl, title: Localization().getStringEx("panel.settings.privacy_statement.label.title", "Privacy Statement"),))); + } + } + + // Account + + Widget _buildAccount() { + List contentList = new List(); + + List codes = FlexUI()['settings.account'] ?? []; + for (int index = 0; index < codes.length; index++) { + String code = codes[index]; + BorderRadius borderRadius = _borderRadiusFromIndex(index, codes.length); + if (code == 'personal_info') { + contentList.add(RibbonButton( + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + borderRadius: borderRadius, + label: Localization().getStringEx("panel.settings.home.account.personal_info.title", "Personal Info"), + onTap: _onPersonalInfoClicked)); + } + } + + return _OptionsSection( + title: Localization().getStringEx("panel.settings.home.account.title", "Your Account"), + widgets: contentList, + ); + } + + void _onPersonalInfoClicked() { + Analytics.instance.logSelect(target: "Personal Info"); + if (Auth().isLoggedIn) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsPersonalInfoPanel())); + } + } + + // Feedback + + Widget _buildFeedback(){ + return Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + Localization().getStringEx("panel.settings.home.feedback.title", "We need your ideas!"), + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20), + ), + Container(height: 5,), + Text( + Localization().getStringEx("panel.settings.home.feedback.description", "Enjoying the app? Missing something? Tap on the bottom to submit your idea."), + style: TextStyle(fontFamily: Styles().fontFamilies.regular,color: Styles().colors.textBackground, fontSize: 16), + ), + ]) + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 18, vertical: 12), + child: RoundedButton( + label: Localization().getStringEx("panel.settings.home.button.feedback.title", "Submit Feedback"), + hint: Localization().getStringEx("panel.settings.home.button.feedback.hint", ""), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + onTap: _onFeedbackClicked, + ), + ), + ], + ); + } + + String _constructFeedbackParams(String email, String phone, String name) { + Map params = Map(); + params['email'] = Uri.encodeComponent(email != null ? email : ""); + params['phone'] = Uri.encodeComponent(phone != null ? phone : ""); + params['name'] = Uri.encodeComponent(name != null ? name : ""); + + String result = ""; + if (params.length > 0) { + result += "?"; + params.forEach((key, value) => + result+= key + "=" + value + "&" + ); + result = result.substring(0, result.length - 1); //remove the last symbol & + } + return result; + } + + void _onFeedbackClicked() { + Analytics.instance.logSelect(target: "Provide Feedback"); + + if (Connectivity().isNotOffline && (Config().feedbackUrl != null)) { + String email = Auth().userPiiData?.email; + String name = Auth().userPiiData?.fullName; + String phone = Auth().phoneToken?.phone; + String params = _constructFeedbackParams(email, phone, name); + String feedbackUrl = Config().feedbackUrl + params; + + String panelTitle = Localization().getStringEx('panel.settings.feedback.label.title', 'PROVIDE FEEDBACK'); + Navigator.push( + context, CupertinoPageRoute(builder: (context) => WebPanel(url: feedbackUrl, title: panelTitle,))); + } + else { + AppAlert.showOfflineMessage(context, Localization().getStringEx('panel.settings.label.offline.feedback', 'Providing a Feedback is not available while offline.')); + } + } + + // Debug + + Widget _buildDebug() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 18, vertical: 24), + child: RoundedButton( + label: Localization().getStringEx("panel.profile_info.button.debug.title", "Debug"), + hint: Localization().getStringEx("panel.profile_info.button.debug.hint", ""), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + onTap: _onDebugClicked(), + ), + ); + } + + Function _onDebugClicked() { + return () { + Analytics.instance.logSelect(target: "Debug"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsDebugPanel())); + }; + } + + //Version Info + Widget _buildVersionInfo(){ + return Container( + alignment: Alignment.center, + child: Text( + "Version: $_versionName", + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + )); + } + + void _loadVersionInfo() async { + PackageInfo.fromPlatform().then((PackageInfo packageInfo) { + setState(() { + _versionName = packageInfo?.version; + }); + }); + } + + // Utilities + + BorderRadius _borderRadiusFromIndex(int index, int length) { + int first = 0; + int last = length - 1; + if ((index == first) && (index < last)) { + return _topRounding; + } + else if ((first < index) && (index == last)) { + return _bottomRounding; + } + else if ((index == first) && (index == last)) { + return _allRounding; + } + else { + return BorderRadius.zero; + } + } + + void _updateState() { + if (mounted) { + setState(() {}); + } + } + +} + +class _OptionsSection extends StatelessWidget { + final List widgets; + final String title; + final String description; + + const _OptionsSection({Key key, this.widgets, this.title, this.description}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Semantics( header: true, + child: Text(title, style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20), + )), + ), + AppString.isStringEmpty(description) + ? Container() + : Padding( + padding: EdgeInsets.only(left: 8, right: 8, bottom: 12), + child: Text( + description, + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + )), + Stack(alignment: Alignment.topCenter, children: [ + Container( + decoration: BoxDecoration( + border: Border.all(color: Styles().colors.surfaceAccent, width: 0.5), + borderRadius: BorderRadius.circular(5.0), + ), + child: Padding(padding: EdgeInsets.all(0), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets)), + ) + ]) + ])); + } +} + +class _DebugContainer extends StatefulWidget { + + final Widget _child; + + _DebugContainer({@required Widget child}) : _child = child; + + _DebugContainerState createState() => _DebugContainerState(); +} + +class _DebugContainerState extends State<_DebugContainer> { + + int _clickedCount = 0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + child: widget._child, + onTap: () { + Log.d("On tap debug widget"); + _clickedCount++; + if (_clickedCount == 7) { + if (Auth().isDebugManager) { + Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsDebugPanel())); + } + _clickedCount = 0; + } + }, + ); + } +} diff --git a/lib/ui/settings/SettingsNewHomePanel.dart b/lib/ui/settings/SettingsNewHomePanel.dart new file mode 100644 index 00000000..4da48bb7 --- /dev/null +++ b/lib/ui/settings/SettingsNewHomePanel.dart @@ -0,0 +1,828 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/AppNavigation.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Connectivity.dart'; +import 'package:illinois/service/Exposure.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/ui/health/Covid19HistoryPanel.dart'; +import 'package:illinois/ui/health/Covid19TransferEncryptionKeyPanel.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingResidentInfoPanel.dart'; +import 'package:illinois/ui/onboarding/OnboardingLoginPhoneVerifyPanel.dart'; +import 'package:illinois/ui/settings/SettingsConsentPanel.dart'; +import 'package:illinois/ui/settings/SettingsExposureNotificationsPanel.dart'; +import 'package:illinois/ui/settings/SettingsGovernmentIdPanel.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; + +import 'SettingsDebugPanel.dart'; + +class SettingsNewHomePanel extends StatefulWidget { + @override + _SettingsNewHomePanelState createState() => _SettingsNewHomePanelState(); +} + +class _SettingsNewHomePanelState extends State implements NotificationsListener { + + bool _isLoading = false; + bool _isDeleting = false; + + @override + void initState() { + super.initState(); + + NotificationService().subscribe(this, [ + Auth.notifyUserPiiDataChanged, + User.notifyUserUpdated, + Health.notifyUserUpdated, + ]); + + _loadHealthUser(); + } + + @override + void dispose() { + super.dispose(); + NotificationService().unsubscribe(this); + } + + void _updateState() { + if (mounted) { + setState(() {}); + } + } + + Future _deleteUserData() async{ + Analytics.instance.logAlert(text: "Remove My Information", selection: "Yes"); + + await Health().deleteUser(); + await Exposure().deleteUser(); + await Auth().deleteUserPiiData(); + await User().deleteUser(); + Auth().logout(); + } + + void _loadHealthUser() { + setState(() { + _isLoading = true; + }); + Health().loadUser().whenComplete((){ + setState(() { + _isLoading = false; + }); + }); + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == Auth.notifyUserPiiDataChanged) { + _updateState(); + } else if (name == User.notifyUserUpdated){ + _updateState(); + } else if (name == Health.notifyUserUpdated){ + _updateState(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: _DebugContainer( + child: Text( + Localization().getStringEx("panel.settings.home.settings.header", "Settings"), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontFamily: Styles().fontFamilies.extraBold, + letterSpacing: 1.0, + ), + textAlign: TextAlign.center, + )), + ), + body: Stack( + children: [ + SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text("About You", + style: TextStyle( + fontFamily: Styles().fontFamilies.extraBold, + color: Styles().colors.fillColorPrimary, + fontSize: 20, + ), + ), + Container(height: 12,), + _buildConnected(), + Container(height: 12,), + CustomRibbonButton( + height: null, + borderRadius: BorderRadius.all(Radius.circular(4)), + label: 'Add A Government ID', + descriptionLabel: 'Verify your identity by adding a government-issued ID', + leftIcon: 'images/icon-passport.png', + onTap: _onAddGovernmentId, + ), + Container(height: 12,), + CustomRibbonButton( + height: null, + borderRadius: BorderRadius.all(Radius.circular(4)), + label: 'COVID-19 Event History', + descriptionLabel: 'View or delete test results, symptom updates, or contact tracing information', + leftIcon: 'images/icon-identity.png', + onTap: _onEventHistoryTapped, + ), + Container(height: 12,), + CustomRibbonButton( + height: null, + borderRadius: BorderRadius.all(Radius.circular(4)), + label: 'Transfer Your COVID-19 Encryption Key', + descriptionLabel: 'View, scan, or save your COVID-19 Encryption Key to transfer to another device.', + leftIcon: 'images/icon-key.png', + onTap: _onTransferKeyTapped, + ), + Container(height: 40,), + Text("Special Consent", + style: TextStyle( + fontFamily: Styles().fontFamilies.extraBold, + color: Styles().colors.fillColorPrimary, + fontSize: 20, + ), + ), + Container(height: 12,), + CustomRibbonButton( + height: null, + borderRadius: BorderRadius.all(Radius.circular(4)), + label: 'Exposure Notifications', + value: (Health()?.healthUser?.exposureNotification ?? false) ? 'Enabled' : 'Disabled', + descriptionLabel: 'Learn more information about exposure notifications and manage your settings.', + onTap: _onExposureNotificationsTapped, + ), + Container(height: 12,), + CustomRibbonButton( + height: null, + value: (Health()?.healthUser?.consent ?? false) ? 'Enabled' : 'Disabled', + borderRadius: BorderRadius.all(Radius.circular(4)), + label: 'Automatic Test Results', + descriptionLabel: 'Learn more information about automatic test results and manage your settings.', + onTap: _onConsentTapped, + ), + Container(height: 40,), + Text("System Settings", + style: TextStyle( + fontFamily: Styles().fontFamilies.extraBold, + color: Styles().colors.fillColorPrimary, + fontSize: 20, + ), + ), + Container(height: 12,), + CustomRibbonButton( + height: null, + value: 'Disabled', + borderRadius: BorderRadius.all(Radius.circular(4)), + label: 'Access device’s location', + descriptionLabel: 'To get the most out of our features, enable location in your device’s settings.', + leftIcon: 'images/icon-location-1.png', + ), + Container(height: 12,), + CustomRibbonButton( + height: null, + value: 'Disabled', + borderRadius: BorderRadius.all(Radius.circular(4)), + label: 'Access device\'s bluetooth', + descriptionLabel: 'To use Bluetooth enable in your device\'s settings.', + leftIcon: 'images/icon-bluetooth.png', + ), + Container(height: 12,), + CustomRibbonButton( + height: null, + value: 'Disabled', + borderRadius: BorderRadius.all(Radius.circular(4)), + label: 'Notifications', + descriptionLabel: 'To receive notifications enable in your device\'s settings.', + leftIcon: 'images/icon-notifications-blue.png', + ), + Container(height: 1, color: Styles().colors.surfaceAccent, margin: EdgeInsets.symmetric(vertical: 20),), + RoundedButton( + label: 'Delete my COVID-19 Information', + hint: '', + backgroundColor: Styles().colors.surface, + fontSize: 16.0, + textColor: Styles().colors.fillColorSecondary, + borderColor: Styles().colors.surfaceAccent, + onTap: _onRemoveMyInfoClicked, + + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 10), + child: Text( + 'Delete your government issued ID information, COVID-19 event history, and encryption key.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + color: Styles().colors.textBackground, + fontSize: 12 + ), + ), + ) + ], + ), + ), + ), + _isLoading ? Center(child: CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(Styles().colors.fillColorSecondary), strokeWidth: 2,),) : Container() + ], + ) + ); + } + + Widget _buildConnected() { + return Column( + children: [ + _buildConnectedNetIdLayout(), + _buildConnectedPhoneLayout() + ], + ); + } + + Widget _buildConnectedNetIdLayout() { + List contentList = List(); + + if(Auth().isShibbolethLoggedIn){ + contentList.add(Container( + width: double.infinity, + decoration: BoxDecoration(borderRadius: BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), border: Border.all(color: Styles().colors.surfaceAccent, width: 0.5)), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text("Connected as ", + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16)), + Text(Auth().userPiiData?.fullName ?? "", + style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20)), + ])))); + contentList.add( + Semantics( explicitChildNodes: true, + child:RibbonButton( + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(4), bottomRight: Radius.circular(4)), + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: "Disconnect your NetID", + onTap: _onDisconnectNetIdClicked))); + } + else if(!Auth().isLoggedIn){ + contentList.add( + Semantics( explicitChildNodes: true, + child: RibbonButton( + borderRadius: BorderRadius.all(Radius.circular(4)), + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: "Connect your NetID", + onTap: _onConnectNetIdClicked))); + } + + return Semantics( container:true, + child: Container(child: Column(children: contentList,))); + } + + Widget _buildConnectedPhoneLayout() { + List contentList = List(); + + if(Auth().isPhoneLoggedIn){ + String full = Auth()?.userPiiData?.fullName ?? ""; + bool hasFull = AppString.isStringNotEmpty(full); + + contentList.add(Container( + width: double.infinity, + decoration: BoxDecoration(borderRadius: BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)), border: Border.all(color: Styles().colors.surfaceAccent, width: 0.5)), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text("Verified as ", + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16)), + Visibility(visible: hasFull, child: Text(full ?? "", style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20)),), + Text(Auth().phoneToken?.phone ?? "", style: TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 20)), + ])))); + contentList.add( + Semantics( explicitChildNodes: true, + child:RibbonButton( + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(4), bottomRight: Radius.circular(4)), + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: "Disconnect your Phone", + onTap: _onDisconnectNetIdClicked))); + } + else if(!Auth().isLoggedIn){ + contentList.add( + Semantics( explicitChildNodes: true, + child:RibbonButton( + borderRadius:BorderRadius.all(Radius.circular(4)), + border: Border.all(color: Styles().colors.surfaceAccent, width: 0), + label: "Verify Your Phone Number", + onTap: _onPhoneVerClicked))); + } + return Column(children: contentList,); + } + + Widget _buildRemoveMyInfoDialog(BuildContext context) { + return StatefulBuilder( + builder: (context, setState){ + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Styles().colors.fillColorPrimary, + borderRadius: BorderRadius.vertical(top: Radius.circular(8)), + ), + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Center( + child: Text( + "Delete your COVID-19 event history?", + style: TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Styles().colors.white, width: 2), + ), + child: Center( + child: Text( + '\u00D7', + style: TextStyle( + fontSize: 24, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + "This will permanently delete all of your COVID-19 event history information. Are you sure you want to continue?", + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Container( + height: 26, + ), + Text( + "Are you sure?", + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Colors.black), + ), + Container( + height: 16, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: RoundedButton( + onTap: () { + Analytics.instance.logAlert(text: "Remove My Information", selection: "No"); + Navigator.pop(context); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorPrimary, + textColor: Styles().colors.fillColorPrimary, + label: 'No'), + ), + Container( + width: 10, + ), + Expanded( + child: Stack( + children: [ + RoundedButton( + onTap: () => _onConfirmRemoveMyInfo(context, setState), + backgroundColor: Styles().colors.fillColorSecondaryVariant, + borderColor: Styles().colors.fillColorSecondaryVariant, + textColor: Styles().colors.surface, + label: Localization().getStringEx("panel.profile_info.dialog.remove_my_information.yes.title", "Yes")), + _isDeleting ? Align(alignment: Alignment.center, child: CircularProgressIndicator()) : Container() + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildLogoutDialog(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Safer Illinois", + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + "Are you sure you want to sign out?", + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Sign out", selection: "Yes"); + Navigator.pop(context); + Auth().logout(); + }, + child: Text("Yes")), + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Sign out", selection: "No"); + Navigator.pop(context); + }, + child: Text("No")) + ], + ), + ], + ), + ), + ); + } + + void _onConnectNetIdClicked() { + Analytics.instance.logSelect(target: "Connect netId"); + Auth().authenticateWithShibboleth(); + } + + void _onDisconnectNetIdClicked() { + if(Auth().isShibbolethLoggedIn) { + Analytics.instance.logSelect(target: "Disconnect netId"); + } else { + Analytics.instance.logSelect(target: "Disconnect phone"); + } + showDialog(context: context, builder: (context) => _buildLogoutDialog(context)); + } + + void _onPhoneVerClicked() { + Analytics.instance.logSelect(target: "Phone Verification"); + if (Connectivity().isNotOffline) { + Navigator.push(context, CupertinoPageRoute(settings: RouteSettings(), builder: (context) => OnboardingLoginPhoneVerifyPanel(onFinish: _didPhoneVer,))); + } else { + AppAlert.showOfflineMessage(context, 'Verify Your Phone Number is not available while offline.'); + } + } + + void _onAddGovernmentId(){ + if(Auth()?.userPiiData?.hasPasportInfo ?? false){ + Navigator.push(context, CupertinoPageRoute( + builder: (context) => SettingsGovernmentIdPanel() + )); + } + else { + Navigator.push(context, CupertinoPageRoute( + builder: (context) => Covid19OnBoardingResidentInfoPanel( + onSucceed: (Map data){ + Navigator.pushReplacement(context, CupertinoPageRoute(builder: (context) => SettingsGovernmentIdPanel(initialData: data,))); + }, + onCancel: ()=>Navigator.pop(context), + ) + )); + } + } + + void _onRemoveMyInfoClicked() { + showDialog(context: context, builder: (context) => _buildRemoveMyInfoDialog(context)); + } + + void _didPhoneVer(_) { + Navigator.of(context)?.popUntil((Route route){ + return AppNavigation.routeRootWidget(route, context: context)?.runtimeType == widget.runtimeType; + }); + } + + void _onConfirmRemoveMyInfo(BuildContext context, Function setState){ + setState(() { + _isDeleting = true; + }); + _deleteUserData() + .then((_){ + Navigator.pop(context); + }) + .whenComplete((){ + setState(() { + _isDeleting = false; + }); + }) + .catchError((error){ + AppAlert.showDialogResult(context, error.toString()).then((_){ + Navigator.pop(context); + }); + }); + + + } + + void _onEventHistoryTapped(){ + Analytics.instance.logSelect(target: "COVID-19 Test History"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19HistoryPanel())); + } + + void _onTransferKeyTapped() { + Analytics.instance.logSelect(target: "Transfer Your COVID-19 Encryption Key"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => Covid19TransferEncryptionKeyPanel())); + } + + void _onExposureNotificationsTapped(){ + Analytics.instance.logSelect(target: "Exposure Notifications"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsExposureNotificationsPanel())); + } + + void _onConsentTapped(){ + Analytics.instance.logSelect(target: "Consent"); + Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsConsentPanel())); + } +} + +class CustomRibbonButton extends StatelessWidget { + final String label; + final String value; + final String descriptionLabel; + + final GestureTapCallback onTap; + final EdgeInsets padding; + final BorderRadius borderRadius; + final BoxBorder border; + final TextStyle style; + final double height; + final String leftIcon; + final String icon; + final BuildContext context; + final String hint; + + CustomRibbonButton({ + @required this.label, + this.value, + this.descriptionLabel, + this.onTap, + this.borderRadius = BorderRadius.zero, + this.border, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + this.style, + this.height = 48.0, + this.icon = 'images/chevron-right.png', + this.leftIcon, + this.context, + this.hint, + }); + + @override + Widget build(BuildContext context) { + return getSemantics(); + } + + Semantics getSemantics() { + return Semantics(label: label, hint : hint, button: true, excludeSemantics: true, child: _content()); + } + + Widget _content() { + bool hasDescription = AppString.isStringNotEmpty(descriptionLabel); + bool hasValue = AppString.isStringNotEmpty(value); + Widget image = getImage(); + Widget leftIconWidget = AppString.isStringNotEmpty(leftIcon) ? Padding(padding: EdgeInsets.only(right: 7), child: Image.asset(leftIcon)) : Container(); + Widget leftIconHiddenWidget = Opacity(opacity: 0, child: AppString.isStringNotEmpty(leftIcon) ? Padding(padding: EdgeInsets.only(right: 7), child: Image.asset(leftIcon)) : Container(),); + return GestureDetector( + onTap: () { onTap(); anaunceChange(); }, + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( + child: Container( + decoration: BoxDecoration(color: Colors.white, border:border, borderRadius: borderRadius, boxShadow: [ + BoxShadow( + color: Styles().colors.lightGray, + spreadRadius: 3, + blurRadius: 3, + offset: Offset(2, 2), // changes position of shadow + ), + ]), + height: this.height, + child: Padding( + padding: padding, + child: Column( + children: [ + Row( + children: [ + leftIconWidget, + Expanded(child: + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: style ?? TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold), + ), + ], + ) + ), + (image != null) ? Padding(padding: EdgeInsets.only(left: 7), child: image) : Container(), + ], + ), + hasValue ? Row( + children: [ + leftIconHiddenWidget, + Expanded(child: hasValue ? Container( + child: Text(value, + style: style ?? TextStyle(color: Styles().colors.textSurface, fontSize: 14, fontFamily: Styles().fontFamilies.regular), + ), + ) : Container() + ,) + ], + ) : Container(), + Row( + children: [ + leftIconHiddenWidget, + Expanded(child: hasDescription ? Container( + margin: EdgeInsets.only(top: 4), + child: Text(descriptionLabel, + style: style ?? TextStyle(color: Styles().colors.textSurface, fontSize: 14, fontFamily: Styles().fontFamilies.regular), + ), + ) : Container() + ,) + ], + ), + ], + ), + ), + ) + ),],), + ); + } + + Widget getImage() { + return (icon != null) ? Image.asset(icon) : null; + } + + void anaunceChange() {} +} + +class _DebugContainer extends StatefulWidget { + + final Widget _child; + + _DebugContainer({@required Widget child}) : _child = child; + + _DebugContainerState createState() => _DebugContainerState(); +} + +class _DebugContainerState extends State<_DebugContainer> { + + int _clickedCount = 0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + child: widget._child, + onTap: () { + Log.d("On tap debug widget"); + _clickedCount++; + + if (_clickedCount == 7) { + _showPinDialog(); + _clickedCount = 0; + } + }, + ); + } + + void _showPinDialog(){ + TextEditingController pinController = TextEditingController(text: (!kReleaseMode || (Config().configEnvironment == ConfigEnvironment.dev)) ? this.pinOfTheDay : ''); + showDialog(context: context, barrierDismissible: false, builder: (context) => Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx('app.title', 'Safer Illinois'), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + Localization().getStringEx('panel.debug.label.pin', 'Please enter pin'), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Container(height: 6,), + TextField(controller: pinController, autofocus: true, keyboardType: TextInputType.number, obscureText: true, + onSubmitted:(String value){ + _onEnterPin(value); + } + ,), + Container(height: 6,), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Navigator.pop(context); + //_finish(); + }, + child: Text(Localization().getStringEx('dialog.cancel.title', 'Cancel'))), + Container(width: 6), + FlatButton( + onPressed: () { + _onEnterPin(pinController?.text); + //_finish(); + }, + child: Text(Localization().getStringEx('dialog.ok.title', 'OK'))) + ], + ) + ], + ), + ), + )); + } + + String get pinOfTheDay { + return AppDateTime().formatUniLocalTimeFromUtcTime(DateTime.now(), "MMdd"); + } + + void _onEnterPin(String pin){ + if (this.pinOfTheDay == pin) { + Navigator.pop(context); + Navigator.push(context, CupertinoPageRoute(builder: (context) => SettingsDebugPanel())); + } else { + AppToast.show("Invalid pin"); + } + } +} \ No newline at end of file diff --git a/lib/ui/settings/SettingsPersonalInfoPanel.dart b/lib/ui/settings/SettingsPersonalInfoPanel.dart new file mode 100644 index 00000000..6c0590b1 --- /dev/null +++ b/lib/ui/settings/SettingsPersonalInfoPanel.dart @@ -0,0 +1,384 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Exposure.dart'; +import 'package:illinois/service/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/utils/Utils.dart'; + +class SettingsPersonalInfoPanel extends StatefulWidget { + _SettingsPersonalInfoPanelState createState() => _SettingsPersonalInfoPanelState(); +} + +class _SettingsPersonalInfoPanelState extends State { + + bool _isDeleting = false; + + @override + void initState() { + super.initState(); + } + + Future _deleteUserData() async{ + Analytics.instance.logAlert(text: "Remove My Information", selection: "Yes"); + + await Health().deleteUser(); + await Exposure().deleteUser(); + await Auth().deleteUserPiiData(); + await User().deleteUser(); + Auth().logout(); + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text(Localization().getStringEx("panel.profile_info.header.title", "PERSONAL INFO"), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 1.0), + ), + ), + body: Column(children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: Container( + child: Column( + children: [ + _PersonalInfoEntry( + visible: Auth().isShibbolethLoggedIn, + title: Localization().getStringEx('panel.profile_info.net_id.title', 'NetID'), + value: Auth().userPiiData?.netId ?? "" + ), + _PersonalInfoEntry( + title: Localization().getStringEx('panel.profile_info.full_name.title', 'Full Name'), + value: Auth().userPiiData?.fullName ?? ""), + _PersonalInfoEntry( + title: Localization().getStringEx('panel.profile_info.first_name.title', 'First Name'), + value: Auth().userPiiData?.firstName ?? ""), + _PersonalInfoEntry( + title: Localization().getStringEx('panel.profile_info.middle_name.title', 'Middle Name'), + value: Auth().userPiiData?.middleName ?? ""), + _PersonalInfoEntry( + title: Localization().getStringEx('panel.profile_info.last_name.title', 'Last Name'), + value: Auth().userPiiData?.lastName ?? ""), + _PersonalInfoEntry( + visible: Auth().isShibbolethLoggedIn, + title: Localization().getStringEx('panel.profile_info.email_address.title', 'Email Address'), + value: Auth().userPiiData?.email ?? ""), + _PersonalInfoEntry( + visible: Auth().isPhoneLoggedIn, + title: Localization().getStringEx("panel.profile_info.phone_number.title", "Phone Number"), + value: Auth().userPiiData?.phone ?? ""), + _buildAccountManagementOptions() + ], + ), + ), + ), + ), + ), + ],), + backgroundColor: Styles().colors.background, + ); + } + + //AccountManagementOptions + Widget _buildAccountManagementOptions() { + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container(height: 10,), + Visibility( + visible: Auth().isShibbolethLoggedIn, + child: Padding( + padding: EdgeInsets.symmetric( vertical: 5), + child: RoundedButton( + label: Localization().getStringEx("panel.profile_info.button.sign_out.title", "Sign Out"), + hint: Localization().getStringEx("panel.profile_info.button.sign_out.hint", ""), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + onTap: _onSignOutClicked, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric( vertical: 5), + child: RoundedButton( + label: Localization().getStringEx("panel.profile_info.button.remove_my_information.title", "Remove My Information"), + hint: Localization().getStringEx("panel.profile_info.button.remove_my_information.hint", ""), + backgroundColor: Styles().colors.background, + fontSize: 16.0, + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + onTap: _onRemoveMyInfoClicked, + ), + ), + ], + ); + } + + Widget _buildRemoveMyInfoDialog(BuildContext context) { + return StatefulBuilder( + builder: (context, setState){ + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + color: Styles().colors.fillColorPrimary, + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Center( + child: Text( + Localization().getStringEx("panel.profile_info.label.remove_my_info.title", "Remove My Info"), + style: TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Styles().colors.white, width: 2), + ), + child: Center( + child: Text( + '\u00D7', + style: TextStyle( + fontSize: 24, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + Localization().getStringEx("panel.profile_info.dialog.remove_my_information.title", + "By answering YES all your personal information and preferences will be deleted from our systems. This action can not be recovered. After deleting the information we will return you to the first screen when you installed the app so you can start again or delete the app."), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Container( + height: 26, + ), + Text( + Localization().getStringEx("panel.profile_info.dialog.remove_my_information.subtitle", "Are you sure?"), + textAlign: TextAlign.center, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, fontSize: 16, color: Colors.black), + ), + Container( + height: 26, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + RoundedButton( + onTap: () => onConfirmRemoveMyInfo(context, setState), + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("panel.profile_info.dialog.remove_my_information.yes.title", "Yes")), + _isDeleting ? Align(alignment: Alignment.center, child: CircularProgressIndicator()) : Container() + ], + ), + Container( + height: 10, + ), + RoundedButton( + onTap: () { + Analytics.instance.logAlert(text: "Remove My Information", selection: "No"); + Navigator.pop(context); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: Localization().getStringEx("panel.profile_info.dialog.remove_my_information.no.title", "No")) + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildLogoutDialog(BuildContext context) { + return Dialog( + child: Padding( + padding: EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Localization().getStringEx("app.title", "Safer Illinois"), + style: TextStyle(fontSize: 24, color: Colors.black), + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Text( + Localization().getStringEx("panel.profile_info.logout.message", "Are you sure you want to sign out?"), + textAlign: TextAlign.left, + style: TextStyle(fontFamily: Styles().fontFamilies.medium, fontSize: 16, color: Colors.black), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Sign out", selection: "Yes"); + Navigator.pop(context); + Auth().logout(); + }, + child: Text(Localization().getStringEx("panel.profile_info.logout.button.yes", "Yes"))), + FlatButton( + onPressed: () { + Analytics.instance.logAlert(text: "Sign out", selection: "No"); + Navigator.pop(context); + }, + child: Text(Localization().getStringEx("panel.profile_info.logout.no", "No"))) + ], + ), + ], + ), + ), + ); + } + + void onConfirmRemoveMyInfo(BuildContext context, Function setState){ + setState(() { + _isDeleting = true; + }); + _deleteUserData() + .then((_){ + Navigator.pop(context); + }) + .whenComplete((){ + setState(() { + _isDeleting = false; + }); + }) + .catchError((error){ + AppAlert.showDialogResult(context, error.toString()).then((_){ + Navigator.pop(context); + }); + }); + + + } + + _onRemoveMyInfoClicked() { + showDialog(context: context, builder: (context) => _buildRemoveMyInfoDialog(context)); + } + + _onSignOutClicked() { + showDialog(context: context, builder: (context) => _buildLogoutDialog(context)); + } + +} + +class _PersonalInfoEntry extends StatelessWidget { + final String title; + final String value; + final bool visible; + + _PersonalInfoEntry({this.title, this.value, this.visible = true}); + + @override + Widget build(BuildContext context) { + return visible + ? Container( + margin: EdgeInsets.only(top: 25), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontFamily: Styles().fontFamilies.medium, + fontSize: 14, + letterSpacing: 0.5, + color: Styles().colors.textBackground), + ), + Container( + height: 5, + ), + Text( + value, + style: + TextStyle(fontSize: 20, color: Styles().colors.fillColorPrimary), + ) + ], + ), + ], + ), + ) + : Container(); + } +} diff --git a/lib/ui/settings/SettingsPrivacyCenterPanel.dart b/lib/ui/settings/SettingsPrivacyCenterPanel.dart new file mode 100644 index 00000000..1f68ded0 --- /dev/null +++ b/lib/ui/settings/SettingsPrivacyCenterPanel.dart @@ -0,0 +1,244 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Auth.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/LinkTileButton.dart'; +import 'package:illinois/ui/widgets/RibbonButton.dart'; +import 'package:package_info/package_info.dart'; + +class SettingsPrivacyCenterPanel extends StatefulWidget{ + @override + State createState() => _SettingsPrivacyCenterPanelState(); + +} + +class _SettingsPrivacyCenterPanelState extends State{ + String _versionName = ""; + + @override + void initState() { + _loadVersionInfo(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + Localization().getStringEx("panel.settings.privacy_center.label.title", "Privacy Center"), + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w900, letterSpacing: 1.0), + ), + ), + body: SingleChildScrollView(child:_buildContent()), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildContent(){ + return + Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child: + Column( + children: [ + Container(height: 51,), + Container( + child: Image.asset("images/group-3.png",excludeFromSemantics: true,), + ), + _buildFinishSetupWidget(), + Container(height: 40,), + Text(Localization().getStringEx("panel.settings.privacy_center.label.description", "Personalize your privacy and data preferences."), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 20, + color: Styles().colors.fillColorPrimary + ), + ), + Container(height: 32,), + _buildSquareButtonsLayout(), + Container(height: 10,), + _buildButtonsLayout(), + Container(height: 32,), + _buildPrivacyPolicyButton(), + Container(height: 33,), + _buildVersionInfo(), + Container(height: 30,), + ], + )); + } + + + Widget _buildFinishSetupWidget(){ + return Visibility( + visible: _showFinishSetupWidget, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 32,), + Text(Localization().getStringEx("panel.settings.privacy_center.label.finish_setup", "Finish setup"), + style: TextStyle( + fontFamily: Styles().fontFamilies.extraBold, + fontSize: 16, + color: Styles().colors.textSurface + ), + ), + Container(height: 4,), + Text(Localization().getStringEx("panel.settings.privacy_center.label.finish_setup_description", "Log in with your NetID or Telephone number to get the full Illinois experience."), + style: TextStyle( + fontFamily: Styles().fontFamilies.regular, + fontSize: 16, + color: Styles().colors.textSurface + ), + ), + Container(height: 10,), + RibbonButton( + leftIcon: "images/user-check.png", + label: Localization().getStringEx("panel.settings.privacy_center.button.verify_identity.title", "Verify your Identity"), + borderRadius: BorderRadius.circular(4), + onTap: () => _onTapVerifyIdentity(), + ), + ], + ), + ); + } + + Widget _buildSquareButtonsLayout(){ + return + Container( + alignment: Alignment.topCenter, + child: + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: LinkTileSmallButton( + label: Localization().getStringEx("panel.settings.privacy_center.button.manage_privacy.title", "Manage and Understand Your Privacy"), + hint: Localization().getStringEx("panel.settings.privacy_center.button.manage_privacy.hint", ""), + iconPath: 'images/privacy.png', + onTap: (){}, + textStyle: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: LinkTileSmallButton.defaultTextColor + ),)), + Expanded(child: LinkTileSmallButton( + label: Localization().getStringEx("panel.settings.privacy_center.button.covid19_privacy.title", "Your COVID-19 Privacy Settings"), + hint: Localization().getStringEx("panel.settings.privacy_center.button.covid19_privacy.hint", ""), + iconPath: 'images/covid.png', + onTap: (){}, + textStyle: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: LinkTileSmallButton.defaultTextColor + ), + )), + ],) + ); + } + + Widget _buildButtonsLayout(){ + return + Container( + alignment: Alignment.topCenter, + child: + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RibbonButton( + label: Localization().getStringEx("panel.settings.privacy_center.button.personal_information.title", "Personal Information"), + borderRadius: BorderRadius.circular(4), + onTap: _onTapPersonalInformation + ), + Container(height: 10,), + RibbonButton( + label: Localization().getStringEx("panel.settings.privacy_center.button.notifications.title", "Notification Preferences"), + borderRadius: BorderRadius.circular(4), + onTap: _onTapNotifications + ), + ],) + ); + } + + Widget _buildPrivacyPolicyButton(){ + return + GestureDetector( + onTap: _onTapPrivacyPolicy, + child: Text( + Localization().getStringEx("panel.settings.privacy_center.button.privacy_policy.title", "Privacy Policy"), + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16, decoration: TextDecoration.underline,decorationColor: Styles().colors.fillColorSecondary,), + )); + } + + //Version Info + Widget _buildVersionInfo(){ + return + Column(children: [ + Container(height: 1, color: Styles().colors.surfaceAccent,), + Container(height: 12,), + Container( + alignment: Alignment.center, + child: Text( + "Version: $_versionName", + style: TextStyle(color: Styles().colors.textBackground, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + )), + ],); + } + + void _loadVersionInfo() async { + PackageInfo.fromPlatform().then((PackageInfo packageInfo) { + setState(() { + _versionName = packageInfo?.version; + }); + }); + } + + + void _onTapVerifyIdentity(){ + Analytics.instance.logSelect(target: "Verify Identity"); + //TBD + } + + void _onTapPersonalInformation(){ + Analytics.instance.logSelect(target: "Personal Information"); + //TBD + } + + void _onTapNotifications(){ + Analytics.instance.logSelect(target: "Notifications"); + //TBD + } + + void _onTapPrivacyPolicy(){ + Analytics.instance.logSelect(target: "Privacy Policy"); + //TBD + } + + bool get _identityVerified{ + return (Auth()?.userPiiData?.identityVerified ?? false); + } + + bool get _showFinishSetupWidget{ + return !(Auth().isLoggedIn && _identityVerified); + } +} \ No newline at end of file diff --git a/lib/ui/settings/SettingsRolesPanel.dart b/lib/ui/settings/SettingsRolesPanel.dart new file mode 100644 index 00000000..4ab6912c --- /dev/null +++ b/lib/ui/settings/SettingsRolesPanel.dart @@ -0,0 +1,197 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/NotificationService.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/ui/widgets/RoleGridButton.dart'; +import 'package:illinois/service/User.dart'; +import 'package:illinois/model/UserData.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class SettingsRolesPanel extends StatefulWidget { + _SettingsRolesPanelState createState() => _SettingsRolesPanelState(); +} + +class _SettingsRolesPanelState extends State implements NotificationsListener { + //User _user; + Set _selectedRoles = Set(); + bool _isResident; + + Timer _saveRolesTimer; + + @override + void initState() { + NotificationService().subscribe(this, User.notifyRolesUpdated); + _selectedRoles = User().roles ?? Set(); + _isResident = _selectedRoles.contains(UserRole.resident); + super.initState(); + } + + @override + void dispose() { + NotificationService().unsubscribe(this); + if (_saveRolesTimer != null) { + _stopSaveRolesTimer(); + _saveSelectedRoles(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + Localization().getStringEx('panel.onboarding.roles.label.title', 'WHO YOU ARE'), + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w900, letterSpacing: 1.0), + ), + ), + body: _buildContent(), + backgroundColor: Styles().colors.background, + ); + } + + Widget _buildContent() { + final double gridSpacing = 5; + + return SingleChildScrollView( + child: Container( + color: Styles().colors.background, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + child: Padding( + padding: EdgeInsets.only(top: 16), + child: Text( + Localization().getStringEx('panel.onboarding.roles.label.description', 'Select all that apply'), + style: TextStyle(fontFamily: Styles().fontFamilies.regular, fontSize: 16, color: Styles().colors.textBackground), + ), + ), + ), + Padding( + padding: EdgeInsets.only(left: 16, top: 8, right: 8, bottom: 16), + child: + + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded(child: RoleGridButton( + title: Localization().getStringEx('panel.onboarding.roles.button.student.title', 'University Student'), + hint: Localization().getStringEx('panel.onboarding.roles.button.student.hint', ''), + iconPath: 'images/icon-persona-student-normal.png', + selectedIconPath: 'images/icon-persona-student-selected.png', + selectedBackgroundColor: Styles().colors.fillColorSecondary, + selected: (_selectedRoles.contains(UserRole.student)), + data: UserRole.student, + sortOrder: 1, + onTap: _onRoleGridButton, + ),), + Container(width: gridSpacing,), + Expanded(child: RoleGridButton( + title: Localization().getStringEx('panel.onboarding.roles.button.employee.title', 'Employee/Affiliate'), + hint: Localization().getStringEx('panel.onboarding.roles.button.employee.hint', ''), + iconPath: 'images/icon-persona-employee-normal.png', + selectedIconPath: 'images/icon-persona-employee-selected.png', + selectedBackgroundColor: Styles().colors.accentColor3, + selected: (_selectedRoles.contains(UserRole.employee)), + data: UserRole.employee, + sortOrder: 4, + onTap: _onRoleGridButton, + ),), + ]), + Container(height: gridSpacing,), + _isResident ? Row(children: [ + Expanded(child: RoleGridButton( + title: Localization().getStringEx('panel.onboarding.roles.button.resident.title', 'Resident'), + hint: Localization().getStringEx('panel.onboarding.roles.button.resident.hint', ''), + iconPath: 'images/icon-persona-resident-normal.png', + selectedIconPath: 'images/icon-persona-resident-selected.png', + selectedBackgroundColor: Styles().colors.fillColorPrimary, + selectedTextColor: Colors.white, + selected:(_selectedRoles.contains(UserRole.resident)), + data: UserRole.resident, + sortOrder: 7, + onTap: _onRoleGridButton, + ),), + Container(width: gridSpacing,), + Expanded(child: Container()), + ]) : Container(), + ]), + ), + ], + ), + ), + ); + } + + void _onRoleGridButton(RoleGridButton button) { + + if (button != null) { + + UserRole role = button.data as UserRole; + + Analytics.instance.logSelect(target: "Role: " + role.toString()); + + if (_selectedRoles.contains(role)) { + _selectedRoles.remove(role); + } else { + _selectedRoles.add(role); + } + + AppSemantics.announceCheckBoxStateChange(context, _selectedRoles.contains(role), button.title); + + setState(() {}); + + _startSaveRolesTimer(); + } + } + + void _startSaveRolesTimer() { + _stopSaveRolesTimer(); + _saveRolesTimer = Timer(Duration(seconds: 3), _saveSelectedRoles); + } + + void _stopSaveRolesTimer() { + if (_saveRolesTimer != null) { + _saveRolesTimer.cancel(); + _saveRolesTimer = null; + } + } + + void _saveSelectedRoles() { + User().roles = _selectedRoles; + _saveRolesTimer = null; + } + + // NotificationsListener + + @override + void onNotification(String name, dynamic param) { + if (name == User.notifyRolesUpdated) { + setState(() { + _selectedRoles = User().roles ?? Set(); + }); + } + } +} + diff --git a/lib/ui/settings/debug/MessagingPanel.dart b/lib/ui/settings/debug/MessagingPanel.dart new file mode 100644 index 00000000..0cf6a928 --- /dev/null +++ b/lib/ui/settings/debug/MessagingPanel.dart @@ -0,0 +1,520 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import "package:flutter/material.dart"; +import 'package:fluttertoast/fluttertoast.dart'; + +import 'package:illinois/service/FirebaseMessaging.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/ui/widgets/HeaderBar.dart'; +import 'package:illinois/service/Styles.dart'; + +class MessagingPanel extends StatefulWidget { + @override + _DropDownButtonState createState() => _DropDownButtonState(); +} + +class _DropDownButtonState extends State { + var _topic = "event_reminders"; + + DropdownButton _itemDown() => DropdownButton( + items: [ + DropdownMenuItem( + value: "event_reminders", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("notification"), + SizedBox(width: 10), + Text( + "event_reminders", + ), + ], + ), + ), + DropdownMenuItem( + value: "athletic_updates", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("notification"), + SizedBox(width: 10), + Text( + "athletic_updates", + ), + ], + ), + ), + DropdownMenuItem( + value: "dinning_specials", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("notification"), + SizedBox(width: 10), + Text( + "dinning_specials", + ), + ], + ), + ), + DropdownMenuItem( + value: "football", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "football", + ), + ], + ), + ), + DropdownMenuItem( + value: "mbball", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "mbball", + ), + ], + ), + ), + DropdownMenuItem( + value: "wbball", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "wbball", + ), + ], + ), + ), + DropdownMenuItem( + value: "mvball", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "mvball", + ), + ], + ), + ), + DropdownMenuItem( + value: "wvball", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "wvball", + ), + ], + ), + ), + DropdownMenuItem( + value: "mtennis", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "mtennis", + ), + ], + ), + ), + DropdownMenuItem( + value: "wtennis", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "wtennis", + ), + ], + ), + ), + DropdownMenuItem( + value: "baseball", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "baseball", + ), + ], + ), + ), + DropdownMenuItem( + value: "softball", + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("data"), + SizedBox(width: 10), + Text( + "softball", + ), + ], + ), + ), + ], + onChanged: (value) { + setState(() { + _topic = value; + }); + }, + value: _topic, + isExpanded: true, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SimpleHeaderBarWithBack( + context: context, + titleWidget: Text( + Localization().getStringEx("panel.debug_messaging.header.title", "Messaging"), + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w900, letterSpacing: 1.0), + ), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: 10), + Container( + padding: EdgeInsets.symmetric(horizontal: 20), + child: _itemDown(), + ), + SizedBox(height: 10), + Visibility( + visible: _isScoreContent(), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 20), + child: _ScoreMessageWidget(topic: _topic), + ), + ), + Visibility( + visible: _isGenericMessageContent(), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 20), + child: _GenericMessageWidget(topic: _topic), + ), + ) + ], + ), + ), + ), + ], + ), + backgroundColor: Styles().colors.background, + ); + } + + bool _isGenericMessageContent() { + return _topic == "event_reminders" || _topic == "athletic_updates" || _topic == "dinning_specials"; + } + + bool _isScoreContent() { + return _topic == "football" || + _topic == "mbball" || + _topic == "wbball" || + _topic == "mvball" || + _topic == "wvball" || + _topic == "mtennis" || + _topic == "wtennis" || + _topic == "baseball" || + _topic == "softball"; + } +} + +class _GenericMessageWidget extends StatefulWidget { + final String _topic; + _GenericMessageWidget({String topic}) : this._topic = topic; + + @override + _GenericMessageWidgetState createState() { + return _GenericMessageWidgetState(); + } +} + +class _GenericMessageWidgetState extends State<_GenericMessageWidget> { + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 25.0), + child: Text(widget._topic), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Enter title'), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + keyboardType: TextInputType.multiline, + maxLines: 3, + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Enter message'), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: RaisedButton( + onPressed: () { + // Validate returns true if the form is valid, or false + // otherwise. + if (_formKey.currentState.validate()) { + _formKey.currentState.save(); + + _showDialog(); + } + }, + child: Text('Submit'), + ), + ), + ], + ), + )); + } + + void _showDialog() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: new Text("Notification type message"), + content: new Text("Currently there is no availability to test notification type messages here. " + "Please use the Firebase console."), + actions: [ + // usually buttons at the bottom of the dialog + new FlatButton( + child: new Text("Close"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + validate(value) { + if (value.isEmpty) { + return 'Enter some text'; + } + return null; + } + + onSubmitResult(value) { + String result = value ? "The message was sent to Firebase successfully" : "An error occured"; + Fluttertoast.showToast(msg: result, toastLength: Toast.LENGTH_SHORT, gravity: ToastGravity.CENTER); + } +} + +class _ScoreMessageWidget extends StatefulWidget { + final String _topic; + _ScoreMessageWidget({String topic}) : this._topic = topic; + + @override + _ScoreMessageWidgetState createState() { + return _ScoreMessageWidgetState(); + } +} + +class _ScoreData { + String gameId; + String path; + String hasStarted; + String isComplete; + String clockSeconds; + String period; + String homeScore; + String visitingScore; +} + +class _ScoreMessageWidgetState extends State<_ScoreMessageWidget> { + final _formKey = GlobalKey(); + _ScoreData _data = new _ScoreData(); + + @override + void initState() { + _data.path = widget._topic; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 25.0), + child: Text(widget._topic), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + initialValue: "16689", + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Enter a game id'), + onSaved: (String value) { + this._data.gameId = value; + }), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + initialValue: "true", + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Has started'), + onSaved: (String value) { + this._data.hasStarted = value; + }), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + initialValue: "false", + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Is completed'), + onSaved: (String value) { + this._data.isComplete = value; + }), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + initialValue: "100", + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Enter clock seconds'), + onSaved: (String value) { + this._data.clockSeconds = value; + }), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + initialValue: "1", + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Enter a period'), + onSaved: (String value) { + this._data.period = value; + }), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + initialValue: "2", + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Enter a home score'), + onSaved: (String value) { + this._data.homeScore = value; + }), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1.0), + child: TextFormField( + initialValue: "1", + validator: (value) => validate(value), + decoration: InputDecoration(hintText: 'Enter a visiting score'), + onSaved: (String value) { + this._data.visitingScore = value; + }), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: RaisedButton( + onPressed: () { + // Validate returns true if the form is valid, or false + // otherwise. + if (_formKey.currentState.validate()) { + _formKey.currentState.save(); + + FirebaseMessaging().send(topic: widget._topic, message: _constructMessage()).then((value) => onSubmitResult(value)); + } + }, + child: Text('Submit'), + ), + ), + ], + ), + )); + } + + dynamic _constructMessage() { + return { + 'ClockSeconds': _data.clockSeconds, + 'GameId': _data.gameId, + 'HomeScore': _data.homeScore, + 'Period': _data.period, + 'HasStarted': _data.hasStarted, + 'IsComplete': _data.isComplete, + 'VisitingScore': _data.visitingScore, + 'Path': _data.path + }; + } + + validate(value) { + //Disable the validation but leave it here for reference + /*if (value.isEmpty) { + return 'Enter some text'; + } */ + return null; + } + + onSubmitResult(value) { + String result = value ? "The message was sent to Firebase successfully" : "An error occured"; + Fluttertoast.showToast(msg: result, toastLength: Toast.LENGTH_SHORT, gravity: ToastGravity.CENTER); + } +} diff --git a/lib/ui/widgets/ExpandableText.dart b/lib/ui/widgets/ExpandableText.dart new file mode 100644 index 00000000..88fd6265 --- /dev/null +++ b/lib/ui/widgets/ExpandableText.dart @@ -0,0 +1,116 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; + +class ExpandableText extends StatefulWidget { + const ExpandableText( + this.text, { + Key key, + this.trimLines = 3, + this.style, + }) : assert(text != null), + super(key: key); + + final String text; + final int trimLines; + final TextStyle style; + + @override + ExpandableTextState createState() => ExpandableTextState(); +} + +class ExpandableTextState extends State { + + bool _collapsed = true; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + TextPainter textPainter = TextPainter( + text: TextSpan( + text: "...", + style: widget.style, + ), + textDirection: TextDirection.rtl, + maxLines: widget.trimLines, + ); + textPainter.layout(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + final elipsisSize = textPainter.size; + textPainter.text = TextSpan( + text: widget.text, + style: widget.style + ); + textPainter.layout(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); + final textSize = textPainter.size; + int endIndex; + final pos = textPainter.getPositionForOffset(Offset( + textSize.width - elipsisSize.width, + textSize.height, + )); + endIndex = textPainter.getOffsetBefore(pos.offset); + if (textPainter.didExceedMaxLines) { + return Column( + children: [ + RichText( + textScaleFactor: MediaQuery.textScaleFactorOf(context), + softWrap: true, + overflow: TextOverflow.clip, + text: TextSpan( + text: _collapsed + ? widget.text.substring(0, endIndex) + "..." + : widget.text, + style: widget.style, + ), + ), + _collapsed ? Container(color: Styles().colors.fillColorSecondary,height: 1,margin: EdgeInsets.only(top:5, bottom: 5),) : Container(), + _collapsed ? Semantics( + button: true, + label: Localization().getStringEx( "app.common.label.read_more", "Read more"), + child: GestureDetector( + onTap: _onTapLink, + child: Center(child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(Localization().getStringEx( "app.common.label.read_more", "Read more"), style: TextStyle(fontSize: 16, + fontFamily: Styles().fontFamilies.bold, + color: Styles().colors.fillColorPrimary),), + Padding( + padding: EdgeInsets.only(left: 7), child: Image.asset('images/icon-down-orange.png'),), + ],), + ), + ), + ) : Container(), + ], + ); + } else { + return Text( + widget.text, + style: widget.style, + ); + } + }, + ); + } + + void _onTapLink() { + setState(() => _collapsed = !_collapsed); + } +} \ No newline at end of file diff --git a/lib/ui/widgets/FilterWidgets.dart b/lib/ui/widgets/FilterWidgets.dart new file mode 100644 index 00000000..54ed5f47 --- /dev/null +++ b/lib/ui/widgets/FilterWidgets.dart @@ -0,0 +1,127 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class FilterListItemWidget extends StatelessWidget { + final String label; + final String subLabel; + final GestureTapCallback onTap; + final bool selected; + + FilterListItemWidget({@required this.label, this.subLabel, @required this.onTap, this.selected = false}); + + @override + Widget build(BuildContext context) { + TextStyle labelsStyle = TextStyle(fontSize: 16, color: Styles().colors.fillColorPrimary, fontFamily: (selected ? Styles().fontFamilies.bold : Styles().fontFamilies.medium)); + bool hasSubLabel = AppString.isStringNotEmpty(subLabel); + return Semantics( + label: label, + button: true, + selected: selected, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + child: Container( + color: (selected ? Styles().colors.background : Colors.white), + child: Padding( + padding: EdgeInsets.all(16), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelsStyle, + ), + Expanded( + child: Container(), + ), + hasSubLabel + ? Text( + subLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelsStyle, + ) + : Container(), + Padding( + padding: EdgeInsets.only(left: 10), + child: Image.asset((selected ? 'images/icon-selected.png' : 'images/icon-unselected.png')), + ) + ], + ), + ), + ))); + } +} + +class FilterSelectorWidget extends StatelessWidget { + final String label; + final String hint; + final String labelFontFamily; + final double labelFontSize; + final bool active; + final EdgeInsets padding; + final bool visible; + final GestureTapCallback onTap; + + FilterSelectorWidget( + {@required this.label, + this.hint, + this.labelFontFamily, + this.labelFontSize = 16, + this.active = false, + this.padding = const EdgeInsets.only(left: 4, right: 4, top: 12), + this.visible = false, + this.onTap}); + + @override + Widget build(BuildContext context) { + return Visibility( + visible: visible, + child: Semantics( + label: label, + hint: hint, + excludeSemantics: true, + button: true, + child: InkWell( + onTap: onTap, + child: Container( + child: Padding( + padding: padding, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + label, + style: TextStyle( + fontSize: labelFontSize, color: (active ? Styles().colors.fillColorSecondary : Styles().colors.fillColorPrimary), fontFamily: labelFontFamily ?? Styles().fontFamilies.bold), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Image.asset(active ? 'images/icon-up.png' : 'images/icon-down.png'), + ) + ], + ), + ), + )))); + } +} \ No newline at end of file diff --git a/lib/ui/widgets/FlexContentWidget.dart b/lib/ui/widgets/FlexContentWidget.dart new file mode 100644 index 00000000..e60ba2e8 --- /dev/null +++ b/lib/ui/widgets/FlexContentWidget.dart @@ -0,0 +1,181 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/ui/WebPanel.dart'; +import 'package:illinois/ui/widgets/RoundedButton.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/* + "widgets": { + "home":{ + "widget1": { + "title": "UIUC Wednesday 2020/03/18", + "text": "7 Dobromir, can we have 3 widgets made like we hacked voter widget for covid. Stored in assets.json. They will be hidden by talent chooser/assets until needed.", + "can_close": true, + "buttons":[ + {"title":"Yes", "link": {"url": "https://illinois.edu", "options": { "target": "internal" } } }, + {"title":"No", "link": {"url": "https://illinois.edu", "options": { "target": "external" } } }, + {"title":"Maybe", "link": {"url": "https://illinois.edu", "options": { "target": { "ios": "internal", "android": "external" } } } } + ] + } + } + }, +*/ + +class FlexContentWidget extends StatefulWidget { + final Map jsonContent; + + FlexContentWidget({@required this.jsonContent}); + + @override + _FlexContentWidgetState createState() => _FlexContentWidgetState(); +} + +class _FlexContentWidgetState extends State { + bool _visible = true; + + @override + Widget build(BuildContext context) { + bool closeVisible = widget.jsonContent != null ? (widget.jsonContent['can_close'] ?? false) : false; + return Visibility( + visible: _visible, + child: Semantics( + container: true, + child: Container( + color: Styles().colors.lightGray, + child: Row( + children: [ + Expanded( + child: Stack( + children: [ + Container( + height: 1, + color: Styles().colors.fillColorPrimaryVariant, + ), + Padding(padding: EdgeInsets.symmetric(horizontal: 20, vertical: 30), child: _buildContent()), + Visibility(visible: closeVisible, child: Container( + alignment: Alignment.topRight, + child: Semantics( + label: Localization().getStringEx("widget.flex_content_widget.button.close.hint", "Close"), + button: true, + excludeSemantics: true, + child: InkWell( + onTap: _onClose, + child: Container(width: 48, height: 48, alignment: Alignment.center, child: Image.asset('images/close-orange.png'))))),), + ], + ), + ) + ], + )))); + } + + Widget _buildContent() { + bool hasJsonContent = (widget.jsonContent != null); + String title = hasJsonContent ? widget.jsonContent['title'] : null; + String text = hasJsonContent ? widget.jsonContent['text'] : null; + List buttonsJsonContent = hasJsonContent ? widget.jsonContent['buttons'] : null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Visibility(visible: AppString.isStringNotEmpty(title), child: Padding(padding: EdgeInsets.only(bottom: 10), child: Text( + AppString.getDefaultEmptyString(value: title), + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontFamily: Styles().fontFamilies.extraBold, + fontSize: 20, + ), + ),),), + Visibility(visible: AppString.isStringNotEmpty(text), child: Padding( + padding: EdgeInsets.only(bottom: 10), + child: Text( + AppString.getDefaultEmptyString(value: text), + style: TextStyle( + color: Color(0xff494949), + fontFamily: Styles().fontFamilies.medium, + fontSize: 16, + ), + ), + ),), + _buildButtons(buttonsJsonContent) + ], + ); + } + + Widget _buildButtons(List buttonsJsonContent) { + if (AppCollection.isCollectionEmpty(buttonsJsonContent)) { + return Container(); + } + List buttons = List(); + for (Map buttonContent in buttonsJsonContent) { + String title = buttonContent['title']; + buttons.add(Row( + mainAxisSize: MainAxisSize.min, + children: [ + RoundedButton( + label: AppString.getDefaultEmptyString(value: title), + padding: EdgeInsets.symmetric(horizontal: 14), + textColor: Styles().colors.fillColorPrimary, + borderColor: Styles().colors.fillColorSecondary, + backgroundColor: Styles().colors.white, + onTap: () => _onTapButton(buttonContent), + ), + ], + )); + } + return Wrap(runSpacing: 8, spacing: 16, children: buttons); + } + + void _onClose() { + Analytics.instance.logSelect(target: "Flex Content: Close"); + setState(() { + _visible = false; + }); + } + + void _onTapButton(Map button) { + String title = button['title']; + Analytics.instance.logSelect(target: "Flex Content: $title"); + + Map linkJsonContent = button['link']; + if (linkJsonContent == null) { + return; + } + String url = linkJsonContent['url']; + if (AppString.isStringEmpty(url)) { + return; + } + Map options = linkJsonContent['options']; + dynamic target = (options != null) ? options['target'] : 'internal'; + if (target is Map) { + target = target[Platform.operatingSystem.toLowerCase()]; + } + + if ((target is String) && (target == 'external')) { + launch(url); + } + else { + Navigator.of(context).push(CupertinoPageRoute(builder: (context) => WebPanel(url: url ))); + } + } +} diff --git a/lib/ui/widgets/HeaderBar.dart b/lib/ui/widgets/HeaderBar.dart new file mode 100644 index 00000000..8df30eb0 --- /dev/null +++ b/lib/ui/widgets/HeaderBar.dart @@ -0,0 +1,223 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/ui/widgets/TrianglePainter.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class HeaderBar extends AppBar { + final BuildContext context; + final Widget titleWidget; + final bool searchVisible; + final bool rightButtonVisible; + final String rightButtonText; + final GestureTapCallback onRightButtonTap; + + HeaderBar( + {@required this.context, this.titleWidget, this.searchVisible = false, + this.rightButtonVisible = false, this.rightButtonText, this.onRightButtonTap}) + : super( + backgroundColor: Styles().colors.fillColorPrimaryVariant, + leading: Semantics( + label: Localization().getStringEx('headerbar.home.title', 'Home'), + hint: Localization().getStringEx('headerbar.home.hint', ''), + button: true, + excludeSemantics: true, + child: IconButton( + icon: Image.asset('images/block-i-orange.png'), + onPressed: () { + Navigator.of(context).popUntil((route) => route.isFirst); + })), + title: titleWidget, + actions: [ + Visibility( + visible: rightButtonVisible, + child: Semantics( + label: rightButtonText, + button: true, + excludeSemantics: true, + child:InkWell( + onTap: onRightButtonTap, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Text(rightButtonText, + style: TextStyle(color: Colors.white, + fontSize: 16, + fontFamily: Styles().fontFamilies.semiBold, + decoration: TextDecoration.underline, + decorationColor: Styles().colors.fillColorSecondary, + decorationThickness: 1, + decorationStyle: TextDecorationStyle.solid)),),))) + ], + centerTitle: true); +} + +// SimpleAppBar + +class SimpleHeaderBarWithBack extends StatelessWidget implements PreferredSizeWidget { + final BuildContext context; + final Widget titleWidget; + final bool backVisible; + final String backIconRes; + final Function onBackPressed; + final bool searchVisible; + + final semanticsSortKey; + + SimpleHeaderBarWithBack({@required this.context, this.titleWidget, this.backVisible = true, this.onBackPressed, this.searchVisible = false, this.backIconRes = 'images/chevron-left-white.png', this.semanticsSortKey = const OrdinalSortKey(1) }); + + @override + Widget build(BuildContext context) { + return Semantics(sortKey:semanticsSortKey,child:AppBar( + leading: Visibility(visible: backVisible, child: Semantics( + label: Localization().getStringEx('headerbar.back.title', 'Back'), + hint: Localization().getStringEx('headerbar.back.hint', ''), + button: true, + excludeSemantics: true, + child: IconButton( + icon: Image.asset(backIconRes), + onPressed: _onTapBack)),), + title: titleWidget, + centerTitle: true, + backgroundColor: Styles().colors.fillColorPrimaryVariant, + actions: [ + ], + )); + } + + void _onTapBack() { + Analytics.instance.logSelect(target: "Back"); + if (onBackPressed != null) { + onBackPressed(); + } else { + Navigator.pop(context); + } + } + + @override + Size get preferredSize => Size.fromHeight(kToolbarHeight); +} + +class SliverToutHeaderBar extends SliverAppBar { + final BuildContext context; + final String imageUrl; + final GestureTapCallback onBackTap; + + SliverToutHeaderBar( + { + @required this.context, + this.imageUrl, + this.onBackTap, + Color backColor, + Color leftTriangleColor, + Color rightTriangleColor, + }) + : super( + pinned: true, + floating: false, + expandedHeight: 200, + backgroundColor: Styles().colors.fillColorPrimaryVariant, + flexibleSpace: Semantics(container: true,excludeSemantics: true,child: FlexibleSpaceBar( + background: + Container( + color: backColor ?? Styles().colors.background, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + AppString.isStringNotEmpty(imageUrl) ? Positioned.fill(child:Image.network(imageUrl, fit: BoxFit.cover, headers: AppImage.getAuthImageHeaders(),)) : Container(), + CustomPaint( + painter: TrianglePainter(painterColor: rightTriangleColor ?? Styles().colors.fillColorSecondaryTransparent05, left: false), + child: Container( + height: 53, + ), + ), + CustomPaint( + painter: TrianglePainter(painterColor: leftTriangleColor ?? Styles().colors.background), + child: Container( + height: 30, + ), + ), + ], + ), + )) + ), + leading: Semantics( + label: Localization().getStringEx('headerbar.back.title', 'Back'), + hint: Localization().getStringEx('headerbar.back.hint', ''), + button: true, + child: Padding( + padding: const EdgeInsets.all(8), + child: GestureDetector( + onTap: onBackTap != null ? onBackTap : (){ + Analytics.instance.logSelect(target: "Back"); + Navigator.pop(context); + }, + child: ClipOval( + child: Container( + height: 32, + width: 32, + color: Styles().colors.fillColorPrimary, + child: Image.asset('images/chevron-left-white.png') + ), + ), + ), + ) + ) + ); +} + +// SliverSheetHeaderBar + +class SliverHeaderBar extends SliverAppBar { + final BuildContext context; + final Widget titleWidget; + final bool backVisible; + final Color backgroundColor; + final String backIconRes; + final Function onBackPressed; + final List actions; + + SliverHeaderBar({@required this.context, this.titleWidget, this.backVisible = true, this.onBackPressed, this.backgroundColor, this.backIconRes = 'images/chevron-left-white.png', this.actions}): + super( + pinned: true, + floating: false, + backgroundColor: backgroundColor ?? Styles().colors.fillColorPrimaryVariant, + elevation: 0, + leading: Visibility(visible: backVisible, child: Semantics( + label: Localization().getStringEx('headerbar.back.title', 'Back'), + hint: Localization().getStringEx('headerbar.back.hint', ''), + button: true, + excludeSemantics: true, + child: IconButton( + icon: Image.asset(backIconRes), + onPressed: (){ + Analytics.instance.logSelect(target: "Back"); + if (onBackPressed != null) { + onBackPressed(); + } else { + Navigator.pop(context); + } + })),), + title: titleWidget, + centerTitle: true, + actions: actions, + ); +} \ No newline at end of file diff --git a/lib/ui/widgets/HomeHeader.dart b/lib/ui/widgets/HomeHeader.dart new file mode 100644 index 00000000..b0b91cdc --- /dev/null +++ b/lib/ui/widgets/HomeHeader.dart @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class HomeHeader extends StatelessWidget { + final String title; + final subTitle; + final String imageRes; + final Function onSettingsTap; + + HomeHeader({this.title, this.subTitle, this.imageRes, this.onSettingsTap}); + + @override + Widget build(BuildContext context) { + bool hasSubTitle = AppString.isStringNotEmpty(subTitle); + + return Container( + color: Styles().colors.fillColorPrimary, + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( + label: title, + header: true, + excludeSemantics: true, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(right: 12), + child: ((imageRes != null) && imageRes.isNotEmpty) + ? Image.asset( + imageRes, + excludeFromSemantics: true, + ) + : Container(), + ), + Text( + title, + style: TextStyle(color: Colors.white, fontSize: 20), + ), + (onSettingsTap == null) ? Container() : + Expanded( + child: GestureDetector( + onTap: onSettingsTap, + child: Container( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.only(right: 0), + child: Image.asset( + 'images/settings-white.png', + excludeFromSemantics: true, + ))))) + ], + ), + ), + Visibility( + visible: hasSubTitle, + child: Semantics( + label: AppString.getDefaultEmptyString(value: subTitle), + header: true, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.only(left: 30), + child: Text( + AppString.getDefaultEmptyString(value: subTitle), + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontFamily: Styles().fontFamilies.regular), + ), + ), + )) + ], + ), + ); + } +} diff --git a/lib/ui/widgets/ImageHolderListItem.dart b/lib/ui/widgets/ImageHolderListItem.dart new file mode 100644 index 00000000..5b9060b7 --- /dev/null +++ b/lib/ui/widgets/ImageHolderListItem.dart @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class ImageHolderListItem extends StatelessWidget { + final Color placeHolderDividerResource; + final String placeHolderSlantResource; + + final String imageUrl; + final Widget child; + final double imageHeight; + final bool applyHorizontalPadding; + + const ImageHolderListItem( + {Key key, this.placeHolderSlantResource = 'images/slant-down-right.png', this.placeHolderDividerResource, this.imageUrl, this.child, this.imageHeight = 240, this.applyHorizontalPadding = true}) + : super(key: key); + + @override + Widget build(BuildContext context) { + double horizontalPadding = applyHorizontalPadding ? 16 : 0; + String _imageUrl = imageUrl; + return Stack( + alignment: (_imageUrl != null && _imageUrl.isNotEmpty) ? Alignment + .bottomCenter : Alignment.topCenter, + children: [ + Container( + child: !_showImage()? + Padding( + padding: EdgeInsets.only(right: horizontalPadding, left: horizontalPadding, top: 16), + child:child): + Column( + children: [ + Stack( + alignment: Alignment.topCenter, + children: [ + _showImage() ? + Image.network( + _imageUrl, + height: imageHeight, + width: MediaQuery.of(context).size.width, + fit: BoxFit.cover, + headers: AppImage.getAuthImageHeaders(), + ) : Container(height: 0), + Padding( + padding: EdgeInsets.only(top: 168), + child: + Stack( + alignment: Alignment.topCenter, + children: [ + Column( + children: [ + useDivider() ? + Container( + height: 72, + color: placeHolderDividerResource ?? Styles().colors.fillColorSecondary, + ) : Container(height: 0), + useSlantImage() ? + Container( + height: 112, + width: double.infinity, + child: Image.asset( + placeHolderSlantResource, + fit: BoxFit.fill, + color: placeHolderDividerResource ?? Styles().colors.fillColorSecondary, + ), + ) : Container(height: 0), + ], + ), + Padding( + padding: EdgeInsets.only(right: horizontalPadding, left: horizontalPadding, top: 16), + child: child) + ]) + ) + + ]), + ], + ) + ), + + ], + ); + } + bool _showImage(){ + return imageUrl!=null && imageUrl.isNotEmpty; + } + + bool useDivider() { + return placeHolderDividerResource != null; + } + + bool useSlantImage() { + return placeHolderSlantResource != null && + placeHolderSlantResource.isNotEmpty; + } +} \ No newline at end of file diff --git a/lib/ui/widgets/LinkTileButton.dart b/lib/ui/widgets/LinkTileButton.dart new file mode 100644 index 00000000..3133c06e --- /dev/null +++ b/lib/ui/widgets/LinkTileButton.dart @@ -0,0 +1,131 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Styles.dart'; + +class LinkTileWideButton extends StatelessWidget { + final String iconPath; + final String label; + final String hint; + final GestureTapCallback onTap; + + static final Color defaultTextColor = Styles().colors.fillColorPrimary; + + LinkTileWideButton({this.iconPath, this.label, this.hint = '', this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Semantics(label: label, hint:hint, button:true, child:Stack( + children: [ + Padding( + padding: EdgeInsets.all(2), + child: Container( + decoration: BoxDecoration( + color: ( Colors.white), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color:Colors.white, + width: 2)), + child: Padding( + padding: EdgeInsets.only(top: 16, bottom: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 20, + color: LinkTileWideButton.defaultTextColor), + ), + Image.asset((iconPath)), + ], + ), + ), + ), + ), + ], + )), + ); + } +} + +class LinkTileSmallButton extends StatelessWidget { + final String iconPath; + final String label; + final String hint; + final GestureTapCallback onTap; + final TextStyle textStyle; + + static final Color defaultTextColor = Styles().colors.fillColorPrimary; + static const Color _boxShadowColor = Color.fromRGBO(19, 41, 75, 0.3); + + final double width; + + LinkTileSmallButton({this.iconPath, this.label, this.hint = '', this.width = 140, this.onTap, this.textStyle}); + + @override + Widget build(BuildContext context) { + TextStyle style = textStyle?? + TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 20, + color: LinkTileSmallButton.defaultTextColor + ); + + return GestureDetector( + onTap: onTap, + child: Semantics(label: label, hint:hint, button:true, excludeSemantics: true, child:Stack( + children: [ + Padding( + padding: EdgeInsets.all(2), + child: Container( + decoration: BoxDecoration(color: (Styles().colors.white), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.white, width: 2), + boxShadow: [BoxShadow(color: _boxShadowColor, spreadRadius: 2.0, blurRadius: 8.0, offset: Offset(0, 2))]), + width: width, + child: Padding( + padding: EdgeInsets.only(top: 16, bottom: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only(bottom: 16), + child: + Image.asset((iconPath)), + ), + Container(height: 10,), + Text( + label, + textAlign: TextAlign.center, + style: style) + ], + ), + ), + ), + ), + ], + )), + ); + } +} \ No newline at end of file diff --git a/lib/ui/widgets/MapWidget.dart b/lib/ui/widgets/MapWidget.dart new file mode 100644 index 00000000..961abe36 --- /dev/null +++ b/lib/ui/widgets/MapWidget.dart @@ -0,0 +1,96 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:illinois/service/Storage.dart'; +import 'package:illinois/utils/Utils.dart'; + +typedef void MapWidgetCreatedCallback(MapController controller); + +class MapWidget extends StatefulWidget { + final MapWidgetCreatedCallback onMapCreated; + final dynamic creationParams; + + const MapWidget({Key key, this.onMapCreated, this.creationParams}) : super(key: key); + + @override + _MapWidgetState createState() => _MapWidgetState(); +} + +class _MapWidgetState extends State { + @override + Widget build(BuildContext context) { + if (defaultTargetPlatform == TargetPlatform.android) { + return AndroidView( + viewType: 'mapview', + onPlatformViewCreated: onPlatformViewCreated, + creationParamsCodec: const StandardMessageCodec(), + creationParams: widget.creationParams, + ); + } else if(defaultTargetPlatform == TargetPlatform.iOS) { + return UiKitView( + viewType: 'mapview', + onPlatformViewCreated: onPlatformViewCreated, + creationParamsCodec: const StandardMessageCodec(), + creationParams: widget.creationParams, + ); + } + return Text('$defaultTargetPlatform is not yet supported by this plugin'); + } + + Future onPlatformViewCreated(id) async { + if (widget.onMapCreated == null) { + return; + } + widget.onMapCreated(MapController.init(id)); + } +} + +class MapController { + MethodChannel _channel; + int _mapId; + + + MapController.init(int id) { + _mapId = id; + _channel = MethodChannel('edu.illinois.covid/mapview_$id'); + } + + int get mapId { return _mapId; } + + Future placePOIs(List explores) async { + var options = { + "LocationThresoldDistance": Storage().debugMapThresholdDistance + }; + List jsonData = List(); + if (AppCollection.isCollectionNotEmpty(explores)) { + for (dynamic explore in explores) { + jsonData.add(explore.toJson()); + } + } + return _channel.invokeMethod('placePOIs', { "explores": jsonData, "options": options}); + } + + Futureenable(bool enable) async { + return _channel.invokeMethod('enable', enable); + } + + FutureenableMyLocation(bool enable) async { + return _channel.invokeMethod('enableMyLocation', enable); + } +} diff --git a/lib/ui/widgets/ModalImageDialog.dart b/lib/ui/widgets/ModalImageDialog.dart new file mode 100644 index 00000000..efec2b16 --- /dev/null +++ b/lib/ui/widgets/ModalImageDialog.dart @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class ModalImageDialog extends StatelessWidget{ + final String imageUrl; + final GestureTapCallback onClose; + + ModalImageDialog({this.imageUrl, this.onClose}); + + @override + Widget build(BuildContext context) { + return Column(children: [ + Expanded( + child: Container( + color: Styles().colors.blackTransparent06, + child: Dialog( + //backgroundColor: Color(0x00ffffff), + child:Container( + child: Column( + children: [ + Container( + color: Styles().colors.fillColorPrimary, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: onClose, + child: Padding( + padding: EdgeInsets.only(right: 10, top: 10), + child: Text('\u00D7', + style: TextStyle( + color: Colors.white, + fontFamily: Styles().fontFamilies.medium, + fontSize: 50 + ), + ), + ), + ) + ], + ), + ), + Expanded( + child: Container( + //margin: EdgeInsets.only(right: horizontalMargin + photoMargin, top: photoMargin), + child: AppString.isStringNotEmpty(imageUrl) ? Image.network(imageUrl, fit: BoxFit.cover,): Container(), + ), + ) + ], + ), + ) + ), + + ), + ) + ], + ); + } +} \ No newline at end of file diff --git a/lib/ui/widgets/OptionSelectionCell.dart b/lib/ui/widgets/OptionSelectionCell.dart new file mode 100644 index 00000000..59c6b68b --- /dev/null +++ b/lib/ui/widgets/OptionSelectionCell.dart @@ -0,0 +1,104 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; + +class OptionSelectionCell extends StatelessWidget { + final String iconPath; + final String selectedIconPath; + final Color selectedBackgroundColor; + final Color selectedTextColor; + final String label; + final String hint; + final bool selected; + + final bool isButton; + final bool isCustomToggle; + static final Color defaultTextColor = Styles().colors.fillColorPrimary; + + OptionSelectionCell( + {@required this.iconPath, + @required this.selectedIconPath, + @required this.label, + this.hint = '', + this.selected = false, + this.selectedBackgroundColor, + this.selectedTextColor, this.isButton = true, this.isCustomToggle= true}); + + @override + Widget build(BuildContext context) { + String hint = ""; + if(isCustomToggle){ + hint = this.hint + (selected?Localization().getStringEx("toggle_button.status.checked", "checked",) : + Localization().getStringEx("toggle_button.status.unchecked", "unchecked")); + + hint += ", "+ Localization().getStringEx("toggle_button.status.checkbox", "checkbox"); + } + return Semantics(label: label /*+", "+hint,*/, /*hint: hint,*/button: isButton, /*checked: selected,*/ excludeSemantics: true, value: hint,child: + Stack( + children: [ + Padding( + padding: EdgeInsets.all(2), + child: Container( + decoration: BoxDecoration( + color: (selected ? selectedBackgroundColor : Colors.white), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: + (selected ? Styles().colors.fillColorPrimary : Colors.white), + width: 2)), + width: 140, + child:Padding( + padding: EdgeInsets.all(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only(bottom: 16), + child: + Image.asset((selected ? selectedIconPath : iconPath)), + ), + Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 17, + color: (selected + ? selectedTextColor ?? Styles().colors.fillColorPrimary + : OptionSelectionCell.defaultTextColor)), + ) + ], + ), + ), + ), + ), + Container( + width: 146, + child: Visibility( + visible: selected, + child: Align( + alignment: Alignment.topRight, + child: Image.asset('images/icon-check.png'), + )), + ) + ], + )); + } +} diff --git a/lib/ui/widgets/PopupDialog.dart b/lib/ui/widgets/PopupDialog.dart new file mode 100644 index 00000000..4c905b78 --- /dev/null +++ b/lib/ui/widgets/PopupDialog.dart @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; + +import 'RoundedButton.dart'; + +class PopupDialog extends StatelessWidget { + final String _displayText; + final String _positiveButtonText; + + PopupDialog({String displayText, String positiveButtonText}) : _displayText = displayText, _positiveButtonText = positiveButtonText; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + color: Styles().colors.fillColorPrimary, + child: Padding( + padding: EdgeInsets.all(8), + child: Center( + child: Text( + Localization().getStringEx("app.title", "Safer Illinois"), + style: TextStyle(fontSize: 20, color: Colors.white), + ), + ), + ), + ), + ), + ], + ), + Container(height: 26,), + Text( + _displayText, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Colors.black), + ), + Container(height: 26,), + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RoundedButton( + onTap: () { + Navigator.of(context).pop(true); + }, + backgroundColor: Colors.transparent, + borderColor: Styles().colors.fillColorSecondary, + textColor: Styles().colors.fillColorPrimary, + label: _positiveButtonText), + Container(height: 10,), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/ui/widgets/RibbonButton.dart b/lib/ui/widgets/RibbonButton.dart new file mode 100644 index 00000000..725c7e81 --- /dev/null +++ b/lib/ui/widgets/RibbonButton.dart @@ -0,0 +1,141 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class RibbonButton extends StatelessWidget { + final String label; + + final GestureTapCallback onTap; + final EdgeInsets padding; + final BorderRadius borderRadius; + final BoxBorder border; + final TextStyle style; + final double height; + final String leftIcon; + final String icon; + final BuildContext context; + final String hint; + + RibbonButton({ + @required this.label, + this.onTap, + this.borderRadius = BorderRadius.zero, + this.border, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + this.style, + this.height = 48.0, + this.icon = 'images/chevron-right.png', + this.leftIcon, + this.context, + this.hint, + }); + + @override + Widget build(BuildContext context) { + return getSemantics(); + } + + Semantics getSemantics() { + return Semantics(label: label, hint : hint, button: true, excludeSemantics: true, child: _content()); + } + + Widget _content() { + Widget image = getImage(); + return GestureDetector( + onTap: () { onTap(); anaunceChange(); }, + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( + child: Container( + decoration: BoxDecoration(color: Colors.white, border:border, borderRadius: borderRadius), + height: this.height, + child: Padding( + padding: padding, + child: Row( + children: [ + AppString.isStringNotEmpty(leftIcon) ? Padding(padding: EdgeInsets.only(right: 7), child: Image.asset(leftIcon)) : Container(), + Expanded(child: + Text(label, + style: style ?? TextStyle(color: Styles().colors.fillColorPrimary, fontSize: 16, fontFamily: Styles().fontFamilies.bold), + ) + ), + (image != null) ? Padding(padding: EdgeInsets.only(left: 7), child: image) : Container(), + ], + ), + ), + ) + ),],), + ); + } + + Widget getImage() { + return (icon != null) ? Image.asset(icon) : null; + } + + void anaunceChange() {} +} + +class ToggleRibbonButton extends RibbonButton { + final String label; + final GestureTapCallback onTap; + final bool toggled; + final BorderRadius borderRadius; + final BoxBorder border; + final BuildContext context; //Required in order to announce the VO status change + final TextStyle style; + final double height; + + ToggleRibbonButton ({ + @required this.label, + this.onTap, + this.toggled = false, + this.borderRadius = BorderRadius.zero, + this.border, + this.context, + this.height = 48.0, + this.style + }); + + @override + Widget getImage() { + return Image.asset(toggled ? 'images/switch-on.png' : 'images/switch-off.png'); + } + + @override + Semantics getSemantics() { + return Semantics( + label: label, + value: (toggled + ? Localization().getStringEx( + "toggle_button.status.checked", + "checked", + ) + : Localization().getStringEx("toggle_button.status.unchecked", "unchecked")) + + ", " + + Localization().getStringEx("toggle_button.status.checkbox", "checkbox"), + excludeSemantics: true, + child: _content()); + } + + @override + void anaunceChange() { + AppSemantics.announceCheckBoxStateChange(context, !toggled, label); // !toggled because we announce before the state got updated + super.anaunceChange(); + } +} diff --git a/lib/ui/widgets/RoleGridButton.dart b/lib/ui/widgets/RoleGridButton.dart new file mode 100644 index 00000000..94355d27 --- /dev/null +++ b/lib/ui/widgets/RoleGridButton.dart @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class RoleGridButton extends StatelessWidget { + final String title; + final String hint; + final String iconPath; + final String selectedIconPath; + final Color backgroundColor; + final Color selectedBackgroundColor; + final Color borderColor; + final Color selectedBorderColor; + final Color textColor; + final Color selectedTextColor; + final bool selected; + final dynamic data; + final double sortOrder; + final Function onTap; + + RoleGridButton( + {this.title, + this.hint, + this.iconPath, + this.selectedIconPath, + this.backgroundColor = Colors.white, + this.selectedBackgroundColor = Colors.white, + this.borderColor = Colors.white , + this.selectedBorderColor, + this.textColor, + this.selectedTextColor, + this.selected = false, + this.sortOrder, + this.data, + this.onTap,}); + + @override + Widget build(BuildContext context) { + return GestureDetector(onTap: () { + if (this.onTap != null) { + this.onTap(this); + AppSemantics.announceCheckBoxStateChange(context, !selected, title); + } }, //onTap (this), + child: Semantics(label: title, excludeSemantics: true, sortKey: sortOrder!=null?OrdinalSortKey(sortOrder) : null, + value: (selected?Localization().getStringEx("toggle_button.status.checked", "checked",) : + Localization().getStringEx("toggle_button.status.unchecked", "unchecked")) + + ", "+ Localization().getStringEx("toggle_button.status.checkbox", "checkbox"), + child:Stack( + children: [ + Padding( + padding: EdgeInsets.only(top: 8, right: 8), + child: Container( + decoration: BoxDecoration( + color: (this.selected ? this.selectedBackgroundColor : this.backgroundColor), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: this.selected ? (this.selectedBorderColor ?? Styles().colors.fillColorPrimary) : this.borderColor, width: 2), + boxShadow: [BoxShadow(color: Styles().colors.blackTransparent018, offset: Offset(2, 2), blurRadius: 6),], + ), + child: Padding(padding: EdgeInsets.symmetric(horizontal: 28, vertical: 18), child: Column(children: [ + Image.asset((this.selected ? this.selectedIconPath : this.iconPath)), + Container(height: 18,), + Text(title, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 17, + color: (this.selected ? (this.selectedTextColor ?? Styles().colors.fillColorPrimary) : (this.textColor ?? Styles().colors.fillColorPrimary))), + ) + + ],),), + ), + ), + Visibility( + visible: this.selected, + child: Align( + alignment: Alignment.topRight, + child: Image.asset('images/icon-check.png'), + ), + ), + ], + ))); + } +} diff --git a/lib/ui/widgets/RoundedButton.dart b/lib/ui/widgets/RoundedButton.dart new file mode 100644 index 00000000..8bb0e556 --- /dev/null +++ b/lib/ui/widgets/RoundedButton.dart @@ -0,0 +1,246 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Styles.dart' ; + +class RoundedButton extends StatelessWidget { + final String label; + final String hint; + final Color backgroundColor; + final Function onTap; + final Color textColor; + final TextAlign textAlign; + final String fontFamily; + final double fontSize; + final Color borderColor; + final double borderWidth; + final Color secondaryBorderColor; + final EdgeInsetsGeometry padding; + final bool enabled; + final double height; + final double width; + final bool showAdd; + + RoundedButton( + {this.label = '', + this.hint = '', + this.backgroundColor, + this.textColor = Colors.white, + this.textAlign = TextAlign.center, + this.fontFamily, + this.fontSize = 20.0, + this.padding = const EdgeInsets.all(0), + this.enabled = true, + this.borderColor, + this.borderWidth = 2.0, + this.secondaryBorderColor, + this.onTap, + this.height = 48, + this.width, + this.showAdd = false}); + + @override + Widget build(BuildContext context) { + return Semantics( + label: label, + hint: hint, + button: true, + excludeSemantics: true, + enabled: enabled, + child: InkWell( + onTap: onTap, + child: Container( + height: height, + width: width, + decoration: BoxDecoration( + color: (backgroundColor ?? Styles().colors.fillColorPrimary), + border: Border.all( + color: (borderColor != null) ? borderColor : (backgroundColor ?? Styles().colors.fillColorPrimary), + width: borderWidth), + borderRadius: BorderRadius.circular(height / 2), + ), + child: Container( + height: (height - 2), + decoration: BoxDecoration( + color: (backgroundColor ?? Styles().colors.fillColorPrimary), + border: Border.all( + color: (secondaryBorderColor != null) + ? secondaryBorderColor + : (backgroundColor ?? Styles().colors.fillColorPrimary), + width: borderWidth), + borderRadius: BorderRadius.circular(height / 2)), + child: Padding( + padding: padding, + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text( + label, + textAlign: textAlign, + style: TextStyle( + fontFamily: fontFamily ?? Styles().fontFamilies.bold, + fontSize: fontSize, + color: textColor, + ), + ), + Visibility( + visible: showAdd, + child: Padding( + padding: EdgeInsets.only(left: 5), + child: Image.asset('images/icon-add-20x18.png'), + )) + ],)), + ), + ), + )); + } +} + +class SmallRoundedButton extends StatelessWidget { + final String label; + final String hint; + final GestureTapCallback onTap; + final bool showChevron; + final Color borderColor; + + SmallRoundedButton({@required this.label, this.hint = '', this.onTap, this.showChevron = true, this.borderColor}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Semantics( + label: label, + hint: hint, + button: true, + excludeSemantics: true, + child: Container( + height: 32, + decoration: BoxDecoration( + border: Border.all(color: borderColor ?? Styles().colors.fillColorSecondary, width: 2.0), + borderRadius: BorderRadius.circular(24.0), + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontFamily: Styles().fontFamilies.bold, + fontSize: 16, + color: Styles().colors.fillColorPrimary), + ), + Visibility( + visible: showChevron, + child: Padding( + padding: EdgeInsets.only(left: 5), + child: Image.asset('images/chevron-right.png'), + )) + ], + ), + ), + ), + )); + } +} + +class ScalableRoundedButton extends StatelessWidget { + final String label; + final String hint; + final Color backgroundColor; + final Function onTap; + final Color textColor; + final TextAlign textAlign; + final String fontFamily; + final double fontSize; + final Color borderColor; + final double borderWidth; + final Color secondaryBorderColor; + final EdgeInsetsGeometry padding; + final bool enabled; + final bool showAdd; + + ScalableRoundedButton( + {this.label = '', + this.hint = '', + this.backgroundColor, + this.textColor = Colors.white, + this.textAlign = TextAlign.center, + this.fontFamily, + this.fontSize = 20.0, + this.padding = const EdgeInsets.all(5), + this.enabled = true, + this.borderColor, + this.borderWidth = 2.0, + this.secondaryBorderColor, + this.onTap, + this.showAdd = false}); + + @override + Widget build(BuildContext context) { + BorderRadius borderRadius = BorderRadius.circular(24); + return Semantics( + label: label, + hint: hint, + button: true, + excludeSemantics: true, + enabled: enabled, + child: InkWell( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: (backgroundColor ?? Styles().colors.fillColorPrimary), + border: Border.all( + color: (borderColor != null) ? borderColor : (backgroundColor ?? Styles().colors.fillColorPrimary), + width: borderWidth), + borderRadius: borderRadius, + ), + child: Container( + decoration: BoxDecoration( + color: (backgroundColor ?? Styles().colors.fillColorPrimary), + border: Border.all( + color: (secondaryBorderColor != null) + ? secondaryBorderColor + : (backgroundColor ?? Styles().colors.fillColorPrimary), + width: borderWidth), + borderRadius: borderRadius), + child: Padding( + padding: padding, + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Expanded(child: + Text( + label, + textAlign: textAlign, + style: TextStyle( + fontFamily: fontFamily ?? Styles().fontFamilies.bold, + fontSize: fontSize, + color: textColor, + ), + )), + Visibility( + visible: showAdd, + child: Padding( + padding: EdgeInsets.only(left: 5), + child: Image.asset('images/icon-add-20x18.png'), + )) + ],)), + ), + ), + )); + } +} diff --git a/lib/ui/widgets/RoundedTab.dart b/lib/ui/widgets/RoundedTab.dart new file mode 100644 index 00000000..9899d527 --- /dev/null +++ b/lib/ui/widgets/RoundedTab.dart @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Styles.dart'; + +class RoundedTab extends StatelessWidget { + final String title; + final String hint; + final int tabIndex; + final RoundedTabListener listener; + final bool selected; + static const Color _borderColor = Color(0xffdadde1); + RoundedTab({this.title, this.hint, this.tabIndex, this.listener, this.selected}) + : super(); + + @override + Widget build(BuildContext context) { + return GestureDetector(onTap: () => onPressed(), child: Semantics(label: title, hint: hint, button: true, selected: selected, excludeSemantics: true, child:Padding( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: + Container( + height: 40, + decoration: new ShapeDecoration( + color: _borderColor, + shape: RoundedRectangleBorder( + borderRadius: new BorderRadius.circular(34.0), + ), + ), + child: Padding( + padding: EdgeInsets.all(1), + child: Container( + decoration: new ShapeDecoration( + color: getBackColor(), + shape: RoundedRectangleBorder( + borderRadius: new BorderRadius.circular(35.0), + ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + vertical: 10, horizontal: 20), + child: Text(title, + style: TextStyle(fontFamily: Styles().fontFamilies.bold, color: getTextColor(), + fontSize: 16,))))) + ) + ),),); + } + + Color getBackColor() { + return selected + ? Styles().colors.fillColorPrimary + : Styles().colors.surfaceAccent; + } + + Color getTextColor(){ + return selected + ? Colors.white + : Styles().colors.fillColorPrimary; + } + + void onPressed() { + listener.onTabClicked(tabIndex, this); + } +} + +abstract class RoundedTabListener { + void onTabClicked(int tabIndex, RoundedTab caller); +} \ No newline at end of file diff --git a/lib/ui/widgets/ScalableScrollView.dart b/lib/ui/widgets/ScalableScrollView.dart new file mode 100644 index 00000000..8476efbc --- /dev/null +++ b/lib/ui/widgets/ScalableScrollView.dart @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:illinois/ui/health/onboarding/Covid19OnBoardingConsentPanel.dart'; + +class ScalableScrollView extends StatefulWidget{ + final Widget bottomNotScrollableWidget; + final Widget scrollableChild; + + const ScalableScrollView({Key key, this.bottomNotScrollableWidget, this.scrollableChild}) : super(key: key); + + @override + _ScalableScrollViewState createState() => _ScalableScrollViewState(); +} + +class _ScalableScrollViewState extends State{ + Size _bottomWidgetSize; + Size _scrollableChildSize; + + @override + Widget build(BuildContext context) { + bool needScroll = _scrollableChildSize!=null && _bottomWidgetSize!=null ? (_scrollableChildSize.height + _bottomWidgetSize.height > MediaQuery.of(context).size.height): false; + double scrollableHeight = MediaQuery.of(context).size.height - (_bottomWidgetSize?.height??0) ; + int bottomWidgetFlex = (_bottomWidgetSize?.height ?? 0) > 1? (_bottomWidgetSize?.height??1).round() : 0; + int scrollableWidgetFlex = scrollableHeight > 0? (scrollableHeight?.round()??1) : 0; + return Container( + child:Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + needScroll? + Flexible( + flex: scrollableWidgetFlex, + child: SingleChildScrollView( + child: _buildScrollContent(), + ) + ) : _buildScrollContent(), + needScroll? Container() : Expanded(child: Container(),), + needScroll? + Flexible( + flex: bottomWidgetFlex, + child: _buildBottomWidget() + ) : _buildBottomWidget(), + ], + ) + ); + } + + Widget _buildScrollContent(){ + return MeasureSize( + onChange: (Size size){ + if(_scrollableChildSize != size){ + setState(() { + _scrollableChildSize = size; + }); + } + }, + child:widget.scrollableChild ?? Container()); + } + + Widget _buildBottomWidget(){ + return MeasureSize( + onChange: (Size size){ + if(_bottomWidgetSize!=size) { + setState(() { + _bottomWidgetSize = size; + }); + } + }, + child: + Column( +// key: bottomWidgetKey, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children:[ + widget.bottomNotScrollableWidget ?? Container() + ] + ) + ); + } +} \ No newline at end of file diff --git a/lib/ui/widgets/SectionTitlePrimary.dart b/lib/ui/widgets/SectionTitlePrimary.dart new file mode 100644 index 00000000..5732bd73 --- /dev/null +++ b/lib/ui/widgets/SectionTitlePrimary.dart @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/ui/widgets/TrianglePainter.dart'; +import 'package:illinois/utils/Utils.dart'; +import 'package:illinois/service/Styles.dart'; + +class SectionTitlePrimary extends StatelessWidget{ + final String title; + final String subTitle; + final String iconPath; + final List children; + final String slantImageRes; + final Color backgroundColor; + final Color slantColor; + final Color textColor; + + SectionTitlePrimary({this.title, this.subTitle, this.iconPath, this.children, + this.slantImageRes = "", this.slantColor, this.backgroundColor, this.textColor}); + + @override + Widget build(BuildContext context) { + bool hasSubTitle = AppString.isStringNotEmpty(subTitle); + bool useImageSlant = AppString.isStringNotEmpty(slantImageRes); + return Stack( + alignment: Alignment.topCenter, + children: [ + Column( + children: [ + Container( + color: slantColor ?? Styles().colors.fillColorPrimary, + height: 40, + ), + + Visibility(visible:useImageSlant,child:Container( + height: 112, + width: double.infinity, + child: Image.asset(slantImageRes, color: slantColor ?? Styles().colors.fillColorPrimary, fit: BoxFit.fill), + ) + ), + Visibility(visible:!useImageSlant,child: + Container( + color: slantColor ?? Styles().colors.fillColorPrimary, + height: 45, + ), + ), + Visibility(visible:!useImageSlant,child: + Container( + color: slantColor ?? Styles().colors.fillColorPrimary, + child:CustomPaint( + painter: TrianglePainter(painterColor: backgroundColor ?? Styles().colors.background, left : true), + child: Container( + height: 67, + ), + ))), + ], + ), + Column( + children: [ + Padding( + padding: EdgeInsets.only(left: 16, top: 16, right: 16, bottom: 2), + child: Semantics(label:title, header: true, excludeSemantics: true, child:Row( + children: [ + iconPath != null ? Padding( + padding: EdgeInsets.only( + right: 16), + child: Image.asset( + iconPath), + ) : Container(), + Text( + title, + style: TextStyle( + color: textColor ?? Styles().colors.textColorPrimary, + fontSize: 20), + ), + ], + )), + ), + Visibility(visible: hasSubTitle, + child: Semantics( + label: AppString.getDefaultEmptyString(value: subTitle), + header: true, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.only(left: 50, right: 16), + child: Row(children: [ + Text(AppString.getDefaultEmptyString(value: subTitle), + style: TextStyle(fontSize: 16, + color: Colors.white, + fontFamily: Styles().fontFamilies.regular),), + Expanded(child: Container(),) + ],),),)), + Padding( + padding: EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: children, + ), + ) + ], + ) + ], + ); + } +} \ No newline at end of file diff --git a/lib/ui/widgets/StatusInfoDialog.dart b/lib/ui/widgets/StatusInfoDialog.dart new file mode 100644 index 00000000..283bd662 --- /dev/null +++ b/lib/ui/widgets/StatusInfoDialog.dart @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/model/Health.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Styles.dart'; + +class StatusInfoDialog extends StatelessWidget{ + final String currentCountyName; + + const StatusInfoDialog({Key key, this.currentCountyName}) : super(key: key); + + static show(BuildContext context, countyName){ + showDialog(context: context,child: StatusInfoDialog(currentCountyName: countyName,)); + } + + @override + Widget build(BuildContext context) { + return StatefulBuilder( + builder: (context, setState){ + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Dialog( + backgroundColor: Styles().colors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView(child:Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(Localization().getStringEx("panel.health.status_update.info_dialog.label1", "Status color definitions can change depending on different counties."), + style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + ), + Container(height: 10,), + RichText( + textScaleFactor: MediaQuery.textScaleFactorOf(context), + text: TextSpan( + text: Localization().getStringEx("panel.health.status_update.info_dialog.label2", "Status colors for "), + style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + children: [ + TextSpan(text: currentCountyName, style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.bold, fontSize: 16)), + TextSpan(text: ':', style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.bold, fontSize: 16)), + ], + ), + ), + Container(height: 10,), + Row( + children: [ + Image.asset('images/icon-member.png', color: covid19HealthStatusColor(kCovid19HealthStatusGreen),), + Container(width: 8,), + Expanded( + child: Text(Localization().getStringEx("com.illinois.covid19.status.info.description.green", "Green: Recent antibodies"), + style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + ), + ), + ], + ), + Container(height: 10,), + Row( + children: [ + Image.asset('images/icon-member.png', color: covid19HealthStatusColor(kCovid19HealthStatusYellow),), + Container(width: 8,), + Expanded( + child: Text(Localization().getStringEx("com.illinois.covid19.status.info.description.yellow", "Yellow: Recent negative test"), + style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + ), + ), + ], + ), + Container(height: 10,), + Row( + children: [ + Image.asset('images/icon-member.png', color: covid19HealthStatusColor(kCovid19HealthStatusOrange),), + Container(width: 8,), + Expanded( + child: Text(Localization().getStringEx("com.illinois.covid19.status.info.description.orange", "Orange: First time user, Past due for test, Self-reported symptoms, Received exposure notification or Quarantined"), + style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + ), + ), + ], + ), + Container(height: 10,), + Row( + children: [ + Image.asset('images/icon-member.png', color: covid19HealthStatusColor(kCovid19HealthStatusRed),), + Container(width: 8,), + Expanded( + child: Text(Localization().getStringEx("com.illinois.covid19.status.info.description.red", "Red: Positive test"), + style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + ), + ), + ], + ), + Container(height: 10,), + Text(Localization().getStringEx("panel.health.status_update.info_dialog.label3", "Default status for new users is set to Orange."), + style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + ), + Container(height: 10,), + Text(Localization().getStringEx("panel.health.status_update.info_dialog.label4", "An up-to-date on-campus negative test result will reset your COVID-19 status to Yellow, and Building Entry will change to Granted."), + style: TextStyle(color: Styles().colors.textSurface, fontFamily: Styles().fontFamilies.regular, fontSize: 16), + ), + ], + ), + ), + Container(height: 10,), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + border: Border.all(color: Styles().colors.fillColorSecondary, width: 2), + ), + child: Center( + child: Text( + '\u00D7', + style: TextStyle( + fontSize: 24, + color: Styles().colors.fillColorSecondary, + ), + ), + ), + ), + ), + ], + ), + ), + ], + )), + ), + ); + }, + ); + } + +} \ No newline at end of file diff --git a/lib/ui/widgets/SwipeDetector.dart b/lib/ui/widgets/SwipeDetector.dart new file mode 100644 index 00000000..87e21178 --- /dev/null +++ b/lib/ui/widgets/SwipeDetector.dart @@ -0,0 +1,169 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; + +class SwipeConfiguration { + //Vertical swipe configuration options + double verticalSwipeMaxWidthThreshold = 50.0; + double verticalSwipeMinDisplacement = 100.0; + double verticalSwipeMinVelocity = 300.0; + + //Horizontal swipe configuration options + double horizontalSwipeMaxHeightThreshold = 50.0; + double horizontalSwipeMinDisplacement = 100.0; + double horizontalSwipeMinVelocity = 300.0; + + SwipeConfiguration({ + double verticalSwipeMaxWidthThreshold, + double verticalSwipeMinDisplacement, + double verticalSwipeMinVelocity, + double horizontalSwipeMaxHeightThreshold, + double horizontalSwipeMinDisplacement, + double horizontalSwipeMinVelocity, + }) { + if (verticalSwipeMaxWidthThreshold != null) { + this.verticalSwipeMaxWidthThreshold = verticalSwipeMaxWidthThreshold; + } + + if (verticalSwipeMinDisplacement != null) { + this.verticalSwipeMinDisplacement = verticalSwipeMinDisplacement; + } + + if (verticalSwipeMinVelocity != null) { + this.verticalSwipeMinVelocity = verticalSwipeMinVelocity; + } + + if (horizontalSwipeMaxHeightThreshold != null) { + this.horizontalSwipeMaxHeightThreshold = horizontalSwipeMaxHeightThreshold; + } + + if (horizontalSwipeMinDisplacement != null) { + this.horizontalSwipeMinDisplacement = horizontalSwipeMinDisplacement; + } + + if (horizontalSwipeMinVelocity != null) { + this.horizontalSwipeMinVelocity = horizontalSwipeMinVelocity; + } + } +} + +class SwipeDetector extends StatelessWidget { + final Widget child; + final Function() onSwipeUp; + final Function() onSwipeDown; + final Function() onSwipeLeft; + final Function() onSwipeRight; + final SwipeConfiguration swipeConfiguration; + + SwipeDetector( + {@required this.child, + this.onSwipeUp, + this.onSwipeDown, + this.onSwipeLeft, + this.onSwipeRight, + SwipeConfiguration swipeConfiguration}) + : this.swipeConfiguration = swipeConfiguration == null + ? SwipeConfiguration() + : swipeConfiguration; + + @override + Widget build(BuildContext context) { + //Vertical drag details + DragStartDetails startVerticalDragDetails; + DragUpdateDetails updateVerticalDragDetails; + + //Horizontal drag details + DragStartDetails startHorizontalDragDetails; + DragUpdateDetails updateHorizontalDragDetails; + + return GestureDetector( + child: child, + excludeFromSemantics: true, + onVerticalDragStart: (dragDetails) { + startVerticalDragDetails = dragDetails; + }, + onVerticalDragUpdate: (dragDetails) { + updateVerticalDragDetails = dragDetails; + }, + onVerticalDragEnd: (endDetails) { + double dx = updateVerticalDragDetails.globalPosition.dx - + startVerticalDragDetails.globalPosition.dx; + double dy = updateVerticalDragDetails.globalPosition.dy - + startVerticalDragDetails.globalPosition.dy; + double velocity = endDetails.primaryVelocity; + + //Convert values to be positive + if (dx < 0) dx = -dx; + if (dy < 0) dy = -dy; + double positiveVelocity = velocity < 0 ? -velocity : velocity; + + if (dx > swipeConfiguration.verticalSwipeMaxWidthThreshold) return; + if (dy < swipeConfiguration.verticalSwipeMinDisplacement) return; + if (positiveVelocity < swipeConfiguration.verticalSwipeMinVelocity) + return; + + if (velocity < 0) { + //Swipe Up + if (onSwipeUp != null) { + onSwipeUp(); + } + } else { + //Swipe Down + if (onSwipeDown != null) { + onSwipeDown(); + } + } + }, + onHorizontalDragStart: (dragDetails) { + startHorizontalDragDetails = dragDetails; + }, + onHorizontalDragUpdate: (dragDetails) { + updateHorizontalDragDetails = dragDetails; + }, + onHorizontalDragEnd: (endDetails) { + double dx = updateHorizontalDragDetails.globalPosition.dx - + startHorizontalDragDetails.globalPosition.dx; + double dy = updateHorizontalDragDetails.globalPosition.dy - + startHorizontalDragDetails.globalPosition.dy; + double velocity = endDetails.primaryVelocity; + + if (dx < 0) dx = -dx; + if (dy < 0) dy = -dy; + double positiveVelocity = velocity < 0 ? -velocity : velocity; + + print("$dx $dy $velocity $positiveVelocity"); + + if (dx < swipeConfiguration.horizontalSwipeMinDisplacement) return; + if (dy > swipeConfiguration.horizontalSwipeMaxHeightThreshold) return; + if (positiveVelocity < swipeConfiguration.horizontalSwipeMinVelocity) + return; + + if (velocity < 0) { + //Swipe Up + if (onSwipeLeft != null) { + onSwipeLeft(); + } + } else { + //Swipe Down + if (onSwipeRight != null) { + onSwipeRight(); + } + } + }, + ); + } +} diff --git a/lib/ui/widgets/TrianglePainter.dart b/lib/ui/widgets/TrianglePainter.dart new file mode 100644 index 00000000..83ee9521 --- /dev/null +++ b/lib/ui/widgets/TrianglePainter.dart @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; + +class TrianglePainter extends CustomPainter { + final Color painterColor; + final bool left; + + TrianglePainter({this.painterColor, this.left = true}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint(); + paint.color = painterColor; + // create a path + var path = Path(); + if (left) { + path.lineTo(0, size.height); + path.lineTo(size.width, size.height); + } else { + path.moveTo(0, size.height); + path.lineTo(size.width, 0); + path.lineTo(size.width, size.height); + } + // close the path to form a bounded shape + path.close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return false; + } +} + +/// TBD Redesign it +class InvertedTrianglePainter extends CustomPainter { + final Color painterColor; + final bool left; + + InvertedTrianglePainter({this.painterColor, this.left = true,}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint(); + paint.color = painterColor; + // create a path + var path = Path(); + if (left) { + path.moveTo(size.width, size.height); + path.lineTo(size.width, 0); + path.lineTo(0, 0); + } else { + path.moveTo(size.width, 0); + path.lineTo(0, size.height); + path.lineTo(0, 0); + } + // close the path to form a bounded shape + path.close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return false; + } +} \ No newline at end of file diff --git a/lib/ui/widgets/VerticalTitleContentSection.dart b/lib/ui/widgets/VerticalTitleContentSection.dart new file mode 100644 index 00000000..a2b1667d --- /dev/null +++ b/lib/ui/widgets/VerticalTitleContentSection.dart @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:illinois/service/Styles.dart'; + +class VerticalTitleContentSection extends StatelessWidget { + final String title; + final String content; + final double bottomPadding; + + const VerticalTitleContentSection( + {Key key, this.title, this.content, this.bottomPadding = 16}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Semantics( + label: title, + value: content, + excludeSemantics: true, + child: Padding( + padding: EdgeInsets.only( + left: 16, right: 16, bottom: bottomPadding, top: 16), + child: + Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: Styles().colors.fillColorSecondary, width: 3)) + ), + child: Padding(padding: EdgeInsets.only(left: 10, right: 5), + child: + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontSize: 14, + fontFamily: Styles().fontFamilies.regular), + ), + Text(content, + style: TextStyle( + color: Styles().colors.fillColorPrimary, + fontSize: 24, + fontFamily: Styles().fontFamilies.extraBold) + ) + ], + ) + ))), + ); + } +} \ No newline at end of file diff --git a/lib/utils/Covid19.dart b/lib/utils/Covid19.dart new file mode 100644 index 00000000..62ec8b98 --- /dev/null +++ b/lib/utils/Covid19.dart @@ -0,0 +1,97 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:firebase_ml_vision/firebase_ml_vision.dart'; +import 'package:flutter/material.dart'; +import 'package:illinois/service/AppDateTime.dart'; +import 'package:illinois/service/Gallery.dart'; +import 'package:illinois/service/Styles.dart'; +import 'package:image_picker/image_picker.dart'; + +class Covid19Utils { + + static Future saveQRCodeImageToPictures({Uint8List qrCodeBytes, String title}) async { + if (qrCodeBytes != null) { + try { + + final recorder = new ui.PictureRecorder(); + Canvas canvas = new Canvas(recorder, new Rect.fromPoints(new Offset(0.0, 0.0), new Offset(1024.0, 1180.0))); + final fillPaint = new Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + canvas.drawRect( + new Rect.fromLTWH(0.0, 0.0, 1024.0, 1180.0), fillPaint); + + ui.Codec codec = await ui.instantiateImageCodec(qrCodeBytes); + ui.FrameInfo frameInfo = await codec.getNextFrame(); + canvas.drawImage(frameInfo.image, Offset(0.0, 156.0), fillPaint); + + final ui.ParagraphBuilder paragraphBuilder = ui.ParagraphBuilder( + ui.ParagraphStyle(textDirection: ui.TextDirection.ltr, textAlign: TextAlign.center, fontSize: 54, fontFamily: Styles().fontFamilies.bold,), + ) + ..pushStyle(new ui.TextStyle(color: Styles().colors.textSurface)) + ..addText(title); + final ui.Paragraph paragraph = paragraphBuilder.build() + ..layout(ui.ParagraphConstraints(width: 1024)); + double textY = ((1180.0 - 1024.0) - paragraph.height) / 2.0; + canvas.drawParagraph(paragraph, Offset(0.0, textY)); + + final picture = recorder.endRecording(); + final img = await picture.toImage(1024, 1180); + ByteData pngBytes = await img.toByteData(format: ui.ImageByteFormat.png); + Uint8List newQrBytes = Uint8List(pngBytes.lengthInBytes); + for (int i = 0; i < pngBytes.lengthInBytes; i++) { + newQrBytes[i] = pngBytes.getUint8(i); + } + + String dateTimeStr = AppDateTime().formatDateTime(DateTime.now(), format: AppDateTime.covid19QrDateFormat); + String fileName = "Illinois COVID-19 Code $dateTimeStr"; + + return await Gallery().storeImage(imageBytes: newQrBytes, name: fileName); + } + catch(e){ + } + } + return false; + } + + static Future loadQRCodeImageFromPictures() async { + String qrCodeString; + PickedFile imageFile = await ImagePicker().getImage(source: ImageSource.gallery); + if (imageFile != null) { + try { + final FirebaseVisionImage visionImage = FirebaseVisionImage.fromFile(File(imageFile.path)); + final BarcodeDetector barcodeDetector = FirebaseVision.instance.barcodeDetector(BarcodeDetectorOptions(barcodeFormats: BarcodeFormat.qrCode)); + final List barcodes = await barcodeDetector.detectInImage(visionImage); + if ((barcodes != null) && (0 < barcodes.length)) { + Barcode resultBarcode, anyBarcode; + for (Barcode barcode in barcodes) { + if (barcode.format.value == BarcodeFormat.qrCode.value) { + if (barcode.valueType == BarcodeValueType.text) { + resultBarcode = barcode; + break; + } + else if (anyBarcode == null) { + anyBarcode = barcode; + } + } + } + if (resultBarcode == null) { + resultBarcode = anyBarcode; + } + if (resultBarcode != null) { + qrCodeString = resultBarcode.rawValue; + } + } + } + catch(e) { + print(e?.toString()); + } + } + + return qrCodeString; + } + +} \ No newline at end of file diff --git a/lib/utils/Crypt.dart b/lib/utils/Crypt.dart new file mode 100644 index 00000000..5574e57b --- /dev/null +++ b/lib/utils/Crypt.dart @@ -0,0 +1,490 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import "package:asn1lib/asn1lib.dart"; +import 'package:flutter/foundation.dart'; +import 'package:illinois/service/Log.dart'; +import "package:pointycastle/export.dart"; +import 'package:encrypt/encrypt.dart' as Encrypt; + +class HealthServiceTest { + + static void test() { + AsymmetricKeyPair rsaKeyPair = RsaKeyHelper.getRsaKeyPair(RsaKeyHelper.getSecureRandom()); + String rsaPublicKeyString = RsaKeyHelper.encodePublicKeyToPemPKCS1(rsaKeyPair.publicKey); + String rsaPrivateKeyString = RsaKeyHelper.encodePrivateKeyToPemPKCS1(rsaKeyPair.privateKey); + + //Log.d('''Health Service Provider: + //- Input: RSA Public Key, Plain Blob; + //- Output: Ecrypted Blob, Ecrypted Key. + //'''); + + //Log.d("RSA Public Key: $rsaPublicKeyString"); + + String plainBlob = '''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut id sagittis nibh. Ut porttitor interdum bibendum. Sed in interdum ante, ac efficitur felis. In diam justo, molestie sed fermentum rhoncus, euismod nec mauris. Donec interdum at sem vitae volutpat. Quisque fermentum lobortis neque, vitae feugiat est malesuada nec. Cras vehicula dapibus elementum. In at nisi in leo gravida dapibus. Nulla facilisi. Fusce varius tortor non nibh euismod varius. Aenean condimentum velit a felis ornare congue. Donec interdum, leo sit amet iaculis elementum, nunc orci fringilla nulla, non pulvinar lectus turpis vitae ligula. Nam ullamcorper feugiat enim in ullamcorper. Phasellus dignissim nulla et mattis imperdiet.'''; + //Log.d("Plain Blob: $plainBlob"); + + String aesKey = AESCrypt.randomKey(); + //Log.d("Random AES Key: $aesKey"); + + String encryptedBlob = AESCrypt.encrypt(plainBlob, aesKey); + //Log.d("Ecrypted Blob: $encryptedBlob"); + + PublicKey rsaPublicKey = RsaKeyHelper.parsePublicKeyFromPem(rsaPublicKeyString); + String encryptedKey = RSACrypt.encrypt(aesKey, rsaPublicKey); + //Log.d("Ecrypted Key: $encryptedKey"); + + //Log.d('''Client Processing: + //- Input: RSA Private Key, Ecrypted Blob, Ecrypted Key; + //- Output: Decrypted Blob. + //'''); + + //Log.d("RSA Private Key: $rsaPrivateKeyString"); + + PrivateKey rsaPrivateKey = RsaKeyHelper.parsePrivateKeyFromPem(rsaPrivateKeyString); + String decryptedKey = RSACrypt.decrypt(encryptedKey, rsaPrivateKey); + //Log.d("Decrypted Key: $decryptedKey"); + + String decryptedBlob = AESCrypt.decrypt(encryptedBlob, decryptedKey); + //Log.d("Decrypted Blob: $decryptedBlob"); + + String status = (plainBlob == decryptedBlob) ? "Test Succeeded" : "Test Failed"; + //Log.d("$status"); + + Log.d(''' + Health Service Provider: + - Input: Plain Blob, RSA Public Key; + - Output: Ecrypted Blob, Ecrypted Key. + + Plain Blob: $plainBlob + + RSA Public Key: $rsaPublicKeyString + + Random AES Key: $aesKey + + Ecrypted Blob: $encryptedBlob + + Ecrypted Key: $encryptedKey + + Client Processing: + - Input: RSA Private Key, Ecrypted Blob, Ecrypted Key; + - Output: Decrypted Blob; + + RSA Private Key: $rsaPrivateKeyString + + Decrypted Key: $decryptedKey + + Decrypted Blob: $decryptedBlob + + $status + '''); + } +} + +class AESCrypt { + + static const int kCCBlockSizeAES128 = 16; + + static String encrypt(String plainText, String keyString, { Encrypt.AESMode mode = Encrypt.AESMode.cbc, String padding = 'PKCS7' }) { + try { + final key = Encrypt.Key.fromUtf8(keyString); + final iv = Encrypt.IV.fromLength(keyString.length); + final encrypter = Encrypt.Encrypter(Encrypt.AES(key, mode: mode, padding: padding)); + return encrypter.encrypt(plainText, iv: iv).base64; + } + catch(e) { print(e.toString()); } + return null; + } + + static String decrypt(String cipherBase64, String keyString, { Encrypt.AESMode mode = Encrypt.AESMode.cbc, String padding = 'PKCS7' }) { + try { + final key = Encrypt.Key.fromUtf8(keyString); + final iv = Encrypt.IV.fromLength(keyString.length); + final encrypter = Encrypt.Encrypter(Encrypt.AES(key, mode: mode, padding: padding)); + return encrypter.decrypt(Encrypt.Encrypted.fromBase64(cipherBase64), iv: iv); + } + catch(e) { print(e.toString()); } + return null; + } + + static String decode(String base64Data, { Encrypt.AESMode mode = Encrypt.AESMode.cbc, String padding = 'PKCS7' }) { + var data; + try { data = (base64Data != null) ? base64Decode(base64Data) : null; } + catch (e) { print(e?.toString()); } + if ((data != null) && (data.length > kCCBlockSizeAES128)) { + try { + var keyData = data.sublist(0, kCCBlockSizeAES128); + var encryptedData = data.sublist(kCCBlockSizeAES128); + + final keyString = String.fromCharCodes(keyData); + final key = Encrypt.Key.fromUtf8(keyString); + final iv = Encrypt.IV.fromLength(kCCBlockSizeAES128); + final encrypter = Encrypt.Encrypter(Encrypt.AES(key, mode: mode, padding: padding)); + + return encrypter.decrypt(Encrypt.Encrypted(encryptedData), iv: iv); + } + catch(e) { print(e.toString()); } + } + return null; + } + + static String encode(String dataString, { String keyString, Encrypt.AESMode mode = Encrypt.AESMode.cbc, String padding = 'PKCS7' }) { + try { + final keyString2 = (keyString != null) ? keyString : randomKey(); + final key = Encrypt.Key.fromUtf8(keyString2); + final iv = Encrypt.IV.fromLength(kCCBlockSizeAES128); + final encrypter = Encrypt.Encrypter(Encrypt.AES(key, mode: mode, padding: padding)); + + Uint8List encryptedJson = encrypter.encrypt(dataString, iv: iv).bytes; + + List list = List(); + list.addAll(keyString2.codeUnits); + list.addAll(encryptedJson); + Uint8List data = Uint8List.fromList(list); + + return base64Encode(data); + } + catch(e) { print(e.toString()); } + return null; + } + + static String randomKey({ int keySize = kCCBlockSizeAES128 }) { + var rand = new Random(); + var codeUnits = List.generate(keySize, (index) { + return rand.nextInt(33) + 89; // rand.nextInt(255); + }); + return new String.fromCharCodes(codeUnits); + } +} + +class RSACrypt { + + static String encrypt(String plainText, PublicKey publicKey) { + try { + final encrypter = Encrypt.Encrypter(Encrypt.RSA(publicKey: publicKey, privateKey: null)); + return encrypter.encrypt(plainText).base64; + } + catch(e) { print(e.toString()); } + return null; + } + + static String decrypt(String cipherBase64, PrivateKey privateKey) { + try { + final encrypter = Encrypt.Encrypter(Encrypt.RSA(publicKey: null, privateKey: privateKey)); + return encrypter.decrypt(Encrypt.Encrypted.fromBase64(cipherBase64)); + } + catch(e) { print(e.toString()); } + return null; + } +} + +/// Helper class to handle RSA key generation and encoding +class RsaKeyHelper { + + /// Generate a [PublicKey] and [PrivateKey] pair + /// + /// Returns a [AsymmetricKeyPair] based on the [RSAKeyGenerator] with custom parameters, + /// including a [SecureRandom] + static Future> computeRSAKeyPair(SecureRandom secureRandom) async { + return await compute(getRsaKeyPair, secureRandom); + } + + /// Generates a [SecureRandom] + /// + /// Returns [FortunaRandom] to be used in the [AsymmetricKeyPair] generation + static SecureRandom getSecureRandom() { + var secureRandom = FortunaRandom(); + var random = Random.secure(); + List seeds = []; + for (int i = 0; i < 32; i++) { + seeds.add(random.nextInt(255)); + } + secureRandom.seed(new KeyParameter(new Uint8List.fromList(seeds))); + return secureRandom; + } + + /// Decode Public key from PEM Format + /// + /// Given a base64 encoded PEM [String] with correct headers and footers, return a + /// [RSAPublicKey] + /// + /// *PKCS1* + /// RSAPublicKey ::= SEQUENCE { + /// modulus INTEGER, -- n + /// publicExponent INTEGER -- e + /// } + /// + /// *PKCS8* + /// PublicKeyInfo ::= SEQUENCE { + /// algorithm AlgorithmIdentifier, + /// PublicKey BIT STRING + /// } + /// + /// AlgorithmIdentifier ::= SEQUENCE { + /// algorithm OBJECT IDENTIFIER, + /// parameters ANY DEFINED BY algorithm OPTIONAL + /// } + static RSAPublicKey parsePublicKeyFromPem(pemString) { + return (pemString != null) ? parsePublicKeyFromPemData(_decodePEM(pemString)) : null; + } + + static RSAPublicKey parsePublicKeyFromPemData(Uint8List pemData) { + if (pemData == null) { + return null; + } + + var asn1Parser = new ASN1Parser(pemData); + var topLevelSeq = asn1Parser.nextObject() as ASN1Sequence; + + var modulus, exponent; + // Depending on the first element type, we either have PKCS1 or 2 + if (topLevelSeq.elements[0].runtimeType == ASN1Integer) { + modulus = topLevelSeq.elements[0] as ASN1Integer; + exponent = topLevelSeq.elements[1] as ASN1Integer; + } else { + var publicKeyBitString = topLevelSeq.elements[1]; + + var publicKeyAsn = new ASN1Parser(publicKeyBitString.contentBytes()); + ASN1Sequence publicKeySeq = publicKeyAsn.nextObject(); + modulus = publicKeySeq.elements[0] as ASN1Integer; + exponent = publicKeySeq.elements[1] as ASN1Integer; + } + + RSAPublicKey rsaPublicKey = RSAPublicKey(modulus.valueAsBigInteger, exponent.valueAsBigInteger); + + return rsaPublicKey; + } + + /// Sign plain text with Private Key + /// + /// Given a plain text [String] and a [RSAPrivateKey], decrypt the text using + /// a [RSAEngine] cipher + static String sign(String plainText, RSAPrivateKey privateKey) { + var signer = RSASigner(SHA256Digest(), "0609608648016503040201"); + signer.init(true, PrivateKeyParameter(privateKey)); + return base64Encode(signer.generateSignature(_createUint8ListFromString(plainText)).bytes); + } + + + /// Creates a [Uint8List] from a string to be signed + static Uint8List _createUint8ListFromString(String s) { + var codec = Utf8Codec(allowMalformed: true); + return Uint8List.fromList(codec.encode(s)); + } + + /// Decode Private key from PEM Format + /// + /// Given a base64 encoded PEM [String] with correct headers and footers, return a + /// [RSAPrivateKey] + static RSAPrivateKey parsePrivateKeyFromPem(pemString) { + return (pemString != null) ? parsePrivateKeyFromPemData(_decodePEM(pemString)) : null; + } + + static RSAPrivateKey parsePrivateKeyFromPemData(Uint8List pemData) { + if (pemData == null) { + return null; + } + + var asn1Parser = new ASN1Parser(pemData); + var topLevelSeq = asn1Parser.nextObject() as ASN1Sequence; + + var modulus, privateExponent, p, q; + // Depending on the number of elements, we will either use PKCS1 or PKCS8 + if (topLevelSeq.elements.length == 3) { + var privateKey = topLevelSeq.elements[2]; + + asn1Parser = new ASN1Parser(privateKey.contentBytes()); + var pkSeq = asn1Parser.nextObject() as ASN1Sequence; + + modulus = pkSeq.elements[1] as ASN1Integer; + privateExponent = pkSeq.elements[3] as ASN1Integer; + p = pkSeq.elements[4] as ASN1Integer; + q = pkSeq.elements[5] as ASN1Integer; + } else { + modulus = topLevelSeq.elements[1] as ASN1Integer; + privateExponent = topLevelSeq.elements[3] as ASN1Integer; + p = topLevelSeq.elements[4] as ASN1Integer; + q = topLevelSeq.elements[5] as ASN1Integer; + } + + RSAPrivateKey rsaPrivateKey = RSAPrivateKey( + modulus.valueAsBigInteger, + privateExponent.valueAsBigInteger, + p.valueAsBigInteger, + q.valueAsBigInteger); + + return rsaPrivateKey; + } + + static Uint8List _decodePEM(String pem) { + return base64.decode(_removePemHeaderAndFooter(pem)); + } + + static String _removePemHeaderAndFooter(String pem) { + var startsWith = [ + "-----BEGIN PUBLIC KEY-----", + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN RSA PUBLIC KEY-----", + "-----BEGIN PRIVATE KEY-----", + "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\nVersion: React-Native-OpenPGP.js 0.1\r\nComment: http://openpgpjs.org\r\n\r\n", + "-----BEGIN PGP PRIVATE KEY BLOCK-----\r\nVersion: React-Native-OpenPGP.js 0.1\r\nComment: http://openpgpjs.org\r\n\r\n", + ]; + var endsWith = [ + "-----END PUBLIC KEY-----", + "-----END PRIVATE KEY-----", + "-----END RSA PRIVATE KEY-----", + "-----END RSA PUBLIC KEY-----", + "-----END PGP PUBLIC KEY BLOCK-----", + "-----END PGP PRIVATE KEY BLOCK-----", + ]; + bool isOpenPgp = pem.indexOf('BEGIN PGP') != -1; + + pem = pem.replaceAll(' ', ''); + pem = pem.replaceAll('\n', ''); + pem = pem.replaceAll('\r', ''); + + for (var s in startsWith) { + s = s.replaceAll(' ', ''); + if (pem.startsWith(s)) { + pem = pem.substring(s.length); + } + } + + for (var s in endsWith) { + s = s.replaceAll(' ', ''); + if (pem.endsWith(s)) { + pem = pem.substring(0, pem.length - s.length); + } + } + + if (isOpenPgp) { + var index = pem.indexOf('\r\n'); + pem = pem.substring(0, index); + } + + return pem; + } + + /// Encode Private key to PEM Format + /// + /// Given [RSAPrivateKey] returns a base64 encoded [String] with standard PEM headers and footers + static String encodePrivateKeyToPemPKCS1(RSAPrivateKey privateKey) { + if (privateKey == null) { + return null; + } + Uint8List dataBytes = encodePrivateKeyToPEMDataPKCS1(privateKey); + var dataBase64 = base64.encode(dataBytes); + return """-----BEGIN PRIVATE KEY-----\r\n$dataBase64\r\n-----END PRIVATE KEY-----"""; + } + + static Uint8List encodePrivateKeyToPEMDataPKCS1(RSAPrivateKey privateKey) { + if (privateKey == null) { + return null; + } + + var topLevel = new ASN1Sequence(); + + var version = ASN1Integer(BigInt.from(0)); + var modulus = ASN1Integer(privateKey.n); + var publicExponent = ASN1Integer(privateKey.exponent); + var privateExponent = ASN1Integer(privateKey.d); + var p = ASN1Integer(privateKey.p); + var q = ASN1Integer(privateKey.q); + var dP = privateKey.d % (privateKey.p - BigInt.from(1)); + var exp1 = ASN1Integer(dP); + var dQ = privateKey.d % (privateKey.q - BigInt.from(1)); + var exp2 = ASN1Integer(dQ); + var iQ = privateKey.q.modInverse(privateKey.p); + var co = ASN1Integer(iQ); + + topLevel.add(version); + topLevel.add(modulus); + topLevel.add(publicExponent); + topLevel.add(privateExponent); + topLevel.add(p); + topLevel.add(q); + topLevel.add(exp1); + topLevel.add(exp2); + topLevel.add(co); + + return topLevel.encodedBytes; + } + + /// Encode Public key to PEM Format + /// + /// Given [RSAPublicKey] returns a base64 encoded [String] with standard PEM headers and footers + static String encodePublicKeyToPemPKCS1(RSAPublicKey publicKey) { + if (publicKey == null) { + return null; + } + + Uint8List pemData = encodePublicKeyToPemDataPKCS1(publicKey); + var dataBase64 = base64.encode(pemData); + return """-----BEGIN PUBLIC KEY-----\r\n$dataBase64\r\n-----END PUBLIC KEY-----"""; + } + + static Uint8List encodePublicKeyToPemDataPKCS1(RSAPublicKey publicKey) { + if (publicKey == null) { + return null; + } + + var topLevel = new ASN1Sequence(); + + topLevel.add(ASN1Integer(publicKey.modulus)); + topLevel.add(ASN1Integer(publicKey.exponent)); + + return topLevel.encodedBytes; + } + + /// Generate a [PublicKey] and [PrivateKey] pair + /// + /// Returns a [AsymmetricKeyPair] based on the [RSAKeyGenerator] with custom parameters, + /// including a [SecureRandom] + static AsymmetricKeyPair getRsaKeyPair(SecureRandom secureRandom) { + var rsapars = new RSAKeyGeneratorParameters(BigInt.from(65537), 2048, 5); + var params = new ParametersWithRandom(rsapars, secureRandom); + var keyGenerator = new RSAKeyGenerator(); + keyGenerator.init(params); + return keyGenerator.generateKeyPair(); + } + + /// Verify a [PublicKey] and [PrivateKey] pair + /// + /// Returns a boolean based on the whether [AsymmetricKeyPair] is paired or not, + static Future verifyRsaKeyPair(AsymmetricKeyPair rsaKeyPair) async { + return await compute(_verifyRSAKeyPair, rsaKeyPair); + } +} + +bool _verifyRSAKeyPair(AsymmetricKeyPair rsaKeyPair) { + PublicKey rsaPublicKey = rsaKeyPair?.publicKey; + PrivateKey rsaPrivateKey = rsaKeyPair?.privateKey; + if ((rsaPublicKey != null) && (rsaPrivateKey != null)) { + String aesKey = AESCrypt.randomKey(); + if (aesKey != null) { + String encryptedAESKey = RSACrypt.encrypt(aesKey, rsaPublicKey); + if (encryptedAESKey != null) { + String decryptedAESKey = RSACrypt.decrypt(encryptedAESKey, rsaPrivateKey); + return (decryptedAESKey == aesKey); + } + } + } + return null; +} \ No newline at end of file diff --git a/lib/utils/Utils.dart b/lib/utils/Utils.dart new file mode 100644 index 00000000..f0599dad --- /dev/null +++ b/lib/utils/Utils.dart @@ -0,0 +1,562 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui'; +import 'dart:math'; +import 'package:illinois/service/Styles.dart'; +import 'package:path/path.dart' as Path; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:illinois/service/Localization.dart'; +import 'package:illinois/service/Analytics.dart'; +import 'package:illinois/service/Config.dart'; +import 'package:illinois/service/Log.dart'; +import 'package:illinois/service/Network.dart'; + +class AppBytes{ + static Uint8List decodeBase64Bytes(String base64String){ + return base64Decode(base64String); + } +} + +class AppString { + + static bool isStringEmpty(String stringToCheck) { + return (stringToCheck == null || stringToCheck.isEmpty); + } + + static bool isStringNotEmpty(String stringToCheck) { + return !isStringEmpty(stringToCheck); + } + + static String getDefaultEmptyString({String value, String defaultValue = ''}) { + if (isStringEmpty(value)) { + return defaultValue; + } + return value; + } + + static String getMaskedPhoneNumber(String phoneNumber) { + if(AppString.isStringEmpty(phoneNumber)) { + return "*********"; + } + int phoneNumberLength = phoneNumber.length; + int lastXNumbers = min(phoneNumberLength, 4); + int starsCount = (phoneNumberLength - lastXNumbers); + String replacement = "*" * starsCount; + String maskedPhoneNumber = phoneNumber.replaceRange(0, starsCount, replacement); + return maskedPhoneNumber; + } + + static String capitalize(String value) { + if (value == null) { + return null; + } + else if (value.length == 0) { + return ''; + } + else if (value.length == 1) { + return value[0].toUpperCase(); + } + else { + return "${value[0].toUpperCase()}${value.substring(1).toLowerCase()}"; + } + } +} + +class AppCollection { + static bool isCollectionNotEmpty(Iterable collection) { + return collection != null && collection.isNotEmpty; + } + + static bool isCollectionEmpty(Iterable collection) { + return !isCollectionNotEmpty(collection); + } +} + +class AppVersion { + + static int compareVersions(String versionString1, String versionString2) { + List versionList1 = (versionString1 is String) ? versionString1.split('.') : []; + List versionList2 = (versionString2 is String) ? versionString2.split('.') : []; + int minLen = min(versionList1.length, versionList2.length); + for (int index = 0; index < minLen; index++) { + String s1 = versionList1[index], s2 = versionList2[index]; + int n1 = int.tryParse(s1), n2 = int.tryParse(s2); + int result = ((n1 != null) && (n2 != null)) ? n1.compareTo(n2) : s1.compareTo(s2); + if (result != 0) { + return result; + } + } + if (versionList1.length < versionList2.length) { + return -1; + } + else if (versionList1.length > versionList2.length) { + return 1; + } + else { + return 0; + } + } + + static bool matchVersions(String versionString1, String versionString2) { + List versionList1 = (versionString1 is String) ? versionString1.split('.') : []; + List versionList2 = (versionString2 is String) ? versionString2.split('.') : []; + int minLen = min(versionList1.length, versionList2.length); + for (int index = 0; index < minLen; index++) { + String s1 = versionList1[index], s2 = versionList2[index]; + int n1 = int.tryParse(s1), n2 = int.tryParse(s2); + int result = ((n1 != null) && (n2 != null)) ? n1.compareTo(n2) : s1.compareTo(s2); + if (result != 0) { + return false; + } + } + return true; + } + + static String majorVersion(String versionString, int versionsLength) { + if (versionString is String) { + List versionList = versionString.split('.'); + if (versionsLength < versionList.length) { + versionList = versionList.sublist(0, versionsLength); + } + return versionList.join('.'); + } + return null; + } +} + +class AppUrl { + + static String getScheme(String url) { + try { + Uri uri = (url != null) ? Uri.parse(url) : null; + return (uri != null) ? uri.scheme : null; + } catch(e) {} + return null; + } + + static String getExt(String url) { + try { + Uri uri = (url != null) ? Uri.parse(url) : null; + String path = (uri != null) ? uri.path : null; + return (path != null) ? Path.extension(path) : null; + } catch(e) {} + return null; + } + + static bool isPdf(String url) { + return (getExt(url) == '.pdf'); + } + + static bool isWebScheme(String url) { + String scheme = getScheme(url); + return (scheme == 'http') || (scheme == 'https'); + } + + static bool launchInternal(String url) { + return AppUrl.isWebScheme(url) && !(Platform.isAndroid && AppUrl.isPdf(url)); + } +} + +class AppLocation { + static final double defaultLocationLat = 40.096230; + static final double defaultLocationLng = -88.235899; + static final int defaultLocationRadiusInMeters = 1000; + + static double distance(double lat1, double lon1, double lat2, double lon2) { + double theta = lon1 - lon2; + double dist = sin(deg2rad(lat1)) + * sin(deg2rad(lat2)) + + cos(deg2rad(lat1)) + * cos(deg2rad(lat2)) + * cos(deg2rad(theta)); + dist = acos(dist); + dist = rad2deg(dist); + dist = dist * 60 * 1.1515; + return (dist); + } + + static double deg2rad(double deg) { + return (deg * pi / 180.0); + } + + static double rad2deg(double rad) { + return (rad * 180.0 / pi); + } +} + +class AppJson { + + static List encodeList(List items) { + List result = new List(); + if (items != null && items.isNotEmpty) { + items.forEach((item) { + result.add(item.toJson()); + }); + } + + return result; + } + + static List castToStringList(List items) { + if (items == null) + return null; + + List result = new List(); + if (items != null && items.isNotEmpty) { + items.forEach((item) { + result.add(item is String ? item : item.toString()); + }); + } + + return result; + } + + static String encode(dynamic value) { + String result; + if (value != null) { + try { + result = json.encode(value); + } catch (e) { + Log.e(e?.toString()); + } + } + return result; + } + + // TBD: Use everywhere decodeMap or decodeList to guard type cast + static dynamic decode(String jsonString) { + dynamic jsonContent; + if (AppString.isStringNotEmpty(jsonString)) { + try { + jsonContent = json.decode(jsonString); + } catch (e) { + Log.e(e?.toString()); + } + } + return jsonContent; + } + + static List decodeList(String jsonString) { + try { + return (decode(jsonString) as List)?.cast(); + } catch (e) { + print(e?.toString()); + return null; + } + } + + static Map decodeMap(String jsonString) { + try { + return (decode(jsonString) as Map)?.cast(); + } catch (e) { + print(e?.toString()); + return null; + } + } + + static String stringValue(dynamic value) { + if (value is String) { + return value; + } + else if (value != null) { + try { return value.toString(); } + catch(e) { print(e?.toString()); } + } + return null; + } + + static int intValue(dynamic value) { + return (value is int) ? value : null; + } + + static bool boolValue(dynamic value) { + return (value is bool) ? value : null; + } + + static double doubleValue(dynamic value) { + if (value is double) { + return value; + } + else if (value is int) { + return value.toDouble(); + } + else if (value is String) { + return double.tryParse(value); + } + else { + return null; + } + } +} + +class AppToast { + static void show(String msg) { + Fluttertoast.showToast( + msg: msg, + textColor: Colors.white, + toastLength: Toast.LENGTH_LONG, + timeInSecForIos: 3, + gravity: ToastGravity.BOTTOM, + backgroundColor: Styles().colors.blackTransparent06, + ); + } +} + +class AppAlert { + static Future showDialogResult( + BuildContext builderContext, String message) async { + if(builderContext != null) { + bool alertDismissed = await showDialog( + context: builderContext, + builder: (context) { + return AlertDialog( + content: Text(message), + actions: [ + FlatButton( + child: Text(Localization().getStringEx("dialog.ok.title", "OK")), + onPressed: () { + Analytics.instance.logAlert(text: message, selection: "Ok"); + Navigator.pop(context, true); + } + ) //return dismissed 'true' + ], + ); + }, + ); + return alertDismissed; + } + return true; // dismissed + } + + static Future showCustomDialog( + {BuildContext context, Widget contentWidget, List actions, EdgeInsets contentPadding = const EdgeInsets.all(18), }) async { + bool alertDismissed = await showDialog( + context: context, + builder: (context) { + return AlertDialog(content: contentWidget, actions: actions,contentPadding: contentPadding,); + }, + ); + return alertDismissed; + } + + static Future showOfflineMessage(BuildContext context, String message) async { + return showDialog(context: context, builder: (context) { + return AlertDialog( + content: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(Localization().getStringEx("app.offline.message.title", "You appear to be offline"), style: TextStyle(fontSize: 18),), + Container(height:16), + Text(message, textAlign: TextAlign.center,), + ],), + actions: [ + FlatButton( + child: Text(Localization().getStringEx("dialog.ok.title", "OK")), + onPressed: (){ + Analytics.instance.logAlert(text: message, selection: "OK"); + Navigator.pop(context, true); + } + ) //return dismissed 'true' + ], + ); + },); + + } +} + +class AppMapPathKey { + static dynamic entry(Map map, dynamic key) { + if ((map != null) && (key != null)) { + if (key is String) { + return _pathKeyEntry(map, key); + } + else if (key is List) { + return _listKeyEntry(map, key); + } + } + return null; + } + + static dynamic _pathKeyEntry(Map map, String key) { + String field; + dynamic entry; + int position, start = 0; + Map source = map; + + while (0 <= (position = key.indexOf('.', start))) { + field = key.substring(start, position); + entry = source[field]; + if ((entry != null) && (entry is Map)) { + source = entry; + start = position + 1; + } + else { + break; + } + } + + if (0 < start) { + field = key.substring(start); + return source[field]; + } + else { + return source[key]; + } + } + + static dynamic _listKeyEntry(Map map, List keys) { + dynamic entry; + Map source = map; + for (dynamic key in keys) { + if (source == null) { + return null; + } + + entry = source[key]; + + if (entry != null) { + source = (entry is Map) ? entry : null; + } + else { + return null; + } + } + + return source ?? entry; + } + +} + +class AppSemantics { + static void announceCheckBoxStateChange(BuildContext context, bool checked, String name){ + if(context!=null) { + String message = (AppString.isStringNotEmpty(name)?name+", " :"")+ + (checked ? + Localization().getStringEx("toggle_button.status.checked", "checked",) : + Localization().getStringEx("toggle_button.status.unchecked", "unchecked")); + + context.findRenderObject().sendSemanticsEvent(AnnounceSemanticsEvent(message,TextDirection.ltr)); // !toggled because we announce before it got changed + } + } +} + +class AppImage { + static Map getAuthImageHeaders() { + Map headers; + String rokwireApiKey = Config().rokwireApiKey; + if (AppString.isStringNotEmpty(rokwireApiKey)) { + headers = Map(); + headers[Network.RokwireApiKey] = rokwireApiKey; + } + return headers; + } + + static MemoryImage memoryImageWithBytes( Uint8List bytes){ + if(AppCollection.isCollectionNotEmpty(bytes)) { + return MemoryImage(bytes); + } + return null; + } +} + +class AppDeviceOrientation { + + static DeviceOrientation fromStr(String value) { + switch (value) { + case 'portraitUp': return DeviceOrientation.portraitUp; + case 'portraitDown': return DeviceOrientation.portraitDown; + case 'landscapeLeft': return DeviceOrientation.landscapeLeft; + case 'landscapeRight': return DeviceOrientation.landscapeRight; + } + return null; + } + + static String toStr(DeviceOrientation value) { + switch(value) { + case DeviceOrientation.portraitUp: return "portraitUp"; + case DeviceOrientation.portraitDown: return "portraitDown"; + case DeviceOrientation.landscapeLeft: return "landscapeLeft"; + case DeviceOrientation.landscapeRight: return "landscapeRight"; + } + return null; + } + + static List fromStrList(List stringsList) { + + List orientationsList; + if (stringsList != null) { + orientationsList = List(); + for (dynamic string in stringsList) { + if (string is String) { + DeviceOrientation orientation = fromStr(string); + if (orientation != null) { + orientationsList.add(orientation); + } + } + } + } + return orientationsList; + } + + static List toStrList(List orientationsList) { + + List stringsList; + if (orientationsList != null) { + stringsList = List(); + for (DeviceOrientation orientation in orientationsList) { + String orientationString = toStr(orientation); + if (orientationString != null) { + stringsList.add(orientationString); + } + } + } + return stringsList; + } + +} + +class AppGeometry { + + static Size scaleSizeToFit(Size size, Size boundsSize) { + double fitW = boundsSize.width; + double fitH = boundsSize.height; + double ratioW = (0.0 < boundsSize.width) ? (size.width / boundsSize.width) : double.maxFinite; + double ratioH = (0.0 < boundsSize.height) ? (size.height / boundsSize.height) : double.maxFinite; + if(ratioW < ratioH) + fitW = (0.0 < size.height) ? (size.width * boundsSize.height / size.height) : boundsSize.width; + else if(ratioH < ratioW) + fitH = (0.0 < size.width) ? (size.height * boundsSize.width / size.width) : boundsSize.height; + return Size(fitW, fitH); + } + + static Size scaleSizeToFill(Size size, Size boundsSize) { + double fitW = boundsSize.width; + double fitH = boundsSize.height; + double ratioW = (0.0 < boundsSize.width) ? (size.width / boundsSize.width) : double.maxFinite; + double ratioH = (0.0 < boundsSize.height) ? (size.height / boundsSize.height) : double.maxFinite; + if(ratioW < ratioH) + fitH = (0.0 < size.width) ? (size.height * boundsSize.width / size.width) : boundsSize.height; + else if(ratioH < ratioW) + fitW = (0.0 < size.height) ? (size.width * boundsSize.height / size.height) : boundsSize.width; + return Size(fitW, fitH); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 00000000..a9b97def --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,640 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.15" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.1" + barcode_scan: + dependency: "direct main" + description: + name: barcode_scan + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.3" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + collection: + dependency: "direct main" + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.14.12" + connectivity: + dependency: "direct main" + description: + name: connectivity + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.5+1" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.1" + device_info: + dependency: "direct main" + description: + name: device_info + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.2+4" + dotted_border: + dependency: "direct main" + description: + name: dotted_border + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + encrypt: + dependency: "direct main" + description: + name: encrypt + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.1" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.1" + firebase: + dependency: transitive + description: + name: firebase + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.3+3" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+2" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.2+5" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.9" + firebase_ml_vision: + dependency: "direct main" + description: + name: firebase_ml_vision + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.4" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + url: "https://pub.dartlang.org" + source: hosted + version: "0.11.1" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.8" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.4+3" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_native_timezone: + dependency: "direct main" + description: + name: flutter_native_timezone + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + flutter_page_indicator: + dependency: transitive + description: + name: flutter_page_indicator + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.3" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.8" + flutter_swiper: + dependency: "direct main" + description: + name: flutter_swiper + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0+3" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.2" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.12" + image_picker: + dependency: "direct main" + description: + name: image_picker + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.7+4" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.1" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.1+1" + location: + dependency: "direct main" + description: + name: location + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.5" + logger: + dependency: "direct main" + description: + name: logger + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0+2" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.6" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.8" + mime_type: + dependency: "direct main" + description: + name: mime_type + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.4" + package_info: + dependency: "direct main" + description: + name: package_info + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.4" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.11" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+2" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4+3" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0+1" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + pointycastle: + dependency: transitive + description: + name: pointycastle + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.13" + qr: + dependency: transitive + description: + name: qr + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.3+5" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + sprintf: + dependency: "direct main" + description: + name: sprintf + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2+1" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.15" + timezone: + dependency: "direct main" + description: + name: timezone + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.7" + transformer_page_view: + dependency: transitive + description: + name: transformer_page_view + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.6" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + uni_links: + dependency: "direct main" + description: + name: uni_links + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "5.4.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0+2" + uuid: + dependency: "direct main" + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.22+1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "3.6.1" +sdks: + dart: ">=2.8.0 <3.0.0" + flutter: ">=1.12.13+hotfix.5 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..c6a54eb4 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,145 @@ +name: illinois +description: Illinois client application. + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 2.3.4+204 + +environment: + sdk: ">=2.2.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + shared_preferences: ^0.5.3+5 + flutter_swiper: ^1.1.6 + mime_type: ^0.2.4 + location: ^2.3.5 + uni_links: ^0.2.0 + url_launcher: ^5.2.3 + fluttertoast: ^3.1.3 + #Firebase + firebase_core: ^0.4.3+3 + firebase_messaging: ^6.0.9 + firebase_crashlytics: ^0.1.2+5 + firebase_ml_vision: ^0.9.4 + # End Firebase + logger: ^0.7.0+2 + flutter_local_notifications: ^0.8.4 + sprintf: ^4.0.2 + sqflite: ^1.1.7+1 + encrypt: ^3.3.1 + package_info: ^0.4.0+9 + device_info: ^0.4.0+4 + connectivity: ^0.4.5+1 + collection: ^1.14.11 + uuid: ^2.0.2 + image_picker: ^0.6.7+4 + timezone: ^0.5.5 + flutter_native_timezone: ^1.0.4 + path_provider: ^1.3.1 + cookie_jar: ^1.0.0 + flutter_html: ^0.11.0 + qr_flutter: ^3.1.0 + webview_flutter: ^0.3.19+7 + dotted_border: ^1.0.5 + flutter_image_compress: ^0.6.8 + barcode_scan: 2.0.2 # Explicitly use older version of the plugin. The latest one does not work! + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://www.dartlang.org/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - images/ + - assets/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + fonts: + - family: ProximaNovaBlack + fonts: + - asset: fonts/proximanova-black.otf + - family: ProximaNovaBlackIt + fonts: + - asset: fonts/proximanova-blackit.otf + - family: ProximaNovaBold + fonts: + - asset: fonts/proximanova-bold.otf + - family: ProximaNovaBoldIt + fonts: + - asset: fonts/proximanova-boldit.otf + - family: ProximaNovaExtraBold + fonts: + - asset: fonts/proximanova-extrabold.otf + - family: ProximaNovaExtraBoldIt + fonts: + - asset: fonts/proximanova-extraboldit.otf + - family: ProximaNovaLight + fonts: + - asset: fonts/proximanova-light.otf + - family: ProximaNovaLightIt + fonts: + - asset: fonts/proximanova-lightit.otf + - family: ProximaNovaMedium + fonts: + - asset: fonts/proximanova-medium.otf + - family: ProximaNovaMediumIt + fonts: + - asset: fonts/proximanova-mediumit.otf + - family: ProximaNovaRegular + fonts: + - asset: fonts/proximanova-regular.otf + - family: ProximaNovaRegularIt + fonts: + - asset: fonts/proximanova-regularit.otf + - family: ProximaNovaSemiBold + fonts: + - asset: fonts/proximanova-semibold.otf + - family: ProximaNovaSemiBoldIt + fonts: + - asset: fonts/proximanova-semiboldit.otf + - family: ProximaNovaThin + fonts: + - asset: fonts/proximanova-thin.otf + - family: ProximaNovaThinIt + fonts: + - asset: fonts/proximanova-thinit.otf + + + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 00000000..130532e8 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,127 @@ +#!/bin/bash +help() { + echo "" + echo "SETUP" + echo "Install qrencode: brew install qrencode" + echo "" + echo "Usage: ./scripts/build.sh {BRAND} {ENV} {PLATFORM}" + echo "{BRAND} brand name | Default: Illinois if the param is missing or empty" + echo "{ENV} Environment name | Default: dev" + echo "{PLATFORM} Target platform. Values: all|ios|android Default: all" + echo "" + echo "" +} + +help + +CURRENT_DIR=$(PWD) +FILE_PUBSPEC=$(cat pubspec.yaml) + +BRAND="$1" +ENV="$2" +PLATFORM="$3" + + +if [ -z "$BRAND" ] +then + BRAND="Illinois" + echo "The BRAND param is empty. Use default value: $BRAND" +fi +if [ -z "$ENV" ] +then + ENV="dev" + echo "The ENV param is empty. Use default value: $ENV" +fi +if [ -z "$PLATFORM" ] +then + PLATFORM="all" + echo "The PLATFORM param is empty. Use default value: $PLATFORM" +fi + +cd ios +BUNDLE_ID=$(xcodebuild -showBuildSettings | grep PRODUCT_BUNDLE_IDENTIFIER | awk -F ' = ' '{print $2}') +cd .. + +TEMPLATE_BRAND="{{BRAND}}" +TEMPLATE_VERSION="{{VERSION}}" +TEMPLATE_ENV="{{ENV}}" +TEMPLATE_BUNDLE_ID="{{BUNDLE_ID}}" +BUILD_DIR="${CURRENT_DIR}/build" +OUTPUT_DIR="${BUILD_DIR}/_output" +APK_BUILD_PATH="${BUILD_DIR}/app/outputs/apk/release/app-release.apk" +VERSION=$(grep "version:" pubspec.yaml | sed -e 's/.* //' | sed -e 's/+.*//') +APK_OUT_PATH="${OUTPUT_DIR}/$BRAND-$VERSION-$ENV.apk" +IPA_OUT_PATH="${OUTPUT_DIR}/$BRAND-$VERSION-$ENV.ipa" +QR_BUILD_PATH="${OUTPUT_DIR}/$BRAND-$VERSION-$ENV.png" +PLIST_TEMPLATE_PATH="$CURRENT_DIR/scripts/templates/template.plist" +PLIST_BUILD_PATH="$OUTPUT_DIR/$BRAND-$VERSION-$ENV.plist" + +echo $PLIST_BUILD_PATH +QR_PLIST_URL="itms-services://?action=download-manifest&url=https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Installs/$BRAND-$VERSION-$ENV.plist" + +echo "Cleaning the build environment." +rm -rf $OUTPUT_DIR +flutter clean + +flutter doctor +flutter pub get + +if [ -d "$OUTPUT_DIR" ]; then + echo "Output dir: ${OUTPUT_DIR} " +else + mkdir -p ${OUTPUT_DIR} + echo "Output dir: ${OUTPUT_DIR} created" +fi + +echo "Building version: ${VERSION}" + +if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "android" ]; then + echo "Building APK..." + flutter build apk + + if [ -f "$APK_BUILD_PATH" ]; then + cp $APK_BUILD_PATH $APK_OUT_PATH + echo "Copy to $APK_OUT_PATH" + fi +fi + +if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "ios" ]; then + + echo "Generating QR image..." + qrencode -s 10 -d 300 -o $QR_BUILD_PATH "$QR_PLIST_URL" + + echo "Generating iOS plist..." + cp $PLIST_TEMPLATE_PATH $PLIST_BUILD_PATH + sed -i '' "s/$TEMPLATE_VERSION/$VERSION/g" $PLIST_BUILD_PATH + sed -i '' "s/$TEMPLATE_BRAND/$BRAND/g" $PLIST_BUILD_PATH + sed -i '' "s/$TEMPLATE_ENV/$ENV/g" $PLIST_BUILD_PATH + sed -i '' "s/$TEMPLATE_BUNDLE_ID/$BUNDLE_ID/g" $PLIST_BUILD_PATH + + flutter build ios + + cd ios + xcodebuild -workspace Runner.xcworkspace -scheme Runner -archivePath ../build/_output/tmp/Runner.xcarchive archive + xcodebuild -exportArchive -archivePath ../build/_output/tmp/Runner.xcarchive -exportPath ../build/_output/tmp/ -exportOptionsPlist ../build/_output/$BRAND-$VERSION-$ENV.plist + cd .. + cp ./build/_output/tmp/Runner.ipa ./build/_output/$BRAND-$VERSION-$ENV.ipa + rm -rf ./build/_output/tmp/ +fi + + + +echo "########################################################################" +echo "Generated URLs for the report" +if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "android" ]; then + echo "Android:" + echo "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Installs/$BRAND-$VERSION-$ENV.apk" + echo "" +fi +if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "ios" ]; then +echo "iOS:" +echo "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Installs/$BRAND-$VERSION-$ENV.ipa" +echo "itms-services://?action=download-manifest&url=https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Installs/$BRAND-$VERSION-$ENV.plist" +echo "https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Installs/$BRAND-$VERSION-$ENV.png" +echo "" +fi +echo "TODO2: Upload to S3 through command line!" +echo -ne '\007' \ No newline at end of file diff --git a/scripts/templates/template.plist b/scripts/templates/template.plist new file mode 100644 index 00000000..2dfa4f54 --- /dev/null +++ b/scripts/templates/template.plist @@ -0,0 +1,43 @@ + + + + + items + + + assets + + + kind + software-package + url + https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Installs/{{BRAND}}-{{VERSION}}-{{ENV}}.ipa + + + kind + display-image + url + https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Installs/{{BRAND}}-57.png + + + kind + full-size-image + url + https://rokwire-ios-beta.s3.us-east-2.amazonaws.com/Installs/{{BRAND}}-512.png + + + metadata + + bundle-identifier + {{BUNDLE_ID}} + bundle-version + {{VERSION}} + kind + software + title + Illinois + + + + + diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 00000000..ad608597 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Board of Trustees of the University of Illinois. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:illinois/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(App()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}