From 7ca6fcd79a62a44b1942350e9e6bd402c0d0a583 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Tue, 12 Sep 2023 00:47:17 -0600 Subject: [PATCH 001/135] Updated README 1. Updated README like @JGreenlee mentioned to successfully build 2. Maintaining current version 3. Will update again after updating JDK in this intel machine --- README.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0c02868db..06d860d0c 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,11 @@ Installing (one time only) --- Run the setup script for the platform you want to build +Make sure you switch to the "label_dashboard_profile_sept_2023" branch +``` +git checkout label_dashboard_profile_sept_2023 +``` + ``` $ bash setup/setup_android_native.sh AND/OR @@ -156,14 +161,6 @@ $ cp www/json/startupConfig.json.sample www/json/startupConfig.json $ cp ..... www/json/connectionConfig.json ``` -### Activation (after install, and in every new shell) - -``` -$ source setup/activate_native.sh -``` - -### Activation (after install, and in every new shell) - If connecting to a development server over http, make sure to turn on http support on android ``` @@ -174,10 +171,15 @@ If connecting to a development server over http, make sure to turn on http suppo ### Run in the emulator +Pick a version and execute the following: + ``` -$ npx cordova emulate ios -AND/OR -$ npx cordova emulate android +$ npm run +``` + +For instance: (build-dev-android) +``` +$ npm run build-dev-android ``` Creating logos From 6822c67466d48e5cc3059aa6003b7930e25e9095 Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Sat, 23 Sep 2023 13:13:44 -0600 Subject: [PATCH 002/135] multiple changes: 1. structure/ flow 2. added contents section 3. updated certain resources --- README.md | 301 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 185 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 06d860d0c..a14f99a01 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,60 @@ -e-mission phone app --------------------- +# e-mission phone app -This is the phone component of the e-mission system. +__This is the phone component of the e-mission system.__ -:sparkles: This has now been upgraded to cordova android@9.0.0 and iOS@6.0.1 ([details](https://github.com/e-mission/e-mission-docs/issues/554)). It has also been upgraded to [android API 29](https://github.com/e-mission/e-mission-phone/pull/707/), [cordova-lib@10.0.0 and the most recent node and npm versions](https://github.com/e-mission/e-mission-phone/pull/708)It also now supports CI, so we should not have any build issues in the future. The limitations from the [previous upgrade](https://github.com/e-mission/e-mission-docs/issues/519) have all been resolved. This should be ready to build out of the box, after all the configuration files are changed. +:sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to [android API 33 and the latest iOS versions](https://github.com/e-mission/e-mission-docs/issues/934), [cordova-lib@10.0.0 and the most recent node and npm versions](). It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ -Additional Documentation ---- +## Additional Documentation Additional documentation has been moved to its own repository [e-mission-docs](https://github.com/e-mission/e-mission-docs). Specific e-mission-phone wikis can be found here: https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -Updating the UI only +:sparkles: Check 6. Contributing if you're interested in contributing for this project :sparkles: + +## Contents +#### 1. [Creating logos](#1.-Creating-logos) -> Information regarding app Logo +#### 2. [Updating the UI only](#2.-Updating-the-UI-only) -> For UI changes ONLY +#### 3. [Updating the e-mission-* plugins or adding new plugins](#3.-Updating-the-e-mission-\*-plugins-or-adding-new-plugins) -> Work with native code +#### 4. [End to End Testing](#4.-End-to-End-Testing) +#### 5. [Beta-testing debugging](#5.-Beta-testing-debugging) +#### 6. [Contributing](#6.-Contributing) --- + +## 1. Creating logos + +If you are building your own version of the app, you must have your own logo to +avoid app store conficts. Updating the logo is very simple using the [`ionic +cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) +command. + +**Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work + +## 2. Updating the UI only [![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). ### Installing (one-time) -Run the setup script +1. Run the setup script ``` -$ bash setup/setup_serve.sh +bash setup/setup_serve.sh ``` - **(optional)** Configure by changing the files in `www/json`. Defaults are in `www/json/*.sample` ``` -$ ls www/json/*.sample -$ cp www/json/startupConfig.json.sample www/json/startupConfig.json -$ cp ..... www/json/connectionConfig.json +ls www/json/*.sample +cp www/json/startupConfig.json.sample www/json/startupConfig.json +cp ..... www/json/connectionConfig.json ``` ### Activation (after install, and in every new shell) - +2. Run this to activate ``` -$ source setup/activate_serve.sh +source setup/activate_serve.sh ``` ### Running @@ -46,7 +62,7 @@ $ source setup/activate_serve.sh 1. Start the phonegap deployment server and note the URL(s) that the server is listening to. ``` - $ npm run serve + npm run serve .... [phonegap] listening on 10.0.0.14:3000 [phonegap] listening on 192.168.162.1:3000 @@ -56,10 +72,10 @@ $ source setup/activate_serve.sh .... ``` -1. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" -1. The app will now display the version of e-mission app that is in your local directory - 1. The console logs will be displayed back in the server window (prefaced by `[console]`) - 1. Breakpoints can be added by connecting through the browser +2. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" +3. The app will now display the version of e-mission app that is in your local directory + 4. The console logs will be displayed back in the server window (prefaced by `[console]`) + 5. Breakpoints can be added by connecting through the browser - Safari ([enable develop menu](https://support.apple.com/guide/safari/use-the-safari-develop-menu-sfri20948/mac)): Develop -> Simulator -> index.html - Chrome: chrome://inspect -> Remote target (emulator) @@ -67,37 +83,68 @@ $ source setup/activate_serve.sh **Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. -End to end testing ---- -A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: +## 3. Updating the e-mission-\* plugins or adding new plugins -1. installing a local server, -2. running it, -3. loading it with test data, and -4. running analysis on it +[![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) +[![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) -are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). +__Important__ -In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +Most of the recent issues encountered have been due to incompatible setup. We +have now: +- locked down the dependencies, +- created setup and teardown scripts to setup self-contained environments with + those dependencies, and +- CI enabled to validate that they continue work. -One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. +If you have setup failures, please compare the configuration in the **passing CI +builds** with your configuration. That is almost certainly the source of the error. -Updating the e-mission-\* plugins or adding new plugins ---- -[![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) -[![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) +### Tested on +__MacOS__ +- Intel chip, MacOS Ventura 13.6 +- Intel chip, MacOS Ventura 13.5.2 +- Intel chip, MacOS Ventura 13.0 +- Intel chip, MacOS Monterey 12.6.7 Pre-requisites --- -- the version of xcode used by the CI +- The version of xcode used by the CI. - to install a particular version, use [xcode-select](https://www.unix.com/man-page/OSX/1/xcode-select/) - or this [supposedly easier to use repo](https://github.com/xcpretty/xcode-install) - **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - git -- Java 11. Tested with [OpenJDK 11 (Temurin) using AdoptOpenJDK](https://adoptopenjdk.net/releases.html). -- android SDK; install manually or use setup script below. Note that you only need to run this once **per computer**. +- Java 17. Tested with [OpenJDK 17 (Temurin) using AdoptOpenJDK](https://adoptium.net). +- if you are not on the most recent version of OSX: `homebrew` + - this allows us to install the current version of cocoapods without + running into ruby incompatibilities - e.g. + https://github.com/CocoaPods/CocoaPods/issues/11763 + +__1. Export statements__ +``` +export ANDROID_SDK_ROOT="/Users//Library/Android/sdk" +``` +``` +export ANDROID_HOME="/Users//Library/Android/sdk" +``` +aka the path where you want the SDK to be installed. + +To setup JAVA_HOME (after installing the latest JDK ), run this command: +``` +/usr/libexec/java_home +``` +Find the location of the Java installation (Default will look something like this:) +``` +/Library/Java/JavaVirtualMachines/... +``` +and then export the package as: +``` +export JAVA_HOME="" +``` + +- android SDK; install manually or use setup script below (**recommended**). Note that you only need to run this once **per computer**. ``` - $ bash setup/prereq_android_sdk_install.sh + bash setup/prereq_android_sdk_install.sh ```
Expected output @@ -120,45 +167,25 @@ Pre-requisites ```
-- if you are not on the most recent version of OSX, `homebrew` - - this allows us to install the current version of cocoapods without - running into ruby incompatibilities - e.g. - https://github.com/CocoaPods/CocoaPods/issues/11763 - -Important ---- -Most of the recent issues encountered have been due to incompatible setup. We -have now: -- locked down the dependencies, -- created setup and teardown scripts to setup self-contained environments with - those dependencies, and -- CI enabled to validate that they continue work. -If you have setup failures, please compare the configuration in the passing CI -builds with your configuration. That is almost certainly the source of the error. -Installing (one time only) ---- -Run the setup script for the platform you want to build +__2. Installing (one time only)__ -Make sure you switch to the "label_dashboard_profile_sept_2023" branch -``` -git checkout label_dashboard_profile_sept_2023 -``` +- Run the setup script for the platform you want to build ``` -$ bash setup/setup_android_native.sh +bash setup/setup_android_native.sh AND/OR -$ bash setup/setup_ios_native.sh +bash setup/setup_ios_native.sh ``` **(optional)** Configure by changing the files in `www/json`. Defaults are in `www/json/*.sample` ``` -$ ls www/json/*.sample -$ cp www/json/startupConfig.json.sample www/json/startupConfig.json -$ cp ..... www/json/connectionConfig.json +ls www/json/*.sample +cp www/json/startupConfig.json.sample www/json/startupConfig.json +cp ..... www/json/connectionConfig.json ``` If connecting to a development server over http, make sure to turn on http support on android @@ -168,44 +195,58 @@ If connecting to a development server over http, make sure to turn on http suppo ``` +__3. Run this in every new shell__ + +- __Activation__ +``` +source setup/activate_native.sh +``` +
Expected Output + +``` +Activating nvm +Using version 19.5.0 +Now using node v19.5.0 (npm v9.3.1) +npm version = 9.3.1 +Adding cocoapods to the path +Verifying /Users//Library/Android/sk or /Users//Library/Android/sdk is set +Activating sdkman, and by default, gradle +Ensuring that we use the most recent version of the command line tools +Configuring the repo for building native code +Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> package.json +``` -### Run in the emulator +
-Pick a version and execute the following: +- __Pick a type of build and execute the following:__ +More "versions" are available in [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) ``` -$ npm run +npm run ``` For instance: (build-dev-android) ``` -$ npm run build-dev-android +npm run build-dev-android ``` -Creating logos ---- -If you are building your own version of the app, you must have your own logo to -avoid app store conficts. Updating the logo is very simple using the [`ionic -cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) -command. -**Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work +## 4. End to End Testing +A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: -Troubleshooting ---- -- Make sure to use `npx ionic` and `npx cordova`. This is - because the setup script installs all the modules locally in a self-contained - environment using `npm install` and not `npm install -g` -- Check the CI to see whether there is a known issue -- Run the commands from the script one by one and see which fails - - compare the failed command with the CI logs -- Another workaround is to delete the local environment and recreate it - - javascript errors: `rm -rf node_modules && npm install` - - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` +1. installing a local server, +2. running it, +3. loading it with test data, and +4. running analysis on it -Beta-testing debugging ---- +are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). + +In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). + +One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. + +## 5. Beta-testing debugging If users run into problems, they have the ability to email logs to the maintainer. These logs are in the form of an sqlite3 database, so they have to be opened using `sqlite3`. Alternatively, you can export it to a csv with @@ -213,40 +254,68 @@ dates using the `bin/csv_export_add_date.py` script. ``` -$ mv ~/Downloads/loggerDB /tmp/logger. -$ pwd +mv ~/Downloads/loggerDB /tmp/logger. +pwd .../e-mission-phone -$ python bin/csv_export_add_date.py /tmp/loggerDB. -$ less /tmp/loggerDB..withdate.log +python bin/csv_export_add_date.py /tmp/loggerDB. +less /tmp/loggerDB..withdate.log ``` -Contributing ---- - -Add the main repo as upstream - - $ git remote add upstream https://github.com/covid19database/phone-app.git - -Create a new branch (IMPORTANT). Please do not submit pull requests from master - - $ git checkout -b mybranch +## 6. Contributing -Make changes to the branch and commit them - $ git commit - -Push the changes to your local fork - - $ git push origin mybranch - -Generate a pull request from the UI +1. Add the main repo as upstream +``` +2. git remote add upstream +``` +3. Create a new branch (IMPORTANT). Please do not submit pull requests from master +``` +4. git checkout -b +``` +5. Make changes to the branch and commit them +``` +6. git commit +``` + 7. Push the changes to your local fork +``` +8. git push origin +``` +9. Generate a pull request from the UI -Address my review comments +__\*__Address my review comments__\*__ Once I merge the pull request, pull the changes to your fork and delete the branch ``` -$ git checkout master -$ git pull upstream master -$ git push origin master -$ git branch -d mybranch +git checkout master +``` +``` +git pull upstream master +``` +``` +git push origin master +``` +``` +git branch -d ``` + +--- +### Troubleshooting +1. Xcode command line tools +``` +Warning: No developer tools installed. +You should install the Command Line Tools. +``` +``` +xcode-select --install +``` + +2. Creating Logos +- Make sure to use `npx ionic` and `npx cordova`. This is + because the setup script installs all the modules locally in a self-contained + environment using `npm install` and not `npm install -g` +- Check the CI to see whether there is a known issue +- Run the commands from the script one by one and see which fails + - compare the failed command with the CI logs +- Another workaround is to delete the local environment and recreate it + - javascript errors: `rm -rf node_modules && npm install` + - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` From 8ea506d1ce5ad113cb0cc86ec5f5cb425d815d7d Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Sat, 23 Sep 2023 13:18:38 -0600 Subject: [PATCH 003/135] build successful screenshot --- Build_ss.png | Bin 0 -> 83719 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Build_ss.png diff --git a/Build_ss.png b/Build_ss.png new file mode 100644 index 0000000000000000000000000000000000000000..18ab48b232d04ae28e72f5677d393045be584659 GIT binary patch literal 83719 zcmagE1z4NSwl++m6bdbnVx@SBYjKyB1a~d&p5X2+?heI^I~0QJTPW@U0t74WP~73; z-TR#H?Cbx|-hXoCnUz_yW{o^+)~tIbTv<`-HP$;U6cm)#GSVPb6cqHyCmMqJ;_3Ni zm{|w~1uMr|LPA+aLV`-!$=<>mVvd3$9iE(mp`18J)OU30yF*haUV!hFxP&VGGB%;dwM7 z#KpJ8_n7H@DB#{9XgOZ^1(e6Md5~YGqL&M{_)WUOW2Q>7=?jggT{cD4}T+n+iQ?8+!;l@Oj+MvCdMj3 zNLlkuK6;*ybAnotr$<6!(e;qJycs_>$}1${m*JH#H54k_?}8$!8DBT4$0X)t`|&YV zvMXj-xws-jPo5=3PrL;TCSH@sEN{qM>yFY`E8@_D2PfO*Nq)-3u@3%bxfQEk^VhWq zG2474;S#Ub=(ODNb_9970IS!8)|_!*H<)4 ze(m4gDVqbvQllTl=7mf`dbC&Iu!J6#%~#I9U|X{A`~okg9M+MX)ORFY-OXROf0K+V z#$tYpHkNU%q0!S3Gu+-2@UDujyByv>x*_~g=`TsYCix$7xn6yyBAZ2#G75g9qk!XY zJ!*zu!7{;FLq*FIItnr*&KV_gV%>y8DE?%Ex2c zBQ?(h&}LA+^NiTsl+ZV#jvEXlhdCGX46ox7#RZTlqn3$dZ$C3Nia~6l=mlW>dGSE? z9Q2j{2cE-6rvB$9Kb}{h*osp#ywLs2P5D{bPl_M7WY0rCGV?zh4Z!}5)r4dB1>-yJ ztFK!|1Z@~=c+Xd;u|UD<)X;tu&Oiv&i}e?t5?_@02{33sF~;E~h%P|@vXR0pUPBPc2Yj4>_bccQSZ!}d1F?TAI0)!r zQwHe%=K4c;MYMn+7TSjw&`-q;!r+j4i;4a6<7&JaD5~O9L>y-Uk@Y8JJlpyUcxaor zsIkLarjQtpz9yr)4=QijEI2F(E#4QQ!f+?Q3WPR)+3AZi=`d#e{JMe!K{C|aYohi; z?nm$}Cxras?S=3KksIDEMQHbggCxIHCM6qAYKU&keAs-jZLjNZF9)vo|4_Yaq|OXA@~} zKdl2&0etwEZ|1{i!UBIeCZG<4t;7%5{3FN`|TMCBB*-;-zW{_Lh`tkM^!1*L$u|c(c{-UpNv&56w8zm8k`BVw1y0J>nKxpGmg6bazB7Gh z4q`B4f~e0{JIEM+22@*>Y*+THu_!O(duTcaL@1w^urQ^z#I&S(Web)Bsd1{BFc&fs zGLx(CYQEFZRBup=(KIO2EA>%rjtv=qWew4J0p&XQaKL+TN;V=eEN~}4y2(AVlXdzL ztluLMT&-FFuW*vvfHCP$>+md{e(@vo&o{!d%)~;yfKWgZ%q!@A*Dv$cxh2t0 z8!fOLAKKa4&KGs^*zmZSdrMTW)tmXVg&Gik3^?Pl$&?TYOxzR-KBd%~OO zo6FZ>FJ1+z2a>w`jzRc8PbM#IHaX-Yobr0-bGWqQ;S-RCMKPEx6J)B?SnjA z!7WXXFIeF~K}~^Kp*roS1&#$OOy5GpxyCtb6Ke}G+HR`IYni^4&X<0vJI;d2iqNUk zX{(!RjA~r4nKQp08C+uOwyB;*vjyAw@6{s?5cUvxXcKQTFCUb#LA0S9Iu9|NPn$bF zaGPJOFGga_RW}~hJ?{SArgF1#dh0mlF7Hy|)Yh$R62!^N$tGx=C7h)xl;v=hxli)M46nqY2S;(LAyEX3@`W&w0jit7)c1?0Ee2x961S zD-MNk*28GSso9#@CS#`Al%Gc{k}Gi7e+|u)_m|J>P6!RQcHP#*Rw+d&<&TwAw;wqz zJ15pSz_UrOj-tX+lJAiG>U*FX*nE3y?l^*LKiO0>0%BNAunsAzU zIq`ae(xlBq*5t;7)l{q3AY3gH|5ts_+FJhF-fG{b_t>(i|A(KVxgS<8Q1j8{i1l?`6FX!Oz=(LT+;ik1Uc9Un9A%IlohD3MYaasW^Vmt-iZFAe@%OrYc{i< zu)abtlLTdaUwT+-UdsNQb@$smZdX+t1De=K2MyvDQ}Tmwz*@Au36wPV&34Vn&6KGic7a-T<5Gv6 zY4f5XUKY~9(}7!a0Sf_l&n>JhLR-zVYO|^eZS+c6o#|#Bd)J@4KYf<(d~Ky|;WKv~ z2pi}C(tXbaQBQ0|v`+l$?)R;PX)YwU>CdQF>iM*oi66n;Z2M2Q?$OuF5gy>w$jXSJ zh)&45UE5vaxzdK10z`Ics(sG*^pLfQwTrc}vBiE-D^EE=i}MGom989PdtJ%d;stn~ zJ$Wu!7rnt=_`b@q8-> zU2M7XxYD|mr-F4pa6YkhYiDV@UBB~#pLK~^Idj#m4tvGXJaNa7UZAN&w_o&q2I-2V z%v%4KAha1=FLH=2r+t}q5f3pXYA8);_I>)*-a+@XP@&ilG2G-l{5q~tUKJ(iHrfbI zWI7T#Lo|bMUU>AdY?ZsMS!VGo7t3|dJ1ICB7%7%6#!^(s92F8lQ15y;(rpZjZ{>|* zSelM42@|_j-TM4>9ZBrNmU&>}mesNCb^MEVORkTdi>=YXu?g-p{jgiJeBdvJI2d1a z8adXPcP-vK5IOZyJa4(oZeDR+>r%KFVQ&0tf4&gY;(MvsmDSPO*4ll^dDz~OevrG?N~}IuM(q&E?9cp$=lCnLR0ZWDac1VL zzcRc~7#vZQtI@>M#YE(kNVr8Fl>k>t!XoxcX=o@Ir_a8TkwAq|_>;s$235xicb|zD zU<5#+g|-%lS?Lq>Bj?jm7ap#*qF12`V@J!=_e!`0+K-!$=tfScdLL&We?9G3P0T;b zSSToT%ao2Q@hchvvpqECKD z`)?Wr@^`_HY7#OsPpO)zlexK_vz5I|D#MyP3JR*Iwfbk5&kFK_ruI-aV>5dbb2bmC z!`~t(!XAQ8B-Gr+n92hRv2zym5CQ(9gy0kXcQ!ka>K{d1Y(;>d6_lwY?48W1c-cO% zeE^DLQBhF|JDFJss)8i{OZ@4d2++#K#X*pr-QC@t&7F(Q-pP`kLqI@){R1aECnxJu z307xMI~QXQRy*hS|E%P{>j9ZNn>txLxLDiUQT<)7v5CE_iwF?-x1#_1`DZ)LJ*@vn zlb!Q_b?d2v?0<9EIoLk1|8HV0))xOp>~GFL#s1N+f2tGyyD~v#YY%gXHpm+KG^$Tc z6Xp28FZ>TR{}1PX8v0L34QF#F347?1po{4LOv`@>|10zVEBFtUpZ`TAw*be#>iieh zzli?b2SFt#>nC-_e-EPQlg$5<_h03O+5aBle;M(APUk;zpXO5(OPKxtz8j)g%UnLW zC@A76GN6y@9?$l(Fq#TATpwl_FBc4oE{#Ksjoy0UTcf)5%qmR1mE)E)jgrLA54odM zkT+3?5^3nO+Gl0T`~EiF1@CQ$u@}4h=IFwb;(ceQ@8LU~=N5PrCIR)KM}EtVmq^>n z`$J!xvC~6W#3+kFTa(3W+IF)drnA-9*TC4yKAlbfs9e$B+gG>ou{$cYiSbL`mbn)7 z!@2hl&<$KMhsK$vH}7&td74;Uy(C$#ml>SDZM`5%Y``Erl1LVcYPQ08j`~HM3J>$& zC4CgAY4Tb571Qqo2Bh4{w6%_U?-!47HIEJ!9Gi41i-b=6CVHLSc4fOw9a=m_&CUIG zNz!f``H*f|+UF}?-+A{{^oTk+qwu(Z=J%W;t@68nFdu0Q3t(mbv;FJbMEbMGs}~9T z>gFd(YZq{awQpXe?)FNq+lb)l+t|$YUsLX5Mu#_4DrqJ=2XW-aif`_NVSgr%x+)>=lBaZ>sp5n`il<{Y&+fSR9MsUF*gp{H=fLRmj1= zAJKuDZqfkar7|wtMZ;}>X~Jcu^xW-Ct51tuk!Izddu@aleM@Fr zL9`R4epw#9s~`0Y!`?cvuYU2+c%QkoKQ|Wd0tWrg0$T$)<>iR41EuF>ji=a5sC*?V z>abMaZhVT!GqjZox3i+W3_TSYf(A2Cid>Z*n>JgHT)=SsQQtA(I2Ib zZb$n4;(iNY_qn3XgT&Wj)SuO;6YSnmj}Np1<rl^lTH_7M6 z^a{?5kw3z(raEgVa+<92tLOh%nk^daUSU2oQS^Pe{q-y8V@2z7toS{3>sOWh*Gr_> z9m-^KsqSQl?8~#4{%dphU6ei>?Rg6Cvwx1&Y$%eRN9PPkQtEF$I7M?Fll|M*Qk?3~ zFR=KtL03*PR7S#h)m2lq+?b(om?XTC&5=EK8_K5CI`eV#R>(D)Gx}Uc+TZGO^|`ib z)*iU1X6g1yfc;qf1_8g#@sBA_&-t+e@~|;s!~|H`1YD{~l^ncahO4aBEf-4f|NjsNzQ$pbyMT zbLzLpIyltgOwIthz-%m|WSCr~l`iiC?UCZ&((TXq({`?!+B#cVWd`-`4ar+uqAPk2 zXz0%$C&w5Ktx#7P6DYM^Bq~~1t8LGoEyjo)*p;h0x z#um@HV`2zItZqc4q#lvRNzDD>CmqY^NpYR^1<8C2M;nR4I%zCZd0LJyd$# zOTDu=KEP+wkoCn=NrBH{1tEN zJwD@{p5lW>@m)qb@mZPo?#k(m6<7T!h9LkXAw4yQk?rSOcc^;*9pK)n9d7JL7F zv=oC9mUum|c&mUW%0aPznz>JtW+rTHBGOzHC0>?MHB|i2)v)9~_-CpTU|)8ZwIzAo zJ5nSGY*5J-#clj8WEy~HXJaGl*W|nV$Twg^nK-|-xc8QF&Hfk4vi`+N%wzmTx2R}y zbsLxE)h11Ab*!YWC>v#Lqsr0RN&N=Y5%=G%s84Hyhspc6P#-5?L~G)hf!0Xaeim^0 zjUyceY^fNBjE22-idGf-6Umas5ne?pD*#!R%2HRI{)RfUe@WPEr~P?Rfhy&$2V3f% z8RJosf^3_c1Eusb1*gAhEpqejYgMHIuG}t=@Zh@hx#h+Y@z#;F1J54Hs=Sv4sK{@ z0F{}hy6>xMu@L!_x^~vHT=={ac2*&yr0lGIhAdqpKY5(EILdv6d6F54`s}b{?ft1- z)@{_QgDDQ8WtL-6QG@kuO4|Qm2~>Cv&pD;Y96?#EnPoB00uAesCM$ zey1RDh_^#+>p7v9)f$dxPfu$j-|mGaPJ0l8YwgfpbE{z;Pe(rcfW#-uBAgO=ZofBb zV)16-kjnXxDc>eDFJLfrDtPyez~R-j@B1p9hvnFk%VQ_0(MU11BU7tEo>EU)$+e%^ z53=U^y|>~zL)ga)uHG`C>r6a_j%i&Bu0?24#bKXz+nU&x_B>!DD)d!}pT9QS(HCd# z3m0-z0Vf^fF|VFOR;e~}*134#lO>Kei9OIJ_P8`fHG@u#@Fba%hI_~^%u$9xNO z#weYJSxvzyBPd9Zt;477P0UE(T+GeCcbuqS=y6b+tE#Fl1xS+lmEvU;Sw6;nvP;ke z2`7fpg)soM7Vt@5%b2Iw4;Etsr}uUw9zl7EB9n;O)Ig3s-U3QeHDXs89JuO# ztYW~&1eFr!gyEx0>Y;R+V!h?7gsg~cNq1lf?uAla`I19JE{&6>>t(rJoOJA!-eqV_ zcmRQc=iErY0eLm!S34}y4=7w`bWueg9QE2*U+aAWmEemQ(ogK&SepvReG#?GEk^dL zUW#1v1#|K4s9CYm{NxPEU)sZ&zNPckL5m76IegU&Pa_swgY75SllzCuS{{-(E6|9M zzO=r(N|wHg>R>OCntA8o|k!9*L$`Q_RKYA=>K;PiBLW1C0AljD=ieV%x&RW zmmn8K03_LIL>3Gv!3WaNgSk?`T^7s;IIC)-c2J2!23h6qn-xtWMlB+eWM=I?mO>z7 zSXjko1wR{kKAxKfvrBrtO9`?F*w^FnM(I`XKyH@CTsh$>??g3OKgd0D3TiX-*|~B3 zT<_h$XGe9X6B(C=upc+S0OR#kXtFJnbD^!o73Eq%n8JE47OQUep5LO-=N?dm-9M$PDdZJ zg%`I&yZH=O16skZT{SWjy%m9OEA$lF2G%7}S=dg7O0>peUk%rx#BoL%C7*K(Ee
)8Rq^4M9%Qok3Y~8l3CmXMl)W;fy2a&bn1fkn_~|G5%+*;ZzQ)BZ9*d6+ z6mstUyg8O*&#YBdS16ZCmwbPJ-&+JWG|V}EMRd7L;~)}xLXG@SaZ&bL%yrv{ej2aF z3jYxi3#IEoQ|Rc)Xi{z9P3Gs_RkNO6NjfOxhmEcMqD98x!uvyymSh|JhW+7dx6NSs z$im@Skw&|EUH@kA^QL}TfZE0;z_B&24_CIfa9jvIGlNJ7ugGY&=#_X#wX*pBE9rkrD7gv9bnW?M_%5f^LK z2FAg9jhI^T=|JBV7jUxBVA-GnommjlBY+KEs-?KrtiabB zy&|KJ@Z&{Fqp<UekaDt&fHt^8IR=Qh?%fu&rRH94 zu}g#Y{@SeYy*w40^aB^yyYb9TK5Tnrh;}YY;(lB7=pk=7zR*u$B@SxLJE@;b)L8eE zY=Bi@5QPzevJ|(Xhh%ndK7Iqac@N_C>%$|(^CXr&$yV#PA`Uxl?J~G5WA67Y#qfU` zF3TT_;KQ1q0JE8oS0C#U8fhb zUcTz(djmN7>{QI|_T%M6RE}Q^hkM__&%4;0K6=!O*RsQpYtDYA>i;{q@mC;q9DO@j zmiiNSBQTx|YN`nhS%34AU!@@rbMTWbOacERl4BBbxH?&B*(=$)B2`YPQb9lg?Y1Bf z441F2k74+vZAJ{rZwiUZi>-8BsLhH<()?9l9}_Y4Oy03wh%vz>#FfyfOxJTc#pRj}jM?Kx&R~wkN)?A{=2tGMCM3n?9n$zld098$m2KOulf7(sgc-2x{o}z!Zuz@*q8@?rG1ILfKey#R3h$NrjRVY{+aJwJV)Lu?8E_5E4OTf3BWhADsu5=Ez3=R7{ z$B^=Z$3CKqP`mSFA04@1Rlny~_mi$}B5tPoHB&QyS$^_chEye!YKN*t>PKgAP$xnf zp~K^kDiN2$1l~(qG%gmoXV7H(wP6UsxHzU})b?8j9`?+o-!%h9)0n znd(R&gC?qpYYDHryi}{MqI%UP=$_fWsx42CUp|mHcg-ttXWrI7Es3`lHrdivItbL! zLUND4KqD2>gbTx}kRjX{EwSNEqp+I1vCzEVlVXPzT${|(HEhbV8ou|_hI^#^iXET+ z2p-G(xk#J$Yx}gH!R4U$H|OD*^R?!3z+vimYDemQDyN-5;dMJHDX9pY(EUR4JTD_7 zssXAJc4mwDcVcm~va(({Mw9zUhFBeUUx#My71+Q3;=3L%g(R`ejCb)r@?M6Cc-IXS za|CjD?Sv|U5S{0h_6mS~A-DY>BN=@3WW=mGW|73~4ep({iw*Y6qPGv1ojEsnuCYf~ z1J$B`5kY>$D(=4JIt>(4$@akfQS}*D<==VF-{HaH2G6F>@8G~`Y7E$i=^hL4@+M@f zMFi1l+hq~vk=0#f3@`l5LhS$N$Oj7O|j2t$2 z-GuS+8i2HGnSlti#AvuQ!fla@TGhSUTqp#(bL?DE2-E&$wsg~=gj{01k4U$x?SP&W z0PBln0DWE|nV}5H)$1p8u5t?N?10r>JxlH$8w+!oYKmx~JvEX<(cQy+&dvU?akOd& z0bhayUu)Q=LEZM7ouO8BW0E*wA2hJu0oPUPjDxS5nK8V(Pd)k!h|6N1xb zNKxBv@f1`=7z$YxA9br(&QMJ{8$PTfk%J+Y=BmP=BFl>q2?^8iAQ_DOHwW31J)g~j z)>)F(i???wc$t#q+A;wR@7%ZAqAG=Lt46dnf7A>-Bc&Xi?^wL&72qh6-nGzmIn#TO z<$HKSl+jvabnn}a;Ia>46|NcaO2N~}sQ;pG8WBJVJFZd5fYmKpuy0QYN6)V&i!_{9 zQOx_eSNDJF{|FY6Kr)>V&n`tBEDACRT$lK-#<6HIYA&hzeXjGD#8p@Vabib z_?KP|dLI0gwz4z|LGYa4cO7SlEjj53j5TMBQY@&GKtJQ3RpORFg6a){3>PV6q)0o4 z39w<>AgP?GCq$Uu!PDIObya_kzqrQEjl@yS8V?Z$AQkN-3?v>Xs`xp!pI^3lp6+*H zv&#pan5XJnP~Oh=y|M0aSo!2Vq`PFo?spt{Fw8YpH^knlx2J7uP-?RsNP+1KFa3Rr zsMN)`9u)#TflqJW`+ah29Sc3h^$e4{`7?}ettgm@ugL$??Eq=sc{5#|z#jF?hpLmh zJzQ37^P4H8CxWm6oI#LtGgZ7F!CWQn$7%JK#2&B1)n=|%OBOwNks^!{26`i>(-Y{S}8b@+qUqAI9>)T)mvLGzUYJpkh@yj-<%D| z&BjVT$KVlPbq*IM+*)k4p0&H1^V(Am+TVX~F?+&gF&`C7j^O2D?rNO(nqkyxJSo-! zCGjzC$jcIUHzg>(h%Yf)G?QCz?8?UfX(!9b<-Y@A)|9F!tRHYYoU`Y9+_eL{%v5Ly z4CWMU?`4w+*rxPeTqu&)3eW+S00==$u43t30=O{YMrO9LrLVJi_kdD-Zg5P*2r-F> zwg^>MeMNMPU+X=xevs9mVGda4?PXzOpmWarwT;|!-=y`n_W=3*gRaM!3pID%!oEVo zPK<%B`x-^zK1mIFpWAFOyRe)d3|_eX*A!T4w9(bwPF-AVz1#+z3SXo2jO~Q!dhyB<<%~n+V2X0mA1w`>;${1Q;KP_sC6<#j$vA{wHEg$J{NPA z^ISRC!ysYrUxH3Kw+ma4wchAb_FBB|Dg4Fz+SGOyK)2ZajuO6%ybCYQ}M;Kzm6@%Zs!V!Z$U1GuG1P9nOnm2@mnB>!~;I9 z5*Fe9$L~)T>b8c_+&?CAske7{$eY*Ic|BrZ)V7C*nB~`_J|TsSZ;7f(sSA|qQZ@hW z*yeVp%Zq=w4cord+W#>pP8~hU+PIL=8@$}O=$28bQ$I*GEmPgyi$;oeIG;c(w?8-L z=VoImf$~ZkDpBx2o2uvJaJuGT8g}uYu&O=`RL{JhTsuZ!@KnOL^bZh~DMqlQBu(~} z)fGSbA10B$AWXPjAzb+;@Y%L>O+6U!En-#!h~=6XL(o&+wibay-y$MaP=BI3VHm<&sbEi*$qg zUHV<57UgBF2LpS5ew8_kkD>X5*;{xcraHV-wp4eQr3F1bc$1= zJ%U?v_nWSNhe42GV*`CU;XND4M$FhCd)P;5J?2P9B7sVb*VsTjpwZkEog=z>@57dI}CMFxJ@8nCXKqvi7eV+<%J80w4h z_uEuX1<7cX`kw%EbzN1V0(^{V31N04g?|Q^^BVKZr$gqYT)tb5@Gkm^d&1pI#tIZ( zg(I{^&z=z3`cXl`8pBd@X-~LN9YFbAby1*{mSN-E1;eAaT%yy~kp|Xx>WxWC(|HkzsL3b%N;a^H>e-?*&$_>%gEB}O7*rR&3 z;PtPhaakhKiS^>7&GQDRGZ|;}oTC>V`^Xu)$l%0q64W$?Gv`qOdT=zQ_kK;da)^A9 zI8s6|AHOTL-xb(d<6ZJe>`a1c4bAK%D7~Au%>&Q}M zPkSd$wecm#>(WsV>azGAqWGNx*vDlF)-wqTT|A`GWm2&)ZDFSI{<82XqM0A9PsH^pa*R1fQCseXP0#6e}z+0~c9dLB6%x|}vw78gkv(asL z*grQr9<=B>uS5Q!tov;;Oi%)g80>5*qmd9M1c+as!poKZVn!b=lfvK#%m9yW#;fIQ zwgD{(MKTFV4Aa%CAe?4NV9=ZE3T_JR_*0FVgfpd^e(&tOk#nZdp5*BPxmQ0)K1tGd zWk_Ez*6Is}m5gd<)boug-^6N?9#AUpCOq3)=7LNTF+<%j3XHOTk200=@90abNdyV* zfX%q$^S{ZEzPO@+^AvikL?kTgUd&~hS~B|%KFi?foE-H#Bx$f8`nI10ooUc-u$-|Y zoz2hO7nDxi-boTO=oi*W){BYpj%H&zcafvDvyrv(gY9G%@MS=@#g;aE~5JQ?iM$6s;Ovg+==F_Qa) zO>f=K8C{i9owk|XJtJ|XMJx-+n=4Gvyy3dZ} zd-WombjlH8TVZIKeC1Olwo|ny_0E&muP$t-bR@H746hz5=ws)-~ub~uRGu^7`qZFE~628<;PZv8!Lm*Ecj z7zBZ#dPpS?($Jfn1wi%MWl9B*6@_Wa*7x)Y9AJnXMsu;P7k#?$Mllh)cJR1nfxV>4 z)7@+hu-Q=h%|{WY8{Goe2tg5PtH`Om&VDuP7GgWheNf3-?H$pI{BATsX?6i9g-NzO zoseVXS%I(wX2e@c_E;nc>>1O8Pn?7Z;$|@i3t8G$0xz-4>>KD-#6|aQ6fiWHXkTVv z-J#`x66kJ06{fG?1YPYzycI~OoMM_aIWW6C!Xz7(X9SCZD5)U&rY+`2&wU%98l6xz zodg>dl?6IfRT|%vW4aewrYbFO@8fj)9uaLfZ2D7z*^T?3k8);+6|epu%OI} z?cb_^g}8_NxcPZ0LTa2t!ArG?6@iVdFD}_hBgZO$;Tx?{h0h;x=Xl71kPz%tIB_kg?pj;Q>T!fzXD{GSR|6v-akNh9vVj z^=KLNs#vK-*)kc9W{Ky+_ORx)EH@X|BFqz+d7mMJ`MV5JaN=qC^NnP~dHcT1?% zs-&dkNPd&|VV_*QH1Ly<1%a;#c{Smj(?uY7SqB+T#yXGKs^w$|y5A>Rp)c}57Z(Tf zYBb!xJ~YGUW&@-ztz7!oTPXaRbGrkI`yKmNaDBHWFisPywPAFH{?wg(7>vMU7aUxI|?62NgB%B|CQ8~I{B5#&Ni~nT1{4{`$hL)GV_7wn72X1u9R5IF=^Xj z^Hz3}L5MmleU^)Ag82dj7ho96+Y5dv3oCJTtB%Kf4TcRwP}SiSs_#hz6K-DhvbmhyS4KdP~uq z0&rw@sx9k?%ZO74y}(D8=l=0d)i**>i3PSx#cw5-?iz>^yxsfrHd%0j2~`7R*zp~6 zIWJ7hl{GSnj#K!WS}N=lA29ee+%FIu#ggPFI-r)1a2Uz5Xk_`4y7A z!#OMKW-6Z$TFGD+zc8&cCs;%b>Qz8a6tgND?9uXa`b3k@vL$FNPFACYp;#__xFZ-A zUsWxRTu}$L@P)5!78XSZmhryd{Z(~`|Gn}W5foorA-|zTtX;0oQ<&g0-uV^N>AR*V zc{?LC7Sl&{Qk`&d5y4Wcd_7Z68?9Ap9s$Ij7pnKn3ly4TFkWlG;_J_`EKV*b~TBiDRU9do4U@aH-YZO4`vjeGxBDY|Ei-uGR202B5ymDXlSf zu^|x~`J|3({YwnhmoIx0aJr{g8qOtb2%D?U<<I)kf#{VP>y6uK0q0fH!qJ$|z7bRYfA zEcSCA-zElY5xGbI$4md>KtBDHFi4d)%z{>TR)X&&lxa2uGqggINGOW(@yAG(Iw#1i zbCs}KdFj#Z_9V3v@^nNNo9MT`gS`{Rb`qXQXFirK5*`-f5bAQcHz^qrX1!h`LFprw z>3}JV}Rn1B3xc7q~Q%bl@8*uMWx;=EhNgto44bC zJ!Dwq$p0ehiwpbX(c`_C&_Uhge#>4JZ24}JZ|6HaboDmZA-16p2lZo#nA;|k@az8_ zc%XnjPkM?j$ak1sXiel8HOmrN1^?o(Y&3)+FQDVb@%obo{c4-@tAsGZ0lhkNdX~C} z147+an`q!TA#G(Fe6AzFf{z2;21bv zSAF^n^|z6-Bxb_G7)Aw65G2xJ3q^+GXY<#70^)fYi-3khVpckJk-b{#=F8qe3*=-<*a<%ZTXL4OQ(E*`AL|<>8nbySINqJe@ z{JJ=Oqo?L`#!^!z&#vwbTpFfdyiKJ)KE{nT=sp$!T21ryV=X`TWEU*rIfzX%8##*3bO@?cQcCi?h*}(UMJ0KR2N2^)&QNwL z$Rh?ccKyp*rNA5*<4pFB*+duA#2Nm%2z)(%nxD5747l#%q4td5CCvNnstQSjms&dS zmRmtjVu-o$H3uV%v?zNMN9xKH&YF4yN98r7ikKR&4_!?gTu0oPtwjn<)sth}_*FG< z+(rsd2D?eOoZyYi`wfPWMh!mbkKm!Ld3b)x{8@7rf}@qnzn7KIxr->${=J~rXyn^vJRkDN_O6{W!d%JE%6{Ku0=sP?Z>{uy&)w{H0)IhUIWxh~L@%++WDciXjN66dRJRV@ z@jh5=##T3B@9bM1+*03O5=%1SSFRo&{5!T&^ZZ0OYQ7`328m8tr*Z*fKAT*O!LK{W z+!q`fTXHaOE;}FZcLL!0vN9m*lbmkNR90P!bshivJwyA>$J@@FKleTCe(~#Rfef+s z&+oX7uPDr;*#6yxB?>0gh)yCXLteZ;VSF=~99278Imku@P~I&tU+*{Gkop38Sw)WE zSt~`rP*z(kuv11G>dR-s$R<~UA_8c6WXCG(2IN0KDytMU^^a_(k7SFf6)cx*fSWcs z1xwW9H}TgZf;7sK+c1{~@>CP2Bqu)>laPOwJ>!>U1|pqhJ5|4|X8@0;jasb==ZxNd|(Ln zMM0$I?43E+v>mUhLF%2F-L8ptsRrmys}G!FSy`!TtrKLZ2E#;r>S?iLK511r05v}^M zS%oS~>cuJCuri*R9HM`bHUwUA0SkCUdEd$kX1d)&fz#ECCi+|%`&Xw#vocEBb^>$k zF8Gd$WogtK%k+zLvnSBJlzt#!;?@Fe7)8 zn~$oKGcscR5Trg%S~uYg8X%U<#esh0rM153MNKU2a@a6k{VTwHzn56=^mfL4AKxW5 ziO)$iYOfM$Z75U!dpYXe2XuCikH{o(zR;R%hpv}0>==Owt0C^d0h#HO6*${e9FyV$Pje0=4h(oMldyQ~lG`L7aeK zBl-imr2iBd(Z4{wwulc*#PS^tPbLbs8$sW|BRP&EgCejs@NZY~d#OYIQBlroO zXN&Y^ehd|=YXd$f988uWMDs0t2g|D}s2K%?wD36SMW$D9NOv`M!^+?ZVR3^D za3MA2Y$&+MlCZ`A|M=yA{JMb3)N0;FY(FnB3|yA*De!KpKm!oVYN8e2wz|@PU1?-e zKOh$0n@3ccHwE+rTniLR4Ky;=YOR%MBZj$1!*hA&;kPrnX}9}_dkyp1 z3KD{tu$~Kh(ut3`Yns|17q0kqyu1i}ErPZe61Nxq`OCHC}s_)_Mi} z(yIPVy25c1O|Pi%m8j$vuwEp~Xk?vzPIgUNq;7@1d(La-PfeC88RU+Vp(CGP8SB<^ zJX4+Sm9~}!HJ&8$$r+AiMvREog5&3r!fz>R4x1xx{NUWS>AXdsZJ;iYoVsvi+y?@g z>gGTfVykzJe>4bu2agold>rV;B0kUuD{0f5UXOX#j_Bn|unZwRv^)C_Tl}(k6v7IK z+&fLzaD-O&%_FX6uEL|Ve!FcAXJ#>;_+R8{M`Yyu5VCKYZ&>0rst?D>cuQ8(abU{Z zpxOzxhx-jxXxm;Mt{0BR51C)W4Phse7(rs^Aanl6MX5U*1PTV_h4%ei1 z-fhSb3&!VWcT4XI`zUB6vDl?cIYH5FvB zp+g?#X@V<3k?v!a#oj#KYAl)**vUP77?SfIwFdmlT?l{}v}dIkwB(ulYT5#>AcB1M zlDZrcxWiYG^>fFGs$e{jbXJ;<5q0fQhza{lK$3alCwF`$J#mAx4||z~lqti3moiK~ z?V|?fpH8c`$ER>~n;>7p9uxuI2`U5+htc=E3+!Ep7JO?x{0S{p(slMRMrdf*K0#LY zNyZEx(X8HI%|)7plxZrp;E242;ZJ~JQr9MHm_iPhu9gH;E?yy5^S5+w_26w`zqEFfMGJFEQV7}?g^baJA`z_37uF+gt74CJ^~&4egy zDeE5Coa_b4Ag<)nfgDz(A(j?R0`D;(kQ^(EbFrI(X5=5dnkvlWv(%eBudxs#;PDPHh&xj(#H`Vx8?{BL+Is~h zM(j{EiYQg8_TGENp0)Rgy@Mcl^7-BO^W4YpK7QZN^XGNsII^zud|&VPYn==9Ga5N5 zZVSOj0T4tZ&$kZOi(pLzm|-o9EJdGnmJJZcgp*#RgjUo`FH!@<29VRuEclnJ%G>zo z!j&!W^9_>QLS>vljwFyG`6o|iC@-O^h=Lq{8w8I3AuRR8JjfteHdLL1{hknCo@c zFsMr-*;qMQ@=Z$tw#hH8<=Zt=r-C1NzD2PD=DP;P17sg3OY>%3W^><) zf^$fi|NG?>o~T*+H1JCn5lttTkTzC(gD%1AltIXl;T}~x#AUj5h2u{y(c+<-LuzG9 z!(zH_Q>x(ZZ%qm(wTactEQW8t$JXUoS9;c0m5nd0R#+3%^OL9UUM6OXZJ=Bjh)m!{1CvDjJo^g-fQLcV)6%QqHf;n$Y{e~ecQ79yVwnn^00~S>r5j*YgP|p%XOcx z$oGsA@s?Nj311H|P*3p}A1dn=>fiEgdrB+)+PJURL-%T*v!Fv-Kuymv=5G?Z=&m(| zMP6s?_i4wj_rDw^gqLG>UQQ$w8~!%Wz3J`uuMZdCCRQNUjzxE z+wq`3iTpNL^Zl^P>DlvS^^`njS#V>pH(&izElo|$Xi)U|eix+Nd1K^PorXM|DJ^Nw zovVn@5uc>k?t^GKN)5?ul!JIcFHib1?Ik2MWfTcgGkXzB$LiwKrT{~PeUUl`!#bz1 zTFkj4<{+uWLXOnv-nmQjY7=i*?UFm~eYVxXCq^wPB{f8}n-h>2lGtUbG?!jvzri$! zwPUmR_fC}d+DQ(T@IV*%+P7y4rjOE{KI)m82$<)pgR$QNd4CSGE;fhU=YB9*u&=vQ zvGT>*pjP)JOV_HX{&RNTq)`9{ZjED9`VdN$I}#%=cTztf1&uDvbv+b_JSzCcqD1Ta z{_Suv;VkAN?KfiiF754H=Z*-l#m6|#we=(w?F_I`Q^kAUDjkMqwXMea2=a@v68;BR zf7<^0U&R8*#FZ9;$peO&<1&%_Qn`}pMD7RlKRGF~-ZHeRarSy88_(^^eyv#bt=N+-i9ClfLip=NfOx%dHq> zW@mmTErOjwd7m&4o68Pt^euy&DuldoR$JlK%y(zal#^@vze|1-R%WArH1#=AF>%`nbmK|m2ixp{_mZ#yxXa6QT;2>Eq-gJI*vD{fme{cPozq6JEkPK=S zCm(N)K5Mm6!gU!&kzyVaStxl-rEp1l9 zD21|)PtIN(A&+*iN`UKBzg}C@@Bz;Dqem(c&A^d+cVznL4+)gM8a~@W2T?94Z&P+& zf9WdFhF8uQj{S_DP$QMl2TEO1HAl|X9P}243Rht-0xNnQ2I`j2*(sE=t+DUWWg$qt2VY0sLUiD`Ur%y{Ep23Gy11~1-3%iqh6xqFB; ziL#g6eVtUNqWjDC<%h@_>&1|Z_ks~2wEAy{on`|=axK;YK{5%0R>OagYcF37);H*n z#4MQp)~ZZ#6?ATu4YIB~{IWGYk@7l$KUT}-@q35hP*33KJ=xh_299w^A~@o}7e2 z{jI=O0(TS^?JxPMq~6L^ue9fACkZZ^VVGIwMnq zHj6iRfD__o+4(%AedC^xeqVxHaGb^h&fm5$ITK2yLmMoZvuu08W z`V-L{>3vV@%Ls;C-!<@k)%hV?SADX|ytj~{pk>`^tn;tYSBf60L37+=A{J7kJEKH? z4+9P^odXmk?*GjKF#eidHZB-m-=C^8m`_h@H9#osEdmp|LfH87VF_JBW>>r3TUQRa zRG&m|jZ_=UN9}T{rSd+iyPMst*It@B$fp+=gEN1*DXEx?|IDQ>n0bnuq}4v~sBQR~ zU6j$M65_Tpr^TeB%UR;Z5tD{er#$ya{p0-lug6RHcV!&ApT4Typoi~fz|x_L6}T8= zubOhL@C8SUa2b!Ed*IW1hg1W9hogp)9`h-_`r4dyZSOgdYV$dyc6cP;&{ljF;rPdO zP~rv@3p@n}JwPlbKjt?)U)TDoNx+dm6+|mg_;J8K0om9JG<{BoERbJN)*+GMD)v{LFq8nvhE+2Qyn zipfX_0A9P-#xb-oG-pfCc3%^zDrxW+pv>xS=u_zge z2}J9*fBR7SgMZ3KY5R}vGjhVxZz`dvTy^$eD29^+ttIz&7N%GQ{hSDG-)6b&L6Bc< z1`;SSR+7c@+Wz%GwqyYFK2!)+6e1b)m{-fKP*pK0+Esi-38gChOB{4J*uiVGbKmR{ zIccrXOi?)gHOG5B@7U5)Gh`jCq_-c0b$dF@IHE3+NB{ zr1%}zkMU{aU#n{6g4^@FKMK#EwBKBQ|DB5~ zT_TlPIv3pROmAIGq8S{GGRxk!1^HXPivO(2@~G1~-&~ABV8ww0x~tWid_frQv;UCC8zxlgp92?sO&3p&Sr1l(M&?)KWYS z4+$~9wLA>VrSvlUoCC_`0{<<19^v(IOLK!Ng$I*-XN9W?5IxrMSu@lxsr*g5l&k3p zr(5;c0s1z~(RFz?FRWYj6_0+uuwyxt_o`n5E<#;6R1MLR9LLELFh)R~(lQuyZK zn7#h$-A>N?Nq7xEy8R&UG1;JvXTybZurfVhUj)#LGv(;$Xc)*f)~dyK#9=MlIg}#l zG)U);FjGK!#{?~SGKRtaXEV$H+8UA6EstM!qzEth z%HObf^`|uT|B`Z60?-Lsa1O%(x>JfxcRih9!7LgM6SBWofDt0BujPQGEHjw_?g2~SJ8NxE($^)b@6fRMII*LdG<$i@P(?pY_HRq+f4#>2k$jzI_ovm;UJeH3 zh;_Vaq0c{TznTg(a`V|wI^zHTe}`+4T;zT)eEc1+ix>!gd+j7i?m-Gy0erSNh=FMQ zri{9vx*ECt|NAyXT6)xDNF==z*1le4tLg9Hpa~Zjm%yWBJmi?KUw`Jtk@Bj%)4#((Fsxk&?Tn!ykpH!)-rLIkv>&u?D1tFa>H zr#}wNUsT%So1q?A2j=T*?NaAYKQSAkb&E`cYgc4;3SH|ClGog)tDMV71FI>uyOkX! z#OZZKwc+ZXq$xl44Z1mRQA1|m=$hE-vo^0(t`x*niF@*-Tt2*^wzzmGLgq+Yx6$}?$ZF6gPR-ro6HcA$3|AQ8VcDa zJJf?xSlOM7)l9F%Tm%W?$vBPy?!uduUZ?Or=Yz1YGdaVXDbx=e#jIyi%;Q|o4U}PV zi@n-ekCoa~Ax^w2t?ep4c5}(*H{sV(v`Ajiqi%EP(9GG)@x(v2 zn8(kEj)wh|s-4NyT>0LI*;^f&-VROvuEh1Ntr&)B-BrC z_V(LeVWfXzqM+++{-$J<@=A9CNyuP0ne=U{v%xKw? zMkgfl23N#`^S#OQOm8Qr$JS8dTFOOMvde_UQCWHIgZY*ZWV#b;l-qT6?4f5UNJMcT zhjTh0Egv43Z)In1e&J@;BR_Lyp1OVFb(uCa+UljdeHO?Cg^iup&Ysnl_%3+uv2fOq zG%xic!rq0xHi!k$wr4bK4`DK=GxJi&TfhJ5mDe1Dau$O%Qs~-z&lOnOsU^yT^%16d z{nv33bYn>I9lSAyL|f<^t){q|P66?t&|9bOJ_xu$|8ow93z<&Q^J%ezt^nzSrGto+ zFrWmnH6mY%m>MA!a8Ys)VRQ48pK!g6&}Z&oXZLU|{nqR%^1)_-m|MH@<>s46;8+9H ztDuo9jP2(cHu(a(kNC$uuRF6J`#68p(8sHYjP^hJ@<^dH_d)0&QY(Mm#2?!zUbhz> z%nav23uv6~(ux#W4BgYMcCr2wq$jbD<=y%S@l22*0brLv)7wSp4blK#&J>I@rS_-u zpwmiHl<(~T@ri(f_|dA&ZF2T0AO{{Rox2TDVq zK3g>=uDXQnkOWMol)!@XCP3Z8`PPcx3L{%4lhjrHTmD>iua#40&YQ%}EfyE1ck(uT zxA9loD;-xRo*V2(D^6X^QNjwkgsbnLY?Kv*rVyy_4NJL{%u{+FY%VsyP#w=qqRda_ z0@LW+*H?G6MYLujo!cl_cbH9Ao4GvmIGO;pVTqwGP78dC3bZtPT;~xik4k=KzXF~Q zIR191a5+6iI9@ixh|SDiVJXa~Qgt;x7Y8YtiFRRqs^^Yt6zP+8M8P;$HLBRY+A{eO01!hu}P_Z|-@#_1u&?o!0+GOV?KUET)Z0 z^_s5}Nmer^L?Ry*AD)l;oX1T&4XLYc|AKf~fFZ$72E}IK`i?zMD1&cfflgIDlw`La zQNE@qy0IVP%GaY3WiyW z3_DZfI@kHC;W0?-9l`OmDqkb!5iR~FKtkB*O~10t_4?noVcf0`Gxo;GB7Jof1->>n zJw7>JP8;qv@wNHN2)9c)BiMZUd&UH;Zth+*126hXeqPK-;AGRbb@_DcNZhe8a&gPn z$5YL@8KS@1UKODI4`rMzR%BDT9Q#%iNIa(Itl55P!%O#Us*yZQyV#&1ypQ`Z;z1v_ z+Ixnba#1nReX$zlzA)`<64bTQ3VXgP7eQTnQE3w4PQl?c6zOvOO%hWW{$TTA=4vW! zj++^GC7kkX^;fdF6C7E$3cDaI4&9sIp04Kd8;-a?E40mV?!_rxy|apAm)Jb}@C#<#iu*zs<5n zQ#g?8U_kEZU%(crJ9h`VNk+52|H>}NHLQm+2)oXDm3xuI^lEoF)(pK#a;(e?dJ#Q( zND%KE9t$+m=-vc_`%K{ti%{;g#^YdoI}F85Z0>tD9Ttp~3yxNfc+9BS^Ao6}0>Y^F zG|i{d%ccL21vq!bK{VmdU~3F2EPgeSNY43HXFcsJ^oHnX*e@(wdSi7#Zg+2UFJ8Wj4i){)v(|83oaD7}= z@|Njt!pTSBJwO^--Vkv&Uwo0T)4MD7fq)nF>8DJX*c#cr?rz`KBZobrnK|NPZ^^KT- zjo)suqnwE*o82t%J*3TApE>R96h$46=6nJaVZiey1)t7$cj0TF&{`a~s(lmp>)Sa4 zK?cx`u;9Wk43-fhyS;2|;wkeiLhtW{5oUe>FNWgwzJ6X6&~y*kdwwm&(W$U5>Yz&D zgswN7gJG_1>S4-UCq!^J@k!`?f&!RHwTH;t%vqss`spWK%BzXSFO|)}dNzRN?MW+W z5pDm5+dM3ua4OeZubdzbP3ac6E&;*L!(0g7zv>sS%wRfEeRN_KzZXT@hzHZp=9Bv# zd+7kJ_|csq=Iil%#5sp`HhX`M+AHV!4Dc5m0CXnJam0zIPrq^-xtU6!^Euo<{xRX=d zt48T3e{Wt$k$XvMV3Av?YixI?vd=58_NX*py$Xa{r8}V-5AveAoAwcgPo=x-zyfRt zb7D5~rSbaBr*;XuScEZl(5$s+^Fz1qg7rI|c=t-I+(`@dP?15Lk=j1E_>3DIy#G6h z=rMDEiCa2py}~B{*o3;CT|5!^j~g@^*iDztvK$YOP<8Jom5cO;kt7>)-xldhzilD| zCqP$CILD+({+BJfi#a;RSGbLZUa6fI5(f}cV1g-={#YC9Ie*xtf6za#t2XdC>UE0s9J(0vHjF`}`)?~^T(F3|@%MTCbQwwTW?LwqXrf2a$!X~L|E z>n=`GE+rU-hUb|o&|Mhj$LXIJim}p09MPAC7t4l&=6*ZA|L9mP?$d9F?U8IqWurB? zCl*6Z^_IK7J57;ZoA5O$7Mhw{n^=IRKQ!uen3wK`*RR3nzi#zpk%c9Qjx58@hhTSM zgzYPQ1#>1t8|tpd8tq4P?#t41zneF9~ zZSYh(>m8sq`ZYZY$xGP1!I7ViO zb;GJVl!?*`&yvdpE|Bw9fov1Rv7h+fBzO|xNHQ>28sJPhVvYA86lc-30C>F)M-{G9O*5vTjc+$$V)T9587Z*dwJ?hXVc}a_`Ht*>XC>99eLf{k4Eb`FnCX zvV*eUW#7xi%OZYqFs=u3*r;rwrLo3+9TtYwU3aAI=5k*Yv0v_o%4 zpsQ@T?%o>D^M{S>)em$_dc>Cka21CGRnrZ3)C&~@Z0A2ic}E{9xI$VfiP0W0sLFRY zdXo3+UZgj`z|Ae;{z19%6Cq2 z-G|%8xMyDKD4t_Ghh5CB0sY(TwM=2^bqYe$?VmvCuEaiGHs=-5RKNJfGte^iEMyoS zG2i!u#In`!EeW|J-(0A5v32_Ju!e2 z3D46dW;LzWU$Vg*a@UH(T@jsr}C@{UK9vXs`D15fGO>MC2=YPG0*;Mj+6Aedr%gfJC zTY$tS&DTD%d2%9#Tgy zL$}sa0ZL1%LE`1;7Mq}VcrFw^J19fENVp|-uw50rUfDqI?(~x^ho|5ZZ^|R>=w>c1 z^e41=x)$iyt%EC9uOAV7)zc7{=d+hGq>~izK^5TW*uB?5^<%tuX&5#XuYE-(pk7YY z5tku+CUo+&hSEJ0UTjR)o4BSG6&ESJcxHJPgXIHi?xw|yhzb*xBPy>`S<2nFMmKhK$~uXdUtOp{?-GV2XC6?4fuK!$)59_PmQJ%4om2Z?t*^} zmG>EWkfqcBNa1<(j~;3`LfF*HfJ>9g#ZcF3Znuj87e_o9ljDA|7Ya?keKJ{pK3Njg zV9qfGyy@K$OnHK|UA?#eePI!vGuK?9-It?@)$+FYwT^SVY@qXF*=K3`eyB7oIm^|} zay^L+B1QtIj}=&;mV`F*q#Yjo9yQfwab=S^#XcsSbG?C^BgdWU;O;~X+%Y`-me>n4 z=o?ES8R4q8o)u3aCV`L2Qggsxctq6nF$c&hW=_;ady<5esk~WM6h9d}DW%y+xy8B( zChKJ$0eC+>TD?ZSzI*-t9+{Nh@E+H-wI1`vjmB^0qY6?EtHv{9F}_AC)k8xM3VjIMI1 z8jux8lg9*9iOs*&vHwBIp79s;-Zha0a=||OJ4;-bq4k7NMYX8VxW^RqkI_)H?y93a zWzq7>>-9Y4ARCEKG5qhT;IGZl>sO)~7yfpz++?~Gh znD_%&X99SA6PDbP#&sz$EY+waCF#}wQTLKHbU7zori zP&9X9093-@tbtH#{3G+JMxbA)`6>GQ)qO6L&1*^z6}ks#C1$nzk=Cd7nX+~Ag0F*S z@o-V(sw={k)UfSRiiR9(LQWUGNk|44I7(!3k&z+RYVZe83XLKqiY|P&_b{|GyXfz5 zuJD82bd5VMd>tvwXIV)1szYicmR}cTq{-Bbqp3LYFvf5JRMb?g9Ly9#6u67ovU186bfzEZgj11NkCnVx)nPi(cz#Mdoo{c;UWyB3ic?D#!Sf~nm*u? zPh4xQs^ls}FNuD>*CL1>Eh~fPU+`w|mY^0AL~1HsVK z0%O&5sq5_P4Zijdy$$K`smWq|48@JK-e^9BvjQ+x-5}V|x zp`@W6qqoEpedi`sz?YIQJgR9bF-dZ1UsOIt-?>`+Ze_}|Vne*@@{9&Bd*SlDIaRk! z_sOV=?d?7`$8B4fV_xN0$X~>B;Xk*zwxd{I(v>M35adPCX0`r^B$1h&$>x~>Xj z7QXz#2$un(St|=X;ARWifP)I?wW;otvtFb24QP-z`frzeoW4vTV|q?3i|IGMo9A{J zReT8yGFZ@3F&{Iq$}8^4AYJ8tmZsO|F(o|D+nTlp&j1E3Dro+`Y?b0%znq{_3Fl3q zKlQbnS0jHYmbqW2HfjGFNd)(u{52O=xqETQ8${(mne~-hzJ01_^xkIcFr<&_k4VM2 zY{_@JN8L}8T9jThxJgpM81#u9Hzbr+3!XsERV?9noa^&MWYPz|GEB}Bz`0@=hJKVU zr9gIKcH5<#L$eKx^Ayz5lN?tL+j|Go2bX!Xfh=!uhC(h)ddU-@aN4TB-*AE%hm}*y z!s7eL2OML65_x?l7K*8r?|Qm#@{Po6?zKJwP)_1<43kWnr^JP9NTGTWwU9h_p=4^r z-oVAHs*|M-lcgL!bZtgUwkg4x3H!AhW$V&2%NfSx7SFpkEG0k8#<^LH+Y<27#2z3K zM;@SA<{y*dH>3xwZG+)9)A&b*+Jhf2o9S1A^lEPkJmO;AeZ;pity`Y|Tn_8u!0tO` z5I;Ix-8JoRH`E)Rnkl_3uoxKVUZR1YbnTgcc~FoMY#Dnz!M8s*SqbMpm_S)_CTw>& zYj$$@^m)!W&Q($BvJDkea*lK{jo}(I3HTV4nY%7B50+cWiDIMm7}57OK_AtKNx>08 znxkS{xCi2tlf96#<=!|1s-kVR3fFnnVw>f~?!#UP*X$P_eX>^il!#J5Hk;b$WA81N zBLqa}KP?;5DzcMTm{UXn?ocf#2TRNOffUgpZBgG_HYn-AD!k-j$XoJlzOs@FDBiZO zNh+}XXS>V~fSUw`G@cJ*YsxvBd?>b-aK5`IN1w04C{{a+HEqGEQE{j~6t_QZwTfb8 zM{DihYFtlX?O-wadg8fa6g(Va_%mpn4+II^xidI zJiNcpw@=LhKTWLP!@)J$a8o@nIa*+;*Ikt}Uj1L0$PEcHaIz6ZNb7oJY_0OKI3ffjiwpXS-0MDsQ;m272ix$8K$VLS1rAY z;~t$yKa#d)y_Gnwqae{dc#Bnn_i4{b1*&Gre+-}?ebPw=B~~Z&hqK?wm5>T+zVd1d z7jXQY_gk&w^oI7Pgr(8ecV&|1wnelo#VI)zZY|xqW8u2DJbk-cv#T6qHO0Ia?tk>7 z_9`s@qk|2jNYcvwJ6Fs&bwP*5g-Mm1PA`6q&Cwu>$z39)WZ9fEVoS?fJ_w?{`g0@x z*mV)V%y`s!`t0MG+^AgN?fvUQvu0oD5_=h(9W(gKsub#_xrqEdLsh!AlDJ>nr{-22 z|5x?-Zj5ekqVrS*izl;F8#X(;7AtZ8*oBk#uzZXZff^Yf3C zRg~`J^D&>QQ(GcM5iRj7NxutSie@rFMR1y%P!)Cqx!_Un3wXv0qZVJ7Gy*81!d31p zfhTbvISX58jYq@7R8$sQD%jL2hbfR7;xgioT2sk2a5u>c^+%^l3g}Lp;N+O($WwxI z!^E3$tfTn560d^>p`2|J_!KVl5+Qy#k^OM@jCSO;UZI{;&)@I{6{nTR=G!OJe&sdG z4-`fvC2VVjyDE1_w!WTwzxe`*cjmIc9x(&I+}ybC7)xiUwQv1n^MRf+_8n4_OO5_d*S& zabzpmB$gT3zmK=~&qgeC)>+Rz5%i`R z9KyHIy>HUxbWe!X-l9AYVsvDh^p1+;xM9Gulz0o*kjd6YIAE6KaD1lW(KJ_28MiAT6O zaEa|Yk~InGFEx~%r>YwYkF-hrj8LrK@!FA^7#Y1V_0dy&F(%oUv~v_&bB-3?zg@Uq zexui=tT_CMi$Q$lRadlB{(Cz|`!xvo)*=)sIA#Yc4ugg6{O!LW@UQs$c+ zXBsbNO?Sy_y*_uBhGWi~AXA&iB#FGo&+SR3JkUZFYek0V7OUr;UjC_B!11Ez-H5_a zL*jx$z4IHF^I6-ik+J_)5&y4lZIR>JVuzC=l|0EtN7_uD==)ekSV_>FTpdq07m8DJ zgOQx8iDBIDiV`!pQJBPDMFJBQ;(5%`CRB~e(ETW+ViwRBxjU|j{@|c60>bpV_d3#o z-)cC>&ABsbagu+l#3Bv7X5iMu-3OrUQc)FU#TeiKFZND4gP$^I3lorB!v7)TkfnGH z^@m=}Yb922+!*LmI!iF4h8~6B(cZC-^rZ1qx50{Q8uLyOc~Uaf=W*?CWv**bwqZ<^ zE(TIm6%XXO*WZMUR#g5O0;yF3OYefe)k*LGOfxdoru})W1bHDF3S~3P!mi_b^Eb8F zBzWiO(cTXadq@+&7-ZoCujbwA8%&h~;_aj)toFjA(|^PVbW$fg+w`Fr*|@ISZRdPn z$P~dB5m$RI;tv0s#%|vTa-UyZbSY)urK4*t@ZmvX5 zC-ultM5*%Fpd8O&VP1=Yk}%^+!of2Q|6KcpdtHm9nbE$XEjyh1s30gDK|pe)O-o^W z8gD`5wYZNS?fLrm%$X}?e~OU#bP&2#Wt(asvA zY&)i$g&n+marR8uq{4;qj65Tc%f?B6KRS-nQMOn2`BNruF1)d7%u)P#0DmS4x|?yC zsz3%t)hE>>iPao8mRZ`` zaYU|;{K)@nss6Xib|!M~&(A}onb!ut`e8mRmbu*?9ZE;hE7F-Tu;W|!p!PLw*Fa6Z zZ`!@d8G-`~SpqR(dP=%QIOJ*7$H_D;t1*xrz4H z^qd%?SQDnx-V2b8=A~h<@*aB!Rr#zLUnTbil>yfv^4^O&`iHk^7lJM|#<3B3e$#u( z99l%|%jFpmngHCIwcy4%3deM|rmm^xE8Be}iAMWS8nKJ|nLk7`MOS<&lkabCu6x&C zrg98M>=G+^GQDNv%AxtIb3ks1M!bb*VHm*+Tn;2B9aca(Ym!pjdFn-h7><#4x9naE z=4lM7Ipt7HdF)t?wW67`W3^q5fvBi4GIQ&0{^UcTWpRWxWz&%b2}$AzxOB}&sb9wjz2wb;(g|vVuvp5uuK#-G#xis`$*NGO+qI6^ePPeCO3G44AC>~9Tj(|e8tnNixleLWKWw| z?~HFDafM!6A`r{XsU!bV$6kwVdLcS5w``Eqet5y?dM_(LqyIaoVRYF^QE3q8>c^w7;A7=|J&%fpE z>}SBkTcUr8h<{6>UQbRT4wBjh9nD#sop8moj#<4i3ES=fTB|KfNi9fOGrlHo3@DIi1;?NpRd~E;TUevXEz-CLf*(5a zd_;??FfR!-F`h7rD4N4WxGRy}EYA(rPZ>(6?wH2ZETOJMCywF#pPIlmFmsATo&of0 z6){GN>Pk28P|sB;y(4L%|CX!OCqqgu!ljLJf$@wM<3cJ`i5q~Si+A$locrs~Jrs~NZ>?;XSz*@prBH>Ho6z4-e zoF&Z#-pHTI->WhakVA0Xg?IaYdd%m~WzwItWTwyH;}n@4FB%ne=?ROy%Ul~ed9hy=PmlkKvuO6Pb-0a)3l$^qTi-6yojMeuTl6ROL+P;iLp`9=DP z&gBR@w^d2-)LJp0mEb<&a*&VgT z8EPQ0thI?>TelT%BRF@v;7iv+;XV7-wZqFe=|{B9r{5v7|GJSmXGt6REFE_YHlXKH zp=FHnW0Tud#PnyW)43~%`&}=LrV~3I?>@)#8iZ}}jSKQx#r%yDX?^)@iN}6;jfGcg zZ)x!p=4=_wt$#?2&t$`p$110@UyBlc_1;_xF1qqQqS}48LtOvRt{;~#CgpX!pp#hkd( zxxV3Giz z7&yaovyz5`_h`2ezxi6<1%8tl?lXwEBYmLr;(R}Kl-SIBMiel=oVZhKvepGjX6J*a zONYf(SJlMON)`*l9De3+|1h8ZnAr1B5BNV|ivM2^8?i#gS?2YteSiiV!=Hxakpze0 zd(x@o?||8j8B>`onlMms7~Q#lp9+iEVPvBWG%Swn9L07%&I;TU%3xcUdDWdulqwk? z{8^vE{PUz(HEb=;Fg?TL=|HGmmqvnqrPzD#wf@8#eGXLOrb3rgqF%-_OmoGylDPq- zQx93&h{4(FK{B-tfAej{wH$xt&l2(??9)QP#npv zy*7}!^2OGxtxI6`6jg>a)vb}Zcsb_1%4@aSkyWZNtQ@=lvF3@OtIHufU+Z#pokZqo z_<*$)+vOFYgMbe-G(kvY747g>c`TrDb!@TuA_$2THf)uMdEkm~wZ^pe2JJme)uQom z>eR`~KNy#knB5m*ot#d>N$h9lzA%)jR)eqo2{J7F+?91Q08g>CK3&%K5s4wMBQ8}} zw4Gqiia%z~M)l_TXb)&rX?%0MX!T!?{;yVRcb3IBFO)LTaC9CSo`3P*+OPk*Lvw`S z2IEO!$QgGU*&2h~qf1I2M#a+kjH*qFder86 za6c%4Gec`?VY6%_e+dv3x0{_`L%8o#hj=>Ddp%u+5;a zq$fj8Ely+w6zF#JNT0AO1T)SKsuWA6vUNe+Au`qB`{2t%Q#tSA3xc1H#9fY4R`17A zc9@@co^lM$aHSmFrhqk2rkFS}C)ysQw1>uNEH^d{W)69LItvV_>)k?*uhYR@<{REi z87}8qcN-@UiQ3%vIeAQb>8_Wu=X4uF0dx`!4}H=uL}WT#r~T0Gf55ry zniP!sfILbrioHBCU{zwftPlB3uz#L6r3^g%@($0N^*8w`7t$^dR|h*ZJbRHlRLaml z8fDmN(sr*JhYf^TJKCYLlJnS#bHyBD zdAP>;))9(7pqy88B!i(Jx{A$|*SA+&inluCj{($woE z*0S?vpXz(T{vX2L!>h@4-TGdZEx1&w3Q7r05fKoT4v7d-EJS4~C?x_4OKB21BtZoU zND~mj6(CCiBO*jVN+>~Er~&DrMoL0&2?-=5;eFWqJ>NLzeCO=<4{!`jp4`uU%{hPb zI@ji5ur@0A-3*z~7rdlO7s5VD^gA;;e7btjRD|15t&3qCQYCbls(~s?ZABrva=elw z5&R8?=h*G9BdmlMZj3(@hP$Q_-EN$}u5*P(3!2{y8AJI?O&ITaF+SOBm^ZwX*Qay6 zh$kL4nSs~-2L5;VHrZ}*X?$p`*n8`=R*;I7inaOCsr}X;LE;X`({_L9WsRfQB5<#^ zqhZ5pWlV>OvV@LsAE^RDuUbWNE87ef#=5JkTfw@jzm9>j5?k2@nzf0-r44gyMe=Lr?n3kDOPUEX5)hQ$eBn zTmDgT>jCZ&^W^CT-d_$wEu=pDOozQ+nT)N(bW}HKZno5*AD87r#8^^`D9xwq+NTo! z9!(mdDU0NrB+Xh<5&hko`~^0G&ZOCZ`FT<<*#pzq7YvAMQvr5CSk_DdgQIs#k&^-WRPEH9 zsBNHLBVzjnXgiPac{IG#TYFd>>@iuP&!iMN>2jOZu#x0IEn`l_kOKx$1phCF-2eCT z{MR$Se&WTB_#UOU7%61(g#|0+M|W}$I+-6)I<2!|u|b^tLCNGR-9~|& za|dck%{j^kvhHtPIdfuqdN;BuVytaA+B1!D);>Ag$9rRx5Np|x8-|)YHRV~Vt!Z%w zi}Jsw^ev%O!$&}~&4Pp_BD&Wnb0IR``j&q`9cpxLYw&a_Qm~P4L^qx*?I2!&Y*_QB zB>ZSuusk9>*S6L%&gqF@fmU!B=VfHc>KlTI%&*-Z&nQ=Z@>QSIiN5qslnuo8c7h)I z&@XNGziU|myZPR*hMfz{lg+C+9a>5|y^NN~P6u`~!fEPj30-8@Kd@B@?oa4&@?q}A@tHu88nS8q^chR>67*L7>vm+B4g zvNM&RQ5ZR73#X+RIldPyKC@R>v}*gHoS(Z9A*LmXa8eR2A3<|kiLpm@#$HN?PqloY z)=N3)Mmbfv_!Kr7z5Papf*W6Jv8LYnYR}~QBq9QABaa;17y7d-`}`o*H?K@bQ>uzS zrI6;@r?W|a03PAeegx|-z<);v#K+-G;zB=Zy(=#<%ck9ymOQJ7_S*7`-rydG?BacW8DdvL_p>7o&fUBNrBw- z2u`}Vs}F%fZq8Q$|``F3`%BCwu)9)18Ubk z8RYwa4{7WW6#0}At8d)y;z4V}iL4Mz~%mkMrCOF8`L`)v?-Ic|C1$1A5 zJM#-bEB+AZfM(xGZ+L`r=4`VC8#wA1fD4X0F1bpL0^w-iel7pN`3SsH7RyAS%rf(HzUW0S7^{d8*=nd{{gkaWG#RRCX zk%1;*?wZRPHN6XpItu?n6jm>Ov&K{U2(SRA7*RuBYivw{y-GTv^YH<~sdQJxzTG6h zH*q6RD}mywC>|><{v`W>Nyq)J5)R--6F9tC2&7d@MY`lr+pDD%{MI#8;*p+Xrmw%I zN-{Ps;S9+1UF|QVA)AVbv&2RaesF-)cOPAAhoL1Z28SQVBpCAPAEWSo>u+7ZU?LIo zo*)NpnBSJTWKay4$bv20Ngt8*YZAxwWvv7V3HxQBe(eKE>jUp4!E|e^hK!K5C4q4p z>}o(!e!81Q6_us5j>`gWMUoc~)(t-5nz^#nIOYRS#l)LDf=Mg;Mr@Djfdp%4^brtE zG05hNni7yG$Tg&1m(CTiTVwzaZ1_Oz!byIaqe5?I_`H`eKe6mdOP`p zXiU=|)n6U|qiUG!quaGlFKz4k!_UiNAZJ7@S<0&j*rx4Ugj@#fCV)D9;wbbs*W?_Y z@mQJY+ zkZ#x8teG{@i`cG+2!9^scD-s%;kDSE_SSJLQz*LX);d&3EfiP;De}>I)RsQ=)AUkL z2Q|)l)g4ZqXQoF4!7K{S|B-zaTT z++cg+ev6)5Xm=1#_Qn=jDqMdqZmW7)ZdHHK(Xr{HYQTJG)&Zrg)**cd;+tv_K0WFv z4LTwI*B~EY5J}S0jpTpexy41v4O5rwh-JH}^PT5d|ExvF>APpgy^<@;G_S26bO^N{ zo$^etxI-K?tuxCa8p;HMX}98qhj{2|8DNX-hv0VGZ1|F%ezD>pdQIIu+^);@$5rd6 z-G;7f%}IWQ^oS;7IYEPqzEP_X2=QJD5|yRXnza5b)xXLMa8Fzk$Z;BSmWJ@?)D*2S z((;iZVK(s0{7g>0b6EegBoT3)CXrs*6tfBZ$|9IR0oAiP=5?xzNC2(yXf@$2_Kve_ z><0h|rfyI8k^L@qC1gs~{QAcBHoUg(+mWIfXA=*#7v#-*i@w4xC{K3M1 zYqlJEF>SI}^~wRoAgd^`JM+sxD4X7@(Z+{VsEGA0oV6pg(|4&`I%3v{N`GosJwgY3 zqOoru;y%CryO(Y&qZr$NRa3E~Nqpb^=x$}`W1wto=5;LGw<(StWIiR*AlsEEH{gEsvJElo4tyUH|8huDAaxIX_3 zwblrcLz;`IoTL0q)jGUn%28~knLUs!C+A_rIc}bHF=psO%32}x2A2`20ry|sedt2c z5)t$0BQjOIvxS&q`bVhA&eZ$J#>_dHh_>s_C4|NR;)GF15cDl)5%+|+g ziTZaWMhJ`*yX(TH5mg7ud@29iga*>u%a35SV47bOfkPwnmYDw9mVtyYXGUr2@*gPP zG>D1_+}?jdVjEb5GNF*R>jNj+MHo*Wgi8CH>xPyc{Mfc#S6gAY_fY7grNbWjjUg$+ zw&d0uGsiy;_GJs_wQ88G3E`;F$1vm3OiyFN$8->BoUtdnCp^ zuZg8{HJ`mW6u)kz6qf!Rkq^@~pvt{K+4!@LzG z=S8kaA%dc=b~-2*&$u{^-AF!!R4L}fh{qO0y~1c^U)nU358q3qX8<+@qPRlDCGoHh z+A6#%7ipH1Xx1ecFgF4cF^FqR*KbAY9q6=96b2ft7VG5kQ`(6$msBD8)lR6-7VeBSC*_RBRcTWiI=s?6cCWIdocch)Q6f!~4_JYDk2rs{1?H$?Y9BL~- zG?*t2HraCEfg<Kv*M#JIO9mVrSX)wvSk?W zTiVc#qVQ8`s>4~E(9m{UU6k98n!-~3W!v?4|4V4}KOD02V$#r0%0J#*X;pr?q1dWq zE`H~CLR7#PkXp2unGF?qeUFdLS?G-zb9<8HbB!*y?n#YWzD6qWNl3TVN!_SCfWH`twKcFB^K z#fy|zk!3m}l2yWEyRctm(}2b2h99C)N}n9|h1LQ$+_6f|OZ&lhdUf!mZ@JL;S4rAL z@fC9~-2h=2{Hzv|G|TUtI3j{)6Y7unTz6{!((chL2yIUuoN-MKX8bAnXqMA$))FAPTR}Acj^=zChSnwpOnl`;aM(fdyWnG+(;~!Qmk}=1YN`WP;W#ivNfqj4mgBi6LTfLge zBcfX$Jw?AKSzh_yQp~7a;Vp>r{LTkR@52rb@L;Y{kH3&4WK_O6lk6HJKO^PZTJVQp zGw5(}R3#(otVxr!%p(cYhUC^OU?mczcK|no5rw`Y(lmTegz-X@hB6`{j|pdzpC8OEMvm<4jQ$Kf1&SF-v4#;^>)wInGZ61VxU}~ zr^bu-yHBd9yB-tJ#odK4S3D&ryud&|WfHEm5JEk_epVjZ+>xZYSJG#RMh{ePy=HD` z9N%;G(YClP2Rv^LJ-O&}nCda z8->pNay0Gl3(T(Xv>oudoPpCH>?XcT4~)*k{6-=R9p4XTzr;~o3dslFYaC#VBpeE~ zFFsXRfg{)Au*6`pavt)ch0mZ#u^!D0+Ti@&#-H9ddEn$ZaRGyI^EZmUKZdeFJ8>c$0j62nuF?z za}MxN3LkS5Te7l+?&-KLpB+=X`)Bfi*YiVNuJP4`-z;8&ms~_;q(PXmWA|3CRnBIU*pB$2YgjYqFj3+#@=$&xAE>N;0caSCZetYw| zPy@urm4OL!o}zy?c9?ERdT)O)(cWZUeMrxbpzJgQiUNHX;Xbe@!KxNmz5!t{`qy?( z7Htnb*i6)Ktc_BFvd`q?Cp3TRKqTS4@&y?bi2D;w(mNnl!d{=2->h6;ev#a?p{NC1 zPD~BMkHt`rDENT4%3s>ot;^JDGs=ByvS(yx^;;A^*N!yFzlk*7{r8D7=Lg4JA~0s# zq1PrMcgc6VuFXc8#9jg;wvYS+pE%iqGJg&OhI<(*>Y2v#0-n;}S-!C{e@v(s1?+2; zT=8*N_`0l0T#{)JIX&VN(!=VnwOb15rE=6k(}TYSv9le+wd(w1K+I2)B;KfuPYk8w zJOsY2u?KRvlMbA}V;M;V2M5nN(*DDg*tTexig6v6T+LcERQ2&ML4Q3IKeG)C-JxIi zg$D)DU|o!Vn@R;D_B_j9=B#K;C#3+=%d5QLpRf!UtYL`yAUZ;8^`Btl9lbA!G zP(;9hH8|P2sqc#wn7z>Iwf~q2KiPGlS+yOJn7C}YEPPg%x0Y}@O|Qh(%MhXPTe7>C zxIecYX3a1nFMnB?J!zw=g4Ut zrNJvJ&g;`?O%ugc$4Db=c2khs^?!|1GQR=2QqFgriT4~D&rgUt9t?E8iux5sRIaZ^ zi-87v!OTllzH*sBb1*3U<~cpdPN%G&er^p3QvOU0q41H#C%?Rs+J~hxmt8_axKz=f z0M2eF%PhX`q5vj$4Cy1?Qs zC?^#3S@tSszxg8u@<{TENWJ+RTlni|u8>rF($*xla9_Tf5A&zR2q2}&D_UZ^1GcVj zU45f?aCk`-@$*=4cBwvTD*=cMT}W+>xoxgBX<3Atxxjb;`f4nfU{C8?1Q!JsdU^x@ z(T=1HURcRH4JENBvu}*H`AsLqs^E{iE#%zkf7E4e9aE(;)@OWCV)KLjnUgVd1L#}0 z>FB);X}&1A+>$C?>N6GE9}#)Q&$74s<(#z}j_1!uzp4~Lq8>^Gxhjywm%nW~KMM_$ zkmVF;MB}~|?z`YLn%YTw6_vIAlt0UxD^Z5$%7JB# zJ57#TzRT^kx+Z$Chfg$peyi+x`4ufrMf_al!8P;$UrO_&!g*<}M^N)`{R7E?mOil9 zwJeSGSbN6VgR2XuId-5#%x(Pf^^LP75`?2Jxy0+biW%`nV%LPvH)R@9V}@eX9<#;A z;Zw@DElU(X&ne}-akA3;D0hMcQ^6TxIUmvc9~a;&H|8xgj|bx~82Q_J%syA#v9dTt z^_LgVjd?udVCbr}Gh?m_p(!1h8shbnPh`7>&FWssNI>NY_)Yp$cxt4s1G5J|V|my+ zLw){ddZeRlE0hoSB`ztQn3n+0uQj6CML&-fgg*6v+Sw#FWf+M0KU*7>pR%;!Hor|R z%ZGtUa|#c3j~CGkxj}{sq;9&z%;Ipk10RbjFAVE`ppHraske z5=46c#0#9sqRj8k=~pNc5HZ9S+d90mXO>!wjoE`_P5>(gP*rh!fjlAmj+v&>eD+7?g6z~=ux@N>4$b=uCFVx++{c3Z=)LC zL9giDnDKI+dV4Vop%fY`kt_^j1NUov5PU`%V&w?A@jB`h{Blf59<)1?rO04Qa#{y! z28K&|-a#pAIuN9~Dsnz{GT)8MP>6XfsPfyZ$|+992Cbv(*=Cwe(sEVRDc+Sht>fDw zCA>p?-WR#( zpr-}M^rB*diHoQ>hcVa67mDFZp!FCWr}m<(&9`LRp%?Tag2zn?L3<5Q@t@ZGPOkt| zPNC#gy4X@7RJyT&+cA7nVM2U?F)Jjpe}$3b*}3RG=C$T6qo*V`CsnuAEc9g65((L= z7fmEoB|o-VYJQ7lb~@jhmE5ECF0ArNhpn-}LIo^ypR?S|fkxw`XEJR?{}0(sa{u0) zefg1Fv4_np6r+`@2IN{#L87fhPrQ2Ai%$r+qNK^i=eNSZqwPir>EM~d-XmhYGPYU&8(Z=^C;`8!T3k( zm+GWNLf^FN1wK+khISdUievx22zm6kK7CGYZMQQb(rd%UU7N3;=li>uUk!NnR&OTl z2Rg*nd~6{{*{&rXr;WBtiK?@U{MkqESTw3}Zsds#THVrJyDK4J7QmcfN}!B0v3}+B z*3~iG&FeK>T< zK~G$mF&w_StR3||Gq!Sp?rO8-(9O)=tPw)B+SbYVqJ6O$KuW_Ni}C$K6~E`GfBZwXtKwb6*&gKXlmh*%muYT$kt-k(r&wrkTWk7SEti) z4*Tg$PfAp;3EtvyFpm*sj|;G^{@04Md8=U z-oP2koL1J($-RfDJ13p5hyQoqel0crN358kcxt-{bU6`pcueB9RaI%$vKM9%xavm= zUpX5#lA;Me+*W|fzwY$(4YKuYRF6hE^jjDTT@mlP*$fMlWS&a#-hOpQxQqjs#E5bGS62bU1V3NmLasDq=PmXei0=O@xj5=~2d1jS0oAQ|XEAB_&Z{<7ZPUd(zrLjdD?CQpr_6Q!nx)q(kLiAxK zz7uu(Pq`)pP=W%X0|U{wi$Vl7 zR*%1Yo2^ZNc=sBDf&@W-N>SeRJ1Y>4`4oG{zl~O>1tQ z^%<_DEYMXCI84ny)$`6&9RjRPz1`xY7RQ7jsv?c>NR6F=?@& zCXZtF^GYv@R?$BYAN}{I>c7C-zoK6JhRNr-DLWOegcKVm)vAvt zX_fS`uL(g@m2m%b%s?&XCwuHth9M0igjbnn=M^z;J~WA9c8xVTBue^cUrx-;sbd$j zuh1k9T8O3XI++K0@>hXcDGnXjk};w{7rU4I3Zb|tmfA`z00hp+kkrjxQPyF%Q7f&Up-vO^O6l{%LdT*YcNrsXRb`TxqT6(N1OG$zI@E^9Lp) zRW?>ucE1J;W@6)0WdC=IHPskcAb#eTrOndT1oUehp*<4G`EjS3Wy=_HY_vQ#Q)>5{ zVyFo#0(c%G{PE%Ao)qP)^Fa6Usw~uisUA&KuGpWW3V28;?l1OM3k22lVe>7{_%Ucq@FBYCG`*3OpaNCK?7K0%V5y?KJWTii$Bd4-Z(S6chN}$50(Igp zzFqT(1zYS%=1cLH(j*_c!t-S_iE_l8f8vTNC)#0XkDdtcxBRm8QQ`B`__DXkM*aiX zfW&Ze$L^!&XVYGPH_lp{hk?#X^O( zo^O~#4`x-Gue8SIod8gZGPbMU&EgPCdoq+K$Dj8Kb+bQa&IG^jaCEP!vXCpL zo{VdDb~rbU(05P9Ylr{Vm5aQ%SjhdC{VcuLNl#Rl$W4*~j<)G&YKilI54!VI6wgaP zX-eF&QXm02fDhOSiD*^R!`>}ou_Up#fN<$=X`Ljueo5^L184(chUD#ocT1Ky=RFq& z3ddH#BRr+1H%{4KmM!^v9YtFG9vywzo=SF&S&p@vNmspf@J_z$U z?_nEIVXSQI*i0Vi;C&Pksi0~;%{y8OUVJ%IPT#C-F(EDf(G(3D6X#i3dAe$~wIv)U zis4;jv@}hLRfH?uBlT&J?e@%A&M7bPTJDUS=$x%8G^{t3iQ(2X0vuxPS14gvcgnHa~J^? za+bgAApSVomh={<>;V==hC@dUruB}@!1L6do_wz*lEN^0P8mJk%ji^A@xLK(0j;A3 z2ox?$7E#R>6tdskH|J9;%w-v?kaCq>I-#PRl%F#SX zQFva)?x|!LqerS3{q^3gmg$YFR_#)pyT;&V?XGl`f%(JO`iTs~lmyXfuZ`FUuc#xA zod=y4c7QEh}bK^}YNonP+oGYRDx&BqQQpxapPx~0Dq6HyIu(CL& z+PF{+##8b(aVgNyA{fp!J~uzA1f$`ABQ7GG(so@I%;_ix2*AuvFzbY*S>m(fu>2&f z@X`r~MUQxI%9rXFISCs9{#Trv?1!?-|c|*R`rg)sO83r0L4YxT^}su18j%R#>Zi;$foRV{WoUgOAEktbA2pLZ=)2QE5x{M# z>wi|Mk+mK4g5UJ-2CjsPoP}s*VCR9x+1<;oOGk8JrY!kBRwt17<8LJHCe&tmUs#j0 z2@^$WlrP^k9548cR3@MDePrT9%vy9IOG!{`5IGI`Jzpc}ZemWLu(5G(aiN85l0b>@o|Nm3x|G8^qT5m5p3%I!P)$7viS_}ar>0tA_lbpZDAtMV#QE5qrSnok5RtI|~9F8BPv9XtFsa5<_6|N;Zs{VmXLe*U@X5vGG&^uf^D3jqBx7D5`b<|xz{9j@Ge-M`b^-6G2ct&BT2~p#WuN=K9%-?wT+ZC?Y{g3<1kKPg9 zGjmy9ZFu9Tr;4QOYv}a;VsolKa{q~5HKo3cMrjuZP4S1xNW_A=P@sWw-dz76`c{rA zF+|)E5qBG*-RGedp+q%Hc4AmOtk(VklJWcNB7|08(~#lIHb=qJ90Hk>skMgK5jyP_ z&Qtb&dNz1@&~3kdt598Y?j0MrB55Fv*EbM!w3{~|M^i`B^Fh{6u+n-S?7<(I486Vb zOw*QnG$ognF+HDNDh*ej&zFfzOVI+;EbOXf=OmyHJ7%NKxVRU^5@&7&O8;eqm}~eP z=o^op`uR#nw$_;x-aD`xFG>m?kkaB0-gX!djsD}FR2+Wr_7i!U8@4ie<}dSZ`9HG& zR-_SzTGnyn^?BoCeQ(tHxzRIM7f7+P445&0y zCjRrU;=lZ6R1fbL871mYg=8N0dCg+APsPxeLV*nnbb11HF7*%^#m4Ap2uBMEKe zlR})o4;>P`O)h1{dB-319PFby&gnoOhb?`o_tWGw$u}X8?Sm18uMClF1k*C! zv$2u)z_&`o&8q{O)tFJkLdTV*M3}n;O&v`_d51NZ2GOGJS&!i61X*FEHm$P)<^7Q? zBol@xqxL6oihHI+b69>~#l8h)1DiTkn^Fh=a2VeMd+hlT`JgV~?O;KWE_X@2fuw)C zS9>tj+cBQ~avkw{{y;{ri|?qES3l=hN4+IyI1cfB)v>Ri^U3ybpxbh|!RzS$sGnR>o&w1z(TdjQn~Tsc8nQXR>T2#tHjXvi zszWp#b?X|Cu2q2{#5nUglBZcGr0UG7E7KnUcBPhSwh~mv;FAl_d`64psHe zI#{Pq9OSeGQ24p8%dWG(4B@_J6HR)Z%b+86tVOeoQRYU!lTmy{#Kvqo(d1M2gwn|J zMeo^L+YUyfI=pOoX2-ikrY9ZXX6KH5T* z&b)j8d3ixXGE~LlQJOUGOtP&xrpu?3%TaeWPcx38h#0D*#A#{&h4a&X-S~-c)b0u5 z*SEq|)d2D8WMI#gb2dT)A~<>LYBGym0C*aP1ZLblkc?0Sq`3l36iHAn5WKr#}`&Qiz|clf9lwI2YxjF z-uS98v-xjZFs~gmSD`wJ-DnIy^EFBG=-%ah*4-@7BMCO;LoWZl^YS(RLYX>iGzs;a zopobd3z|E?JX@RRw%vO(NFtYF9Zj&Z_f=4^Ik>ckg8i1=k$rI*$-aPtaq zREeLtoRcMTA$h<=EpB5l9Ska+!#*DDs9V2}ud2*N%%`?*Sj@7`FbRHvkMy5pb4%J< z0Icki?xvovVT`|jP-!C~fnfbh$k+qJD}*aPLtOrw@2u%R8ahDe72xb@FJyI@@tu_C z^oqcXfA(c{kSu2}Ifu(@C2DKSlE(KlAAIXt^~#(bU{=_*iCPEyNLk)rVFv!WLSdw*7{fX~EsjWV_M$nX z`fh4rtMk?w<75c69p2{4$#CQJB|{01J4s}FAmJ=WNNJ|&R>S{L1 zPPel?s!Bxz`CgkRwTN)%9Rk@hY#~bPBX;#0C`*WAg$B1-GQYr{d^IU_Mud-NtI{Wq ztZ+#`Hg3l+9U-$wv(f?I<=`KvA$;zF9v<#d;QbY>3$%~S`#xu52qlGNRkd_p91dy} z&*>Ygmx7Emh5lYTe0s-hG9zh8XI|H1dBN2Xy5$KusoJaodOUl7ozsSfSJdgZFVJ*0 z<>c1qYBo|gW?UdoWG+gv);?lxBH^bv(g|)OOCBJ9`Qg>6G`{mG%+zq7Kto9&vQi&6 z?9*kvM8Ap){k#bMlI|Hg=wr+uPm!0&bELT^(!9@s0^Hk*>=W|vPTzDue7x_V)at`K6CARQT2g5DJ1wUR6~TOIGz{e-Q)N9p!lAG* z-iGbNU5I6Mr1Pq(uYQ2j^JDRu*aZ>oPHk8Yywj4C*5d3&yR_Je^m?t`QUJZ5CHM6^ zZE5Fb*Rf@G4rk5H5o@4c2puv;QGP1ZapUs0(WbNGU%N$JI=bwK-Z6%TOw;RJKM&z@ z?Vfbrrb<&#{nX46-;KWt9D|n`1)2OW-3>+St|zoSv>7Jpoct(Iyo->(K^YjH?LE7- zkdV(*J>3J7&za63E~ITVrA8Wtv*sWf-BiDk$ zSwrdA@v*1fM7p7oA=&IcHU4_2-SG-tEQ>6d4V(5dT(}Oc1{+T5w^-Z3s#weKNTuTg zL)o)Og#7R6zPsu7eRJVrHgZ;!%Dy#}b;2RE3tW~UiLxT#a8R$0-HP{u+Jav<;r9o~T#*y5XUYUf`TfLcDAAQEQQ1nx@I529z-+%^mefVRn^OGp{x=vEh1#8+XAW50hn+Asyi}S(hyp=L-qx*5) zpSVSBugFB6RDT!d)-J+t`|1Fyz29KzJUA8+ zMCW|M;HNs51J25fYIuwix@<5g-H{fHpzedZoLgFn9M-BfW@&@fa<-Z%?w)xw^p7q< z@HBAZ1t$)+tQN^rNpds?EU#U!x}6l=7>QVOlkYk)o5(taXeU$Za}jUPe>9TJLTnj5 zCB+w+cz7ZoyjT;a!W8-hqa+!tA3mUE2qD0%$#Tdh{h<56_kf{U;*Tf|J*HmgmV{%#V;|97DaNFTa0%I6Z#;b)NySBXWJabuBp2@ z<7(riEJD8?K}fe`_Rw3lR%Ge2y~FAUOi=At)nF9;e$i8$S?!!OE=( zRE(Cz`mp%=a?)VA05Q`Y;@>H1^DU4}2y6Ae=k2c`HgD$2@%N}pBB~UqOylVVkfux<#0TXyK=-?C zSDu`8ejzLK^aSMO#>jJi@wEs%*$$@q%lS8kWpCE#LL%eiP>62(RDoU>%p1u8Wwju3 z7e>ferQ1nqPU1`0QGVd#xM)yz(cBzUW#th~x%cH{=?Y_&MeKRxD-WuKW7*6~e|@ zIXluDEguA&BoDSVJB>SF)H~YoiRe{*Z^#{!SS1kH_h~^M4+XI}-1g{BaN>#qaN*mz z)@H|{1^dKN>%?8gb;engJuqFT^t1r723Dve-#$D+Mp7L#GCx!&1xDnlLaJOAAb&0( zxOysAK5Pv?>GSJE&fkr;UWO0op4OerDQ@SY8^EAu?DA(%qt4rE$Hxtd$}WFVIBF=> z3CjYH^U@o9WT)o=WenU4wY-ll5qQmJR8Nx?F>piswh2V05gQZwWZUS^O~@tAL$jub zZqPG){%=}*!RhHZO&D80U*mvUHzKji4ScddD~kZbmWMi6hW@a0I_ySn$tX7is~Qcr ziFEd`&Y9#9empuJ6k91$++N_=n}6<8&|+ayVNl8rViB*EMPl6VN?a0+1@vmJcJgOc# z*=Z14pHxzZfx=T#52s{h*RII^t*jN+l!4$!?C<$V1^0=ridg>lk@H%u5d zSY@?fTMtt`3NTN2A`noRtB$B6K2e)Nmo!$w=H|jHsQRSgB5nU1Hq1B-?THE zfD{%@*RE#jSj#^uaRvPKNzw1RsVOL_d6L%!)e8{KL|qp*?|xlZ!qZel!4OU}F(;#% zov_O7M*DrtDq=ehW%KpGn;20H-K&$=dQ~f|hn$8-(8z4d5P&QIY%|cTK-^(>XppTF zo8#V|uhIq;!I(XPorIMnLp0=8kZ8ntfMs5REQpvtM?p_vm%ThjGZ}AjV~FihsysgO zf(14kJ`}92o{%0_6C`R7EIpc)RBjMVF1@l)G*IM6a_rlU()fx%cxz-JN}sQ7gJ1k& z=oG|xkI$dav&D?pM`7<%PA@U*98O+TG`MnQ5vr;>e{T?<8CZBC%QkcBDBxhwbuqiKUCeIESQ!sMJw|TlMu+Xd z4Ix8XPL^Tq3!DOzZ5`c;*{~r0gM9%EJDU-bY~hELghpaXh4=}9Q!Wnl2^bER?`nNY zSsp7&qK0N|F!9h@S&khxPq+4daQS;y@FF{FdaqRFkALze3K?}kZF&-u0~CL)>w`+) zBfoSc7y1u|9#KWI4=**ek%f2a*_(GOi$BWFn-JW3b1oz^m# zSBK_#N@<_gLb>s5%bY$z!@=M=53_b#6Hpqr zw*cC9y601$ftWLQ#se~sSsqA547B(R-t?{Ot+4@W)qINl5SQiXVReSyhnY@U1&{b~L2C>wvAG1hhv#d$G8n*5F zoZO8WA5nd+T_??_D{Qbd5d$v*Whwjfc(Pycvb488$ZPxrHS6hxNvC`*8Pc#8T`b)tq`nnfJ70K+2u9mP6+AQ77)zQKm{*Wdm9QK>T>`F z_*d1O;Y!j0ei3aqfiLu4&~8VBGr2=`C>{`*p)zIXlc!d6g1(a?1UCRwHGo_=&Z_nd zi&*+|d%7jiNg#+^rOHP|E;3LlT84}>_q>t*D3aWKms|4_$I*z_a|(U{SEwCWYSV87&Ux@uTJL+{yO~x73acZLsvTYL=I$RCvYr zRYcq>unKgU?kZJHq2I-a4bXQZ5^J>8{rXF!4okkP!Oq-Bg}hfKz%T3dFW|QS=^>N! zh<C0J~SY2QB8Jo?Qo` z$)X?V{6`nSFg3oJeDD4QO>eO?jFe!|UaecpP0%TVXbXW~=R}m>Rwduf!rq5b>znqe z3VhsmGFpt?-$FwgBz0E-M2kdic+ht!vmJN6S+WHVapeS?EarWf@;pkla zng0JM?h?ywD{^aW$*ppa=5C}&xztxhxnDwG_xo+;7IME;$ep6T%Kd&{7|LaG9b#jc zOKc3YTz>2K5A5-I>~nd)->>sL=O}A5bv4|ojH9dRo3KT?2HoGKc$98>6&X6ZxIpS0 zOZKS$zy0ipAPH{?rprHh8wDRVlkbJm6{7&$V(ni+Lw7p6z1Qe{bike|+&nz=)OH#F z1At9_Yjx75;4Dp4+nKL!p*t&7N9qDQHm#P=Y`k>tV52=&HJ2`?3ar_z9d4OH5Wmq2 zzvqTO!~XQ%+PO*hEmIGNse6TI$B}hi;J_BH9RJESF;ZkJR( zD5mFaWhJ(Vz9R0qpIG@GhmY@%C7=BzJ97UIcu`jR^w0Pk0*}uQ{$k>*#h@@^bMXJ8 z6#j2a-x61#F63K;wF%tv{8(ev7Oau~OLIx1dgn`W%`@s;^5?3sht^m>U>0C*cXoeV zU=9mv-;!dIQyr{n@9%~{SKclz{qwb|{XiaSwbZKh>+JLp3i=@IKk;Z;hk0+;6O#lg zG(fI({!)W}u$)*6cC}y*``eGJOwRxxR{jgC!%~ry(*A{NIJjK>&u0%PPsr53qQ>!G z4tbGo1CqwG?atc7a(Q^svxVrj2Q?ewvTtGJ44Nx z&&+4=)#%3PV37RUVhw7~j7+YLIK)iY>D+NOcL^HcwQ{!*k}&(XB{l@X)}Y z|6-rOyxJG!@Y)xQx%|ZF9(TH+&+~fZ@rNrppK1(xl|B(>FP5SuW_npo*>&zd(wT_- z#Gn8AbqP*8&r(>04~}X>R&3bpd>?eb@z!_o#=x`corTa_CTDaR+;t~xTce8}&ifv8 zFwo>Zf~wrU{NdMe*s5VQI6L&(*(f(jZ}P7jS`~FhuYApB6&<)A@z+LgMWbuJ>dbp| z-|6&AbEEbQNc_>?j@Acsn>@eMKMqVo7%_NK!#^Fq(=0vPzOi)Rp)KVzsF}8I8tOiR z^7<-llB|=Z=6-*9ZJ27R2|?lOfrm_4z59RjxARDJ=?Ok`j(g{i&mMR6fQU)rXF}Ls z>7k})+&M$qaY$9&oXCmuvgL_W2QpyP_12R0NuK!D(z}_1zljeX0hpb?`frZmn-B4+ z2?|SgHU8>afmP7#Up76bTU~PsD%6ll1=YMs^-U?{y&#gR&UHs*M4J>To|(2j{cS1AUj*%5+E?`@1R}*uOeD> zRgQPmR1Uf{@SKod+Bp~0S6|xP=W4oJ^s~b?Xe!iyQ{WJ_J=>v`lAu;y+ck^%@b$o! zXaRMf+=T9+7PK}!b&u#ZuIuw@RQlp`Ggz7L6z#cezO!OV3jrr~!g7ZxcdIkMp86<( zoF}e3rg*rM39YqLW^3&SkvsIpHnJt66#s|b&G#YZ40G}9ACO5cV^7(Slf%XE^$t2{ z_8}+V;+WooHX7TiywS5VPbYA8pPGd_`?I$L9WW(nRSqAegvLJ0X!v6G;V6z&Uu0$C zAKI5?kELh?({e#i9 z_xud~s;*}Bn)gG^KV68U-JuSvMZ+2calS}9YhCE^)~(P4sIxdyZ5q-^w>qDM08?9O z{*^Xf6sy#Y{7epYlGB;1_1^m%8wT8%y+IQg4X_+nx1lgZb)L}!X{>1U-_f$$a;@S@smwPM z*v6y5IV48d4}SOgpv1-L@UiM|UsHr|=Rqp?N98tQlj>2XUC+F~7b4h`x6qccPN0}* zUX3*`|C9onp2?aiX5o`l`NI7}?OEoOE;?Uf`V`Ht*zLq2mBR3{f&gS@`s^tf+^0=m{7+GD7%BVg|i)bE>^1_o1i_SvO9t+vS!8T_PB;=h=SS;oJ!! z)_o&@R&D$tTOP4h!I5}zx!^(ZP#!EO$ zLG*3pbo;A7+B;?H&B){VMC=%T>*0SKX8xP1v*4zbv7HYA`{cTB0l&Koncg9Pjeh4j zC7M1X$3zcTGkSl|`f>1v8qtY;vaDWpT#x+m6t(o=c*YC^**15@u;lf%Q_h*Re<4jy zH%_$~r%d)IhC19pc|nI=EvB9Gqn+EiJ)DD@j#Sv*Azj(t)e!oWaSGv`OT1OtzKP8f z%=t9+K%ytYUFz7PW5Kg*@jt?Lns<^x32Lx?1y@JqOe#L#%Y7?FrrG=cVB+bvYK`kY zFfzYb;h}?7U_^PUum8w>z4e0h8bmZNz9&)11;DULWhd(-IGQ*<Y`FbnQIs&w?d92;(?5~;V~w{O$2HAvq1jHZ|E_&DJN8q> zf8Td%Gzbh{7-{S#zw9hjc7gmVQ^>Qu{;MsvqGTbD2S~*fFU+_+_ROn;t>wzl;&?Pz zfG_$spTkeggtihqW)ii=HlOD=CV0QCL`|3(pexOUDyKt#qw57CeOGKk`PQs{|GXEAG|-`!X~bD+a>7_uKDS=wGemZQLGp_;^#45 z=DAkeY>WabAmCh6ofh8wh%M_Nft&!-U;gimzUW6Azo!*sEARDU6fsDIOHA zvd7+9_tJYY?9gh~4W^7sXPiN|rablP#yR+9SW_yxTq%zH=vGhBk-SaG>~@AIH0)$h zF(}B;1XrSyt0o>|>6#jTa$`dnJpmWkpse_Iej!P0|LwN_tncu~R&ewDJKsaR)t~0d zUR?^nqHPoH8b~Zm#SVp%P=GZKK1g#gsVKbpsYYe#1E|*8M`J zgK(0cg_uQwe{bWp;^F&bQ|O`a#!uxSAgnY$>-x3>~(zuC1&dL@N&ghZ*t)_sShMX&VWa-czQ`-$oj%6wv{0%&=bnoLXbP^ z5kNnTGTo?Yee#lJ>#q%4u`GUgN_dGyWy#l1u&PQo(xI>J=LcbgeSH zw_oZT{BHi}LbIfCP`fQSTF!rD>5b^gdjB?2VIFU>el9oZQe%b;ar0pwiXHmAR&IH+ zKi4&8C@K0uu5WwmVruk)CfHqCs~5xmgU0U={>gCOzjER$ZQL~ zqPkIGjFfu1SrM^@Sume&lm0VIvAVwp>#n_iT$|P5W{=$XTwaFq(n=Xt<<-01y+TS# zuu~H#35osKRa2R4Ce5mdbkA^gz8l86p2vKilLcV1t>O&yn1>+`XQQRgtJ3$$xqKrP znbD~MN?roL_Zh30IY&xUJA){f=9n&X77~KmSltf)__UsrE^;fHtKTdakgj~%5mZ|l z6n%Z}{MG!RA2E=|#UzPPlS6mb+R49rwY|X4O?KxJ!)6)C>xvx@gxoK99!`(KpO-o` zCIW)a*?2ZroBR$+J6{X(l!*ATozm}|$lW*2PKA7@OcfiQH(-HGML7q}T*Y?R2_|0b zQcs9QhSn=A+-%WUpeX=73u-;~p4zPCz6C(sH>hk}A@xP-m%-O5*=Jw94yOa(ouj5L z4MB+cJ0$%?Kepb~@Z)8$!dE1%)hRI9dl*E_2h|t>aydqW=cDHYNC{20-Q$R2qxPp% z-n@NpzMZ2h=36z$y`?00hQx8Sr?eJ{aM|5fs*)E`{6oe0WSEN>Y11YeTr6lpBk6W6 zr$~lPR=wzBtm-+Af46#VI)xMwnL;Szk_-Ar&SMgKOngo*@E3+St6&}rYY@`f6P`7w zkMpXLDqimp87ofbsL2($#UQ^Po=Qg+kM<}FPJ7T8tMxuk0x(VZS3?uu9|p_P+E@Qd zi51I+p+Z%jgv-~7tofC~1&cUFD9%FpSo7{wWykH#Kik2{hg1gV715mN(&6-^2Jvvx zFV{p_Pq}nP>c)^2^@^^)ZH;f35IQuMlhboIY(>f@M?trvlMojme%ZB@XBm_E#kqeo zyu=6^_s6jpIHY-`HaqZNr&lb%T1jdpn0T|SBsI!OUN}JuNQn%5Iwo6wQ62y1ps6b? zx&R#)=Z3$c%VKlv(AKM4+gqsMST4W=wUuyi4jUY_(-2y>7Ji?^n5yB%&{x_xi@y({ zE>(=+&x69Qp~V%$e!>N|$Ce)M1inhuw>}q6#Spb~kuE>qZFeiY>-f5xJ1|PsRo=Z- zjZjPV@Di#VV>P?U2D2tr*nC8yuP2L7<6y?%?E4xm#{5BsP?u6khbeAE(EeJvwNo0i zMPLw_Eesc3oXRbIvlOZ_iI+UKRl^i@^Hxa-Z)s~%lVkeRh3JKM;X}$_u;SUYXnc{w z388`h+(0FTeK_IcOsv7_ssJ>V>ED1huNn!nZiSS~^! zj`d^->M`9{9yFzY#{QfX&DY#JnD8fM1M}HaWm#AFa;a(fuKz2LG))w|Ps)f7x#>fk zi-dXYKQP-Ml?aquS;>>Po(pN`ep@T~O1AYIGF}F?(~7TK;ioS`(PqZ22UiRIll^;l z=%_xpRe$~j(S~=LCZ37@q|cLs=a(UTAaY0`X!yHue`9A~nWub)*T4!>f9D~xhH9hXGHi30LqutW>E@2S?92^&sL03 zIz|JTPY8&sp%FK=P1;>OD=~UBSBE0%5vz+ z-NLuB(?k)#^1Nh|yB3bwER34mv#I9v5uowg`7QBt)hUUfOdvdrgL+VNva}KrK@**=v-vazEBYMJGdl$92J>Yc zni6YtG(Jp_q%F+aKA=xx4$DS77zFv3Tu-?aZzJ5DgOaW!^lNwNA?uMsZpJYRZx5*G{MJ~uT6&e26rtl*DB`{%;2@>1c z8u&I=-luf}d-Qas3~&<(HDi(}cC>LD%SFMIuwi`?S{jZkdk+hvgG+WyWxbBxIKDDG zK3zniQ1jHa9T&>n*xzdTwb*d^{K5(i%xZbz&+?`TGCmnfqv*vB{%f;s$@cmQ5H_HP zjab~M6X`|v)^F^aQa|VpO_*`=uJt_ty``oAWY}|gl(Bok3KuOyD*yEGN|BKp`k30)rYo4h=W*xSwOI!3dyz;D`6f8FoaLGafdt)cWEx1S`B|Z+FU_cp^B!8%4-l1^FJ)gWj=(J$j6{oNMKBF$%2o1~V zli3r^6=C*#ztoq_+t?QZws3UujhV;9I@PDaPsfauwOSmVAp$>HjuL8y_i#OZz|6YQ z@%6`UuQI=!(zD7;W@msFia@Qqmf~{l<}+jQ%HNL>`89<4@9(#|wM*wwxg~zDJRPJx zx{b^HqCLUJwz<9}T_i=^<6q`+mF9u>!YyWnz^L^v)?)xTk<7rW1?WuT`LiAijrg20 zgoy}fTuGACT5D?9_a1E|_r}sg1E5L4eFC5o-hIRleZKOCAZX?1xP3psWunly7X;It z-oomd*CxWk#+-o690;V{NOV&b1I2mVtjan+gfz->#Miv^`k<8jFnLkXp+L-a??(14 zc|ec&>JUGuz)!o=Ie70XnSTMjRZ-b%`xQcJ(<=WL>Ijg0M&hUKK+mu8P!Q8rfkq)i zx&*fcF=jP>b=<}ZOY=G^cVG+UiP(!C-fuQm_7elPmW5LAP?N!n9b2Y(<$=w84^J}q zvDaPhWC=a`!fQ&74Db956#^cID9+^hDILdvQ1SfU={wB_MHe*vCIk+>5`$NFIv+m< z!LUo=9m>}E0UtM)GH9gc1K(>a64a~H{<4aYAzlD-JuN?@gCC9=I2+Un4~RF}^@S~F zPh%sguOJE$LmKrM5Zr~yRCWYOm?!WcYZB+N(Z{9yezcMZxoZ9s{NK7E*h_KvUS#3p z7%=2i7KqC+AuB7G+jL66a@7YSjIlr)JJFyODFVYgEmBA@&}gdHFOJ_;NR8KFi1GMG zNJ3aK%Oc?ls}wdad&GID30)c~Om=N=mUIpgl~PiPGT`$QEsJ#L{DsLq9RcoLE{sgI z3e9=#(fgjjQPxQe-^*RpSaBWhi5)y=dD^F(+>_ZO;P*4mwYJ37dG}S;{J{4!#+Q`| z{JSg`HnqPvyWW+XFGdxsY?i)u*uC89=LIC7~OdMUIvd+@j;8K4dl!ZJqo(w z+>Bu@3|i*!@{ZmbNZ!GG%zIG$V7Zs!kJ8ky)iA$nL~dqkTnkqx@tdO7fVn!Y58kRH zhaJ!)o`<_6!EWl6s-}AqMOkUsYott9pKDs&QuT-JeMf4+=@RMgFEY{xsrP(e;8``>6IAfy254Ke&NG&A%a;q!U`T(U;5B_sdx#Sm@DMS*05_(gP7_7dH z7GgP11OUhux&a@CmBLm(y6N{dyfGFKxAes-a=Jkgt|<^Kx5!)CYt{MTYmF~v@7Q3+Eys-+>31-c0OZO-#__|(t5wx6n{5|YTLCws+P zXyo7ah!;9nv4L_Igdva-h7<@-3z_>W5AJVbaLxsnAFB9sCOYMg21WY;uMG^^q7K7a zBp7SuLF zGT8S4kYam(I_eM2hBtej*Xt&v8P9LqJ@>Qf?^n&p)jyWx+P!v_wQj^le66fQt_r|S@sbR*70aU?@}fXpo?PXv6efx1V`YA@MtV)Q z{eQ{IyBV(&UJn1EX1)Fsf1!RX(VH4Gx^JxN?-`D2<)g-s z);bl)-77~tC+1$!k4BjK|CWZvOU2ozS$v&VjH8_5W= z*k!$FG&{#) zj8G`|Z)&W4ocC&oZ@o~gAw_?D2YJ}0n)Uq=iM`VDwuh^Z=a|fP$)rHvRe?oc{7yX@c~0ny!?i{l)8^Jf?V^%y0|~)H*XBaZl(@ z7K=aPC!>Wa=~LHj>#bLd7(njNSS{pI9~%&!`Hk{_*qM(mTs?rGNO{P`=3 zKQpsY8Mr+nwzzvCUM4e_sZuC*F&oq!K$6+K+)CDtKKbICc>E*w4Huh_q+&~z&|7N^ zfrZ`gTxMZ$s_~pa0&tnE3-@D!hreqoNw-0B1TWKX6XU}EBNND~?y1{2*aP{~GC_au z4k)#LaBdik+j}YB&8JLP6b=_(^K8sS1_XP;aypD73w8m8E}yGh?{*A}dgJIL@DqZm zu*Q@Rp+xSqmTP(=;SX*g%zX=ME~*@?5Da^F40Et{9)$#@y^Z3hez|@k#9`I*InDJ< z>NZfiGJ5R-xPdI9c9r9|eW8Fh-0KG62L#h&)+g@JYlO5PCe!swmrCCm?!qgsF~)>L z=+S`wYTl)xwplRi0HEAh^pfPcl|U=pimzOk_ILXm1@TI>QDg1wmy3wwXXH z5?ONjDEo-dIQRAOxIZ#A(BVrTF?)UxI1;2QX-O=75Rw)+V>qRs>+6@nAN)FTi87g9 zm?jwdnBr@mn!vpK1Sw=ixSN9E>Z`k>EW3nLOv`etCfJF$C} zntV4)MVA#Kuq&xnq!0eSHJbNDF37&A{dOLeQV!SLGxpIdM3DZfih25&6xrGj^qTd) z`2={3vMmZ7C>Jm-I6+V45P)zXWi(!V|y_Z2pU#tTOxTn)YXX5yh?OZ~TbE zPxiB(m8NQ`odAGJl2QuXoUK3khrf@_)R9IjHKo6Gh3M}VG70-jv*Ej$r! zL_a0!hsVXSE!CaesR3}UTGWhiz#xx{nZwAVv6QU26=srJ1|0^gepIUr5_Gnf^sSGK zTeW&yXZF4ATWy^04{r1P*Gqy7`Y%)@tl4h$WL@&QeYY#`X7p5<>@s7&2-WvqU&qq* zcfGIbo{8PN&+-CXEFm;lp_UjWp~zv&%Ms(7IL- zx{Cg2beHj7bu~63z~T90++3Mq?jkL;5@+h(i&psL6Xc$ zU&FwAx79OS*p_iP+gZjic5^0W?5xtrNPq}9@mVwRI9(r-!}g)?R_INUGW}*lb~Ch7 zk*zD)ey`t&E!W9`sm$O>hP^{l=lUDudcI7k)Ld*1;hmsZ=}@> zZ*R>KUjB1&i#Ps@_9)2EdJFquBvAVCOerkjOE*jE@Hw8=y9ERbO*ri0^Pu~V)SGIQ zWnFF;EhZ-(aCXyG(DIF?SY=-~4KkZwTIt=izUbtrg(BCXQ^X(fZhdj~KG*m+cgtUm zM48@vY~O2!g%{Se;D2)kJb@pKHXiXpDXWsXZVU3xc~80niE`q}eToA3}gG)ra1Cx!?GQ9?_1z_i5Nkx7{)^*g8^OIJ#** zJ9Lq_?+eTbPP}^Jo>r98^$mZ;gj}MU*;TxlC$3=H{SQvW7lf0B^f7k2G3rBdYjB67 zUwdzPul|K4ddKpjK_Qp)d!IMUbjoP=-fN#F#%2j6>6+a%G1;D03%ELkb-4<4$K@2_ zMZ&@79kpS8j@D>6Qix`LAtBIFMk4+q!A{pW&nqqFZgyW-YdYoU*Wmp88o0)%I+7ym zZ$g}$fs#2lvi1#6HedjVy!qmgW-(F35pLhuGXISQ5>oP@{q-bTjK94GK>6WJ{Xus$ z8HKc;D)6tuKQpfzu$HvsOlGF19)!Z%CDujg=u0OKE+^4 zo7wK-mOexbJ@Z9Wv3+jnwqzk*on75%WV_DPYv7K?5@;)CgmLsGHSv*y@D zkE9nnh={EQanpK#chTwjLAiYgFhNFHQTbbs^^atf2!CyFoNImS(>OloT{F)G#k1FP z8!Jt_i)dq=R5NqgvASb^r$`ECW2pUrGQEvLkA3D_n}4TxQ-#!4wuu+|fw5KCi6)fea$6@k7TBDutC$nBG7fpG+&_Dar#&{4D5tjf}pqy04X3XknWX&#&*Oe|; zcHbT}%x`&p7Wy*j_lr`mT8_q2hz#|2UUExTR<2@i80?j^^G%$h+r2H6vH6s#m7jX*3XIe^hc%S`!x$4s)ukYh0P0D#4Zo1GSL4Cf0u4RoOxE$E;yK z8B=V#IgW91bm%zBAu0I%=*AhH3v$~K4kO@Hs;YVJR*Dx!LP1j}_U}V{s`&ws_sc9+ zOSW=92IG=N;Iwj+J-PS8*9TLEc>Tt?&IrsM=5au+?w#eBbh~{j#W=@8mH2!bLn)t3Xok(|sayd5`8Crfftrv==Ts;QmFC!1(nKVZkMcSRChc zDgzT>hUV0n`Mo|l@W439*c#Wob1;ET3|=<=P2P4p``O9XA$5=>wELiXY@@xRcF}P0 zt(e?-AhT2Un~_HX49vU(=ge3|*A>0_w9`v$PQVqNt|K1^oJr>Sq4MIoUUPnFUQ$d> ziIOL%?}LU*8Rt;DHT{s>NC6GDn(_>nw-HL8E!uM|Rg+DkjH+$#s~B<%T|-LWz}=p6 za=Y(Uuw3(yM9Fh7|FZ7+=%ZXGUV=IDoKO%+Jjj4x_z-;_NK?ZFiSW}J88&2~jMgND zm*x{0o0S~-_maFOL3>j&p9z%OfYoi~79R7w znZCUSte&YHesNkRAe!SaCviVME+*gpHib1S!_sg8_QFsvgQf8~TWW8ChIlvD!e9z@ z?~)jjn=w(fZ^5;73mP-`LA9WWmR+Y$V5E{F+AVsl2(CxStb`1cAM-JxrG0$u=OBmpD!&rpyrKR zCTL**b5*Dd_I8-JUvw(-;`7=TKi4mJYzkJsKJksXTMj7Vy8}aoT?EL@99g&KB&e={ z^1IjEB4wxGnhOPZsUSir%o1~5vI4G3@~66~6Zg@sXrpIm{eWMY%@O3!$S5XK@AUI8 zJ=W(Mc}1Zxr_OgAMKPVIs2=l9PsMu*CUwf4PQb>C46@j7I+7#zL(+BY!UCLA6rVyJ zW(4vG`T;&RG#!p#)lhOY=S&p!UyXd!gIM-1GGBfTMgQHxH~vK8rNS+}P6ptyqy7k? z2zpkte!GesA-hj8=(2}|PRTabiGe;)m$iE6YCV_)lo_+ToVB)k1 zPE)tEk2#7m92``IN+@d7fLrZG?IXAx>x=jP_NWgXq8?2w130UR<$!pvT~A6>GJn{( zP_5>UGVk)Xm%Q+$AXSDzo2gHRHfjO#+)9x&ux|9OH}ZzRqu=orzR;p#XsmUGAi6<06_@4OC?8omfdo6 z2LgDqlaqo;t*X&<{X&$KI$1Utd0B}!J5Cv$jFpT#GG2%RRul6zBd<$dTJ8%}6EWN4 zY1s?a`2Ju_#N=CB9^J5YrX}DVcbutwx;}7xN^fQeKtEKX zpC_VqRIkq9c-B{^;y5R~D95scQ$iUd@ovQz6``LGNDZpHxEaX~y{e2zOVkza0_dmU zMHJqzyk6BgXcgRa+d!$kBNr&dJr{Uz}Z?wD7fNukj={N2DIFPrd0JaUu6pg z1C#CJ+P?+SBL@Fj=mrddNkQ1&A8LpJo$Vv*oBdz`UYa&fh!AaaJX(a7KKG*|*#X?i zd7;bfqz+VF>q+U83fdm~s+qTyI^tF|-UVPrL~v>v?lnZ@85@4^q~Lits|U>HN!=h> zSU&*U)sklOT)7y`h09=8=R4GNSpmBZ*lk~YFjl|m(=EWQ7g7xz`IDiV{I zlDNuvr&_!Gn}MMc&EP`!UAgwLvcmZ3a}L}q0z~bmr6D%4Zg!yz^Fk%I+Y*w%emt2; zw*QW<$~j#g89|B|Zx?ltO_Ibz*%09#rF>D1V|VN6 z&lux_R;zBs_>#{|lE+(C)oTu}kNTBy^1;`dZlv;VAKc2%eTYx1PEdgHiRb?DalWT5 zvQRp_-y@~BF0v< z?Bb?3;}~UTx5%v*@WINkk>Pq-kIce-m-o@6sHbtTPNxC}9?n7D%P36Khk*to9?&PM zy5PO3?uOzew*mz~PF=U3+%pO(vg#Qz!GCKpDx;mmr5xPdwGhoFXHLA!GmE*#M{?e~ zQh8xdXsHK(D!!}bq-*eCCz6w!AF2>O1h;uaSX(NIvB@^iMR2) zPs(*AP6&8m-18JxueV+Vk^AX~LYX%dhjBg*n%V>^(3+8>gX-8t8q6PxLt9*6^;KMT z3eoXxZ2A7Mo#F;YR5rSpRunkGjTxG)fhFAN+ypJn(V()ub``+gh_L{v(AY5 z%6SZ+NF%ZnksMjRt-dCeyArBOA!Nf*nb+73>MI!Xx^tPn7eQrLG#9w*=pFI(qttOm zTc}dEt5Ehf{-5rr{m_ZQ&s0sgvfj{&Bw~_kD z{tDu*a*L{#qInmwSOSh`FKN_D?On{rJwWU|9CW%47quXtAI25&Sl%TF-zrRL8OCpPQ^gEQW^*t9Pne ztiO0C>mwpxe7W_)R~H^uTxj`TtB*yk!TJseiFv7UlFkB+R0MzAq)J$X)rKlbnC&u3 zo}!|2EgRO$F@;BOM?dqA4ip*DcYcd+(KvX}$d>#ZNN#?R$r@249KKzORXsxU9|=^| zqn-Qh-wLHK;2x7e&eOJ`icj-ZL_H!n0G23!ci;D?`yZJ@`BtK{w^2u?;dVKr5XX^b0t?Hal z&&jH5!&NAEC9t{&6{#T_*h{gAr$b>&(rVbUI8n$QVq&4~vX#co0^rIE+82dntr@8q zx_En$izo(i-utNX*?juH%*|_Pl()uicWF%{?u?Wmvi+lls-@1Vo|B%g8f$rJFBNbC zdy03Ab~BJ}$O*L;;SX--w@mUUDfj|H2KqJ64yyIto@NLO58UEK_DS?g)uOv}fhAhG zrTUEwu>dPVMH=XIz7Fg)ARZrzkk{*L*2rt+8T~jLh#hCH zMc8Xu4S`qvx&Z*Fm|W*8Nz#CW*^926PALQH3qt5DUoRNzz-uuIw~7I)=tLZ9UGBn@ zdByCcPFG;M0=WO>B_Io={Kcn*%T2cL&YN@I+im~-LPG=1zM-1U_OWU1=Js&*E6jpD zgHe*8Oyxeael>o=#ot9ycEONT;1k*wrGZ;P{0WFDo@U*}6%dcgMU%lui(Z*E&= zw_hS<&(2?}!&ZdU$XR&H9jyTP6k&tnPWB~8gdX&?ukvWrsn^XLk^jKhKF{;s4Xm0u zEfKn%FN`R;lb2jtL9Gg3?sZ$Hs)u083Hsa7GRV2}c2x_cTDDh`zONmh3rX22dZHu? zvPav;?6&3Kc|Oz59`9AHzXdIhq`TpE5SH+ISKY$Udh8wfk*Fj~(kl<7Oj%?pN!d?O zKQ=+j<}O?B^0p9SLhy`)=Sf1`2m|ns5+}`a>oPRz``cWX>rr;;r6eAj zDgB}~^x(9d)PbTSZW)(A7iSxobMj(YMQHsPvx?&eM$g8Nf$!J1uboDEEeTC+Lv0I< z85PgHX7AzS>A3ocWD=Y_-V_o+$~n;$MdKY&Fv_=&&@O{c z+>%bET}oehUn9*c-8(pe>}QBR{&w|o*Ap|Z3@){OUWZ<7`7xa`Y$DR_-FVATmqS*8tZ|d&MuETWIosm+!@mD#aYCyj5 z!k>#Oalr$5A4gEEz0l+6$WF?q6j|~J-wM;J-|w-Ey#44@P{PaIa(gb-^ztJec+h}w zA^XA?vlVOuD$-a>S}QsRrnLM_72<;W)*^QYh3mJw}1>-GlJU_OhraePSjYYG=F5EF8pVHj^4!jTH&K75KD% zFfM=0e(~FnJlb*=v|*L$frz@k7pnKc@q5<{wvbB>sK5xL#&uO;mCRCDH6ABLnGK21 z9D{~R=zD`b0L`|SIbz9fDtYL;aRFZV$4U6JRTtGJn7`Y25F)~8YWU^GI#Ae=tIK=j z_>vq@MTi#l`4@J9lj>KQGE^cZ*xxVa0|-C$40Hi>A6yBLW7NC5&KWhP!f_v_Heqo- zTYBGNo-;oE`u4s8oBY#h&YxIRuz=heV^+F?8}OKJwen#V;dzxDnkgHnKqUS`Cq2{zbQ}C3NI4@B%_cGClrs67Q?QQ=Yt2TGQh0Q#?9;AN6nBuXE_t zsc+Wp@h|L_i<6asGM8Db4=F3krgbkGQ?z)xU-tDnJ!*Nk=O;ys>ab$R%ETyrM=u zCV3sk@DZ7y=BM@aDTVuL(Uvh-g_xYklJ^7_dJ^q)ua}5x_o$MbG&HvkW zJYAp!q+Im@~ z=oZ8jZi)@}OQ)6Faz+$O<6^AWBL8U#kg9r&dn8=cy@ zt@fObBl+_PpF3-?bT2{6^kVT$PzjXO!ASg4cnUmO>Vtp$2+sATQqYb2tita>lth?M zEC-zZnX7JA)YMj@bT5d7^N<#A;`mYa=o3J(EiiycpU8Wyv%CA;xEqEjhx?;(%!Vpt za2VoYK5}sS);ztFqoBU8^mL0&Xc||AC{?6pR+IG${T}^#>Abwp($iA2J@{@ruc6QL zraqwc2Kw^ayouW23N}Iky{wGg;LtjXjB)nAf4uepKf$Uc2nKY);{H%|szOOv&3T&A zlq&M6a3n_)nLT9R6|qnB<1$v-WDci06E4qhGMg-;{6SfYDlSiU-fPT!u0gBROSz?| zQa^LIbOOd;EpC;Ah13Fpjl+*CcD6-Ek#fICETA?_cKarv5YcVY{@&GUxnIp>>rP@Y z&sS8xLp5gSVa7%560Z#|G0<&Rg(QSk4ah`9#q2?S3d_ShuqVlTw%uj9u83EG zZ@bi*U8BL8lvtQ^p8#1{I%cQ#g+}Dph4z)_vRV1W3ZRfmp6~|c`fBN~NE3<*ZaOs& z7g!zMjLGKei#|>84RGbu*iL2~RUzShOpwTh?^tBy$(z|^#jN%6fd;K(JZQ9P2=^io zjqwF<(ol)OW{T-Rf^i>gcU(WRls*u{LXpYwHYYeH9wFHI$}0AbeW4*-zTZ2WpN#E< zRAYb!DcW;5inacO`ZO@?(gDNMP4F~YWh=R_tFv-)IalLXsip>AJKXzQGa*vK9iJk5 z2>ea%)*1kWk8x@V{`8h7KGyv6u$;~-B^&?7*0`#4lVXZB)jfaLJv(ZQ>mfFVI~Uvn zcHy(IZx|R<)eY|GJVs5MTnxvoYJ2**Ml=83Nb)6?dcyA6Ooc#3#+w3yW+JrE+y8AT zj|Ykmazxc#zSgG*Nj%5F$dlQt)uYR#`~6eWB%0Cvlk4)i4Z+8rtv>9mGAaTbaowJZ zw=rW`y5?I3euCvDO+rGA3{GE6eoyeC7)vps;O81n|M<9zqHizYC11G#=ZbdgN5Sdt zjqmTQm)uDkK|hd_fk3ub z;v%V7=bwtM&e;al#6zP0SCWYT{X~VZ$!G^43H)Q>#`MU$lg2NANB{t^$K~(2o2KtT z+Buv{-jn|(kvICJPl!X#vtcgnwuJ-ECzY?>A7?6TeZ)~f`fq0b5e22(*?ZS(pb@!h zFP{N2&)D`W?{K^f{JqeQuNoXQXL0)&I=wMpY9z~$#r40c&ikM3?*IQa(^{!2L5Q}B z_OeG~OSMH^M%v3&)Lunv&&D1pu~oH2?dnBadv9U|wX297i9Hj7Ao%9}+xPQ2f5Gc^ z&g-1lc|ISH`{N-Qr}w;l+I3Z3z?0v8JCOJBXYtE*Tj^nE?Pfd9Pa%8->x5@yzk8k^ z9<;b3gYniua@wrvr*&gJtLoVLTUwx|TBPpL`txLeLTv!?4hqlUeeZxJi|B%4$Sxam z7dZP02tWoZEXg)2LPOpGcGkF6t}v+cfNzAh+r~-B3}q|~>Kh~Ig+hIC1AJ}{kwAs& za@wtVwZs7>s?4W2c3;a4T`9!Z?WLPc@y?N4Y5`x{7M|2JBH?b%=%6FJ zq~j0KEj+v8SR|n=w!*gMSrkjy!^;Q7Hj#;!GI9xJL`&X^x3?0PNz#EHqP! zSXxeVDmE()^HNl2(Gjy?FX!LzOng4a94@subf)i#wOm!wRaoGdVLOW&hs;<9v|EiZ z#?DMZ&~=4)dq$_St%&Rg0zV4$0I1=mYu+ou0?~`T5<5{oJIVf+b{JmPF!hRYF0k)H*W_dcF5lx@_gs|AhG$9ujJv@3WB> zb6^IL5+0n6sWx(RDEdAnPz7M`T3s2Ga5 znrgy=*S-6gww)+VRyk{7v)B^?~OGS;zBl7lT={9y`+Pq{?DmP~$KKWuI?4kN^jF<^Xd@aB9&IXZy?l z($P#|#6C1{uwcRvH{B!eU7t{*z-dQ8?p8=ZA!Cw`9O1Z-0|;D0-iR%|I{q}pR=84@I{RYt~t487P)3gDD{m0vh#IV z>{8rdHpJgcdhB66W8af0hig!w>KL83YP2J_o+$|i-z1xAQNA$w7rgcbgi9@0mXMN984{p+n%9-eH8u$Sw^qJoy z)P{lPe(fG&2?7;Qbd(dFNZ3}Tc%B^&BI!oLx+S4PMW#b`GOK{nE4Mu{qGI~Z?9WMv zCa@lt@?H50AfclGa1D$+o9zIs5dE~|fi+$L+LQU1djRBi6)k{fg~ zFgE zeG8L0opu+w2)Gmqcno3bdp-CcqN>uF4Z$&$L=~MkPpGhzeo0F&6mWi+)@I{8wOC7U z75GO?U}os}@WAKqvO~J@0oKs7+0_KZjW*(+G_@s@4?uo0d~IelPlK}AG^MCSvQJsw z(e=oUcX1}39rJ8#qfO%joGO;^dJCQ3^!!&|ae6azwIWQiCMrQbD!kr0;62Fq3a^ud zs?#Tg)QlqsVDy&n+CqL_w&hqp=;|M22T)C*yV%Z^Sfl!(*`Kn(vs1POwj}Ge*$K}6 z6bjyN8tR+vOFDnW<-qI#<%aAtfLd70a5q!O42PkNxaZPRf$OpE_TX}M_U$QFRcgo7 za|eSq@mx&9nxZd59jc#N7+{d~JV8qcJG?HQLwR(KEo$5CFVpsQJ?3eqfGYLF(%3jo zJO0p{vXbqc$#@YV&;G}T@&5MxJnH=%z>bgBUkYkA;Jd5N4a97Y+RQF)FT;dRl*VrV z6@8L{`e{d`o>%$%l%p5ME89W1K}_v-Of&dt{>*ZuxRa;0VkQ#Ez8!Ay=U@tXTZdA>ROYaj0Cx-4nL{QE z;GR@h`-ikXA;yy6)hqDLQ5%AVng}>_(^HUb%1Z4ai2pp{vx?^-U44-62ZIYHsO7ZF z=pujrOIZ#zD8aQvrKWKb-irFN3MLnhcsiOxt`p_k85S6ft*?)NqPHS3hD^roceicd z74t@Rjq$3ye>^TZTMH}@YU=JV=$hfp*ygbz=;F-C` z&%}hG4i;59^HvvH^ij^nm0S)WE0*5;dGcFNSJx)TJ;>Mhxq$&Gt#^(JVd=v&7OKmY z4vxrFtFebb4)PX5y`A(!S?ig%oVkJ!ht7P3NnUS;ky?#apd0|y3Q3B;zdq#gl~bT) zUom7t+-i`uklnhcmC*9Y-6kcXWxlZ9baILZ<09*|}9s#`_F8j-xXcy`n>{g^cRx<{UWcgbiCu?p+;&43_GJ(|KOe)FRri(>LR~ z8~L7LnB#+nog>yZ1|5}dbt&dk`q{L?W%F4&>5H}K1>(XboOJ~ZZfaC>{n>+D@(D_Q zxU2a!@3xZJ$5V%2j5fjxvk$ZS%=vgG1p3S)pnt2cCAi>v3Amq++kbgCq}2ty?^U(&>F*(reN!j}$qR;X~+kt{I8~P90#lj{Nin5jmR^ zKqOBmZoINTVaE~zj)GpEV6?b(KYWWhha@nXeCQLR#4+m9qsxhXpy^$$kLD>l)*g97 zc8AU_8Lv|FbXV`J&vngZGh$OIj`AOcVv1cY9}NLE9t3R-!dx|G)IORXUY}S+V((`# z!1tG6y_u>BjeME7;#R?b!xZ8s?0!;Ma)Nw|Q}s9FtqwoDx%TsjXP(EF=g+D{O$;4A zK0Qg==UxnkbV;ue$m~O)SMP ze)aogP$#0O2lS0HJU!^w$f8EG2iVvq?GDJwoP4hiq6)P_M9H0S`%u7myz#IA@z z5T>J59#O6vRuK#W(Eqx*F3=^zwQVfwF+;y&@oEJ8a3&riyD(keYp;qureL`)I`co?Y;byy>Y@o?e8G$pxDf|H@lC@r*Ni&Jj$CeNS!Wb|1f{P}{xL1F(`%FtJ0K z145{FLVQ4BFKG->clWC}%{iN?eN`Xf6Fv_%<+wAbIyR%8ZMvG>qOpkjX|a>b8qxee z3BD=J?P2Z=A0#@H8tonM$Y7)$nmd$N?R#m#NfijJt^DN5o=vK9 z@T4)d`miYtFf&IF;E=n`5H*}oWW66uSwaOekVI>jkTy~h z5}uEKp8sXr?SJGdSz78}k?1!v+0EV7U54%f@tt`0bupfgZpsYjGd`x;m`rBv-v`HU zCt60ZLwHY9|GOc%dM|TxQWc-y_^XYPPfN&j-DYe1>WFiGkP|iBg)JjPoV;gSbXe5( zKigB$`2%LE>!*=}=K*!pj?5}M9&>T+liq~|EHo#HyI~sbq;Zg;XVObUzpTLRcrvO2 zsx))u=l^oe3GEKhuvyf}wYza<1G5u1Pqh!SSrNHecVF=@l&EzudS^z76e$^Ze;?vY zy1X#`vwPOqRZ&aA z+W#zuTQ`)(qO7#6o6zyqb`m+$cY>bn@$WAG9F7n=k(-Z8Ba2xtr)_6Q^~kCCH#mwD z8|$qeSW|v#(z@kUR>H!kH{my-yH8ElMd1fEepQFz4SLo(_VeLoE3ZVnCE8nD^?hZ`f zsPJ+aH-A{gDfRt7@P2{Qk)tr$gz>{0&EaL+G3!x#Czf-T4{ndDUmm%4yy_&Lp*J^Y zN19D-uGV0X{gBby&-89e{gbwS2(6DoE&ub|?!&5d@ZVqiF(linXKy)~_C{Vn95(Y~ zt|e(m{bLB~IA9elM6B6dW~Nx^-s@Hiy_XZ~Mtvycm6}^=;=g`774__c>9sFkliNPn zPD83azUPTABp#uqUr&tO(X-HYszk3Q)dv?eHf#^wXENZCLZMLd zWm`@uuT$=Bj01C4VX`_|ibCtG5dOQyt91R}zZqXTL|sBTdj7H>70dZ|l#WJMjR%JO zd5KQvI#JFg8#zI*cWdhY42=tT=1R=VnCnXu7^O>6(LszBW~(Je%|=Mse{dmZ`!ch# zIS~>-UFr!Myx_5ZeK{#m#h7%`eInEtHbn+7=hWzhesai96kIazbnC!=eGVh(KyHQE zRI|)4O=Reg(wZHSg?vt_GwPMdoV9bdg`Fu^BW zvyMe&l;yJupk2gi>a?2gG;OPIN4?>;xMzY6>*b$Zt3O?@33R|^_9VQdJ~Vob!{!maQ%UabVsXD!&UFOW&=*Y(`7oP#F0V_SLg3(S$VsQQ9&_BjqH zld%_O?wwfZ{koZwPuBf}38!PkLtsE&qP}b7%ex>3a@1qBHN9ekeD#w?sI-~0IhP15 zAS*pgVc)z0r<3baF&Zj^FGLk^L-z-)FT61y@v#@w(Jp4zQ2$>G8)llq$`NpmT}yG# zyk7oam=|h!LoG;;Dw*hn!6?Y}%qjJTXRf2an^BD0cdhNkU8mt(7@s;@NcG0Xf@#wO z)EA(e=j@+{;wrS-klP&gJ@dfnFHyes)arVkhf|-#O;%@ym8i;n++~~C62G6kg+mrM z=Qji(=Y7Mb3Nf_{-|Hflw_YoS9Tb` zkBl7v;i=r0A&IAxf@zhjva&gdT!?XhHx5 zFQtI@?57DjKrAh@>FTV45Kea?c9oVaT~MN;RZrsZXM$#KvWD!Os^ZqR)<1&Kw-rgm zSVLk&q3fdQ6pKoSqqT!r@-vi%x4jX*>CEBU=Nt3B9n3@#tB$=TMYCyxzrgC%t*psVC7df2)k&3Jq!?+qLI?gEeHy*Svk8%K zpfcTBTgFizNs<3vn2ida{-{-_o0z~97!T=~#mEQQ`NXL-y;s{TH@HsL?in*3#eVx* z9v4*_YY~Tq%!u9nTT>{GXWkSIi7LTwHC%uRIwMx7B_cEQ{ zZuUHD!`qBdA64DD{VG1R{-5oS*xBHFb<^F|jju>|nP~zCC9?#W-e0GB>ylsHzQ!C7 z`A6DCR%q)ww$E`0)Z<4szjco~s1(XjhIcKKgIKPwu2Dx?964=MAI_nR8XP)Av`anz zz94z*(y9}q1HByz%)#F1=BUpOV-NVm%f_4~W+-)y+Uj3i@p0VH%)rcSweDLQh6|Ku z3G!1feeutKu&cGb$#G3G28gK*k0}6hhybC}aNSUv(crY|X&2bkGwAUT>_d0F+?m|x z`3698Z~|z}^#)EStVS7|qUa~Vf45>xe`Hyrj4&&}{@J3N*XO}`K1wkBT6u#GhA0O1 zO*b-D!;P`3`QCRd1QA>zfz?rLXa${5usq>bt^$oXFVEOxjOpB$f^{Pe2X|Y->HBM$ z-cXNzVKg0*qiMu(S#etR(!SkWigi5!<_uJMOgy5sSTpQ3+?*afX9Yb z_n|nZkZnuBZ1CeEJ4PWn4s}fb1IicnSty(&+J?P387(a{YGb6sQJSnJVL2B6f&H$z zQ;V5rhevc1&%))VlS71 z(5@A#&5#MSVO+BS3N$n&?pUKclaOpXz`0*#aXvjBx(Tn2 z#1&5zW`ZdEDk5onSk2I-NpuXL%BeeB>h=z>-UqBu^hvnBO zNQtGuxdc6Usr?jL1jOQRxAnY%W+uiMxH1jdsPuhE#z$obP_#Xq4?1~g4wI$Y)-)Ab zf4SgNg@`nI4y`e{PTwBzE+H^{a19IPAN|E_DIRQ*?AHd?qKT43NsVga(ujM}%=(XB^u%09R4A|fK^WyGN1KF z@0a0CtX1mw@Et6fP-aUO{vkzQ!hcH2C@i{;*qzpQC5!kzYRKOE!&bGWir$`0GcAD) zAc$ai8H!)IA;-x;JC}>*6Sn%g8+*>%d(6t;QG+8$P4Cw{GSAN}&e5+sM}6 zbm#d%OIoprUW^YZtPyZU^X;nPfvghl0KO071?o*^AvH)Qp`yn&1FLT}qivaqK=rgFc)$9v&EoejS6)gj#?*PGNCu<3T0hQ zv+T-GQ+=uo>sQ|&84SY7KayG<7=|fD;<7_Xuv$b9&q<+j(oW^I)5jo4g1z~w=D~YF zyl1iE$f(%DZhtgpm4X1iwq&YuQ<@A~RQVzWwAJPjId3_&GMqd5RmJ=c8e3&q0>N&TT#l|k7 zi9~|e7BXzN;x#zdtTJUivC9r69CV!>8GIo%olY%2sXS)5wWpihG#zKwKX?w53;C+& zsVbgMLK7=WH{+x1w9kG#C)sMTN zfwbQ=8;z`g;>}RN@}K9LtiP-fF{dNleOV&2{tTa&dn-V7xocti_L;)iJO}@W{_I=H z&eOTl+VoUiJBiOP9RkE_P-bN5U81_|_XY`n?&Aq@s`edGFCNvC>+SM&2=U?UHDr%y zD2_iA(c@9c)p8?r3g2F5_ucEX(Ig=;w4vj1waei!jd>|uG4b*VQc{LYWMA zFp+frItK-3)qn5Hqs~GMfj3;QzLXFr#$`J*6ki~`-@|=MNesO|3t?r*C#=q_>}Gbe6P*< zRPocS{;}H1IF&#-V@niNAw{h5gFJ#94OF|o{=735#w3lyvzM>CB{7SsjIKe-hMhgL z-I{ge=hnU0b1SD)qhPlG{Ax2ht8Qg7E zC;JbwZR3(k{rz?Zuqd^|;`e+OHn{sUE&xCdt&2i3#89K9aAQ_N_J=<2V40pCB^nrJ z20=`9EdCW?{C>b#@{Qaf*3(f%UYiM>c4yXw8L`towswbi%=@>Ee8=sNtvX4bgN3L? z+uCU>I&rMc{oQ8f-UtnibjjCK$w^BfKxwv`uN?qNC$YLQ8u&D$fwE`*1DGGaqz0W6VF%*ej%V`gO#VDYzm_ko4~DF;NAr37+Y3Y62!5 z`F8uCtHml7x|xk}eZjGcsi2{dHcXMU%%5hbam1fA_TsMOuaIo#RKU7;7|==vXCUOP z%i)}~drDKF#0## zwt(`yJlV^cc8~TP%JwuZ{xPL^f{|aOc}ykTd<#Ccpjpa-dQ5l5-tmy0>Zf?*y`-Uo zTc(wcxB*|>>IT-x{~@8sMBB&P#_9{ms7Z=R5qv*FEF(Y)aI^Resh4nSMD1h5c5aMalwo!-|R_~LQ*~8RIJASji!LGbd#`||*e$!rZNC15K zp+AjZpIWWrFO1m-t8fDQ(g(+ebPoLtj?1R^<_Yoq*7LtyHXgjJD6mtQ5peJre52|N z`$6*$Ciq|g3km;$fp-wL*qZP})~xXZU6Cw-M}*9MznUa`(tY_W1cmi%sx_obH* zf6(5_Zut5*dToSQpl(hF4?By6%*|5Q#vO_mOs#MHMxyxaKq|EP~fvgM!1;b__-R ziB4~4_f>B>0`b!~@(_PJwd6R+NpW2PJd+~|yi1Y!xy#pxluu80?FJVJS~%^L`COg) z+t5xRo$b$bz9iByZ#TOmbudGrAXA?8H$96$qK;03a&%>uWX}ASxT)8ZkU7<^o=V1> zT8qODOl5hI9J$Bnoh61ZXdSLlbO%a>1CUh7k(%8P&#u;ptM;cu^J#cq*0(lyxPfCk zWD@9u-rrPzytg>JeV@lt{mnw8OLrC2FOtee_d;G^pP)1BEHN9$TFjc2ligMLyJrwu zHpC59S!+PYiwm+*p3GrRIx^BR_N_at-KJpkx=qIu{Olmb892E zTI0A*9+Yzh!Me}0N#fZgrYJW^?UcKF?>xwH@Gc#z2h@m-y37ji_?;f{Zgu-WM^69^=USfM2WCyjO$#8@d5i?gQM5^O3vtt^ zSPvZf3{r2CSuS>iU{?x~)20GU-xvwvwaPD)Qr^sDp|3M&NcePWpOae~$?pxrqF9!+ zsw)uU)0GTcY~VvCDlVZw7nbIDTn|wCK~zuD<+7doKpK#3Zn@7eYjs>XqSr)Xs`9~z zqQ>^0%$v#Crk>a?XRAL(`hhNRjfT#&@*;QjfY)KGkAZ0h0kQ0}yb=FNN>?VnRvqAk zWo4iIOs$n@xrTjnXg;?~Ga*QH|DCOw(6;q!=6OFpqg= zvL?OlokAFOAlq__Tg>{_>F8YAy&UYZN$~!`yQ-q_r3|MqcFv^MMfhRG!OOpcMLl*- zC(MSH%QS{hF`mCs(m5)dVBv|jsYi|9)2bWU^cEPS59vn)l%F;hbM0<>W%<;CHAryk zszt5gphznNPsc!DkCB9hSpmY_gFK`xaq{bvui)=Wv59{}mhVAeyk1^AF~uXpRs{>C z0Rknr)9p-IHU1n|i%}2{2dtmqX07)!JggZzMK)ttIVx<&qMY<0N)^wM%DI@?DSiSR zwPpeEOz5#LgM`QEc#$Xm7NC%pP{=vY|HL-)Ld%L*U40Pc@ohxqawTZbK;iO_CMlZy_S~B|L;t#*+T&5Kvy_l{j_9p9jgGcVxgbW)t*3FhZbua{l%4U@ zqvs)B1R-ha0cv*W3zaN5-Iw3We!1UV!!2xnv0n`vfLl1##>A^jwGlUh{?cx5m1j%0 z5+g75o3~ECQEh<^tLJc(Rfq7y01}7ZpHnSrr+?_Bdw-MPRl0G3Al=4u+vEH6*q1Ou zInY#?I5PP&g-J0C%kvB z*MB}{ab`+%`)Os3*Rgg|sUIsU#HntgikAkb^g_ck)zv#vtSnAlYi)nj2V?sf!cOh1 zhB?*zJdrdj@Oo8 zq2A-nvFxg!D;=csb%;5Xy1u-}?&uMkIBj3@818o}gErkN;OF)5SP`z1ZJaDjL-Nj# z51;E=t;E#(SGH)@i?09q)AY=P;)?6|l&WOco06+UJn4UF@I~0a?Jg8X_Oa^z89Mw# z!|rBkJMU1gY-jyRy$<0GyYfJnW?Yf9*d3sT%(V-AMN~5DY;67rrUAC(G`@9>EuBhG zUR#_S$gfRSOc58*AQBjK)Q40B)VjHor9-w7OU*VEIrPq#NVyLPth2#!g0y*<71)2( zwWui_t@cOX5ObKFDR#yn*a1?s{zmaMVpDno)@hqo9{*u*mWm~F%oP{Ropg3gq$_(H ziosR<8F^r)|5pMsz6EgXDa2{E_1xQZFE}2StB_8HfYh$wWi__olK*9Zic$+Dac-36$^qU{Lu=bYrmW4ZB zwOE?%;MZiwWQ3RmR*;yu)Q3u}oj(+gR`c|#;6`OPp{ELy+v^Ja?ude6D@*;}Fs&6Q zD*^HGg&)Mdt<41TvKCiN9_BnP-3YEsk0fc=jG1kx{8%c)l_IXsD>0S2|hFmA7I)!$! z!4OYMC?;=%__kLk`Cv|VCC8tF=LXPdJ*ktlYFSyqNloRY&v3u@#R9YAWWnI+H?ze{ z>j%gwwbzS!lOrv2#UG^K*;h$k0Vl=(yNG|y`7)e`BY%9a(}m<``%<4rDaijsX$!no z>{eM@3B}YinW>tCX&&_iDFuHiUZ+RE#}HrYKx}S-8dhYxa|tCRAs2$}0~eAuZ+a+w zYlEsCx!v{7Lu##WyqUe%84Xvd&dl6c?i#k3>H7j!$@-buK-~EG32J#Tf(m5R;Dls;dD8G|4jc@n1se45)iq@VNuN@&zFl|3pk(6V$!pc8ywrqoCnJOI zdl110T`a1+oIQ2W=V-$p*&CMgdx$h)#f=c>_7ujiqWz}X;}5qyC9$9y%kp_ooI7x1 z##uUljo^9C8N|XC;x;uexpC^_xOjm#lXUm;jH}<`pYq+_5yAf+;4}_;rA+eSMa?ce z11EVkRA9$C28}a`5kPQQz`=x6x$9DcY|8BF(agRXO7$jNY~4iPG@MVRpoqAppCp;vvD}2 z8Q8XZYmxH6uw2q#MT6`sv(%d*l}jNZ^+wvJghMJxB<9j7MGLW_ea_>pqvo6ofyr?z z4gGK?q0hY3pGKrVwNvt00^_KVEeke#A2lJ$Opl#7WVYZpK?3F5B$e&~Ukvf@Fl0LTM2AKJ|lgogzkiDyC6*IWnY6BOuB@?X&rFLf#WPTbv zxYm9qcY&x7R*LD;fK22ouLv6+$xOV1KX{Y#Ykry+b~Hn*6|`Ha?Y0N#WwPrx{yS@@Ld!1`Tf_4r=TFgH>6G0EJzOSi{zE@%g+OZf zE^7f2mfvUe#pUMxb_A&a$m*e^La@Wdr{yAuLh#c|nPAgDYWrz=eymd{poZ)I%vMo; zuxCdd?{ft6=>Ex**oDp<-T5&R`RRvCC<=@Pfv}8XgeOuML2_^wVIiYbcCP2FY2N2# z$VV-B-J?A!C9$PDT*M>vqWuB<-?Mnpc16Pxlx6LNp|pQ+ce`lMtK!1RPb&Rxlj&iB zbrTl)I(}PhAW$T$b-;_@Zkkn2!EqRmOt}(m$1123;z?&OV zGGpBQ#%yQE2;@~Or%f>{9(H@526ERJQ6?rtE~JCfOED+Y z$!`?KMPl}b+q<%JZ=3N$^y*XMUQ~Fth0W(&e??iiKC{g6)MFbKKY`s{M^ zWJP|&X*SLOR2O?V74QP5&ooDEc)32KM5uOMaA%qU%!~|*XRDW=#bee5heF!wt6YH5 z%E!j#K094j4BZGP4Hw7+qg8RtJVKwbUv8s@gw%c#{MGVFrUu+;M)=AOT77kc}anf zj|5MIAl!{a2KORy^G)MU^5>#Sq_wA8|47!@)~(`KXWxKJ55Es_lyAHDM9FXc1-}^f zR%9-=m{v{b_3$oc_G;t4gb*g$u+?D($3=lHT}rcUHREtm<0ijQYi^?I+qPiu5Q%_G z?uxZWsn&?f-b1rn@@IC0o!D1%3j=FU6T;?mh?I--v%MmDZ2aTyHnr~k%=DaY%#w&! ziObpVF|3}q8Mc`I{~tn)^t;0IPJTi^_Cgbqd@uSQPzD=^g0?3g?g2D>EOS!NtkXd8 zk9I+S2wILUz_wufe1qW8-brxKj#TG~=1aJ`ItuG@(nkkp?FZ}ANU)htg&_Q?Zv$Jf zRm#E>d8aYG)`H_vS|<8_-T6$RqLWUwuh~uD=Nv-xMSBda_LC+2XOd#pS2=5;g}bXm zfzIwZ=H>@G;jcr@fg4_)H#4oQ=cXufT7t{5CSHU{q?fhA(q8>#@cn%4vTIa|z_k)r zNvID&q*j%~ej8G4}dpUbD=&<&i=U5vT_ac8!EF{p&M zo39YzPdiF59tyVRR910 literal 0 HcmV?d00001 From ed0e871645855418908da33ea3b148183caf2c93 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:23:49 -0600 Subject: [PATCH 004/135] Update README.md - Added hyperlink to the title - Added output screenshot --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a14f99a01..5a25ba0e6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# e-mission phone app +# [e-mission phone app](https://github.com/e-mission/e-mission-phone/tree/master) __This is the phone component of the e-mission system.__ @@ -229,7 +229,8 @@ For instance: (build-dev-android) ``` npm run build-dev-android ``` - +Your output should look something like this: +![Build Successful Message screenshot](Build_ss.png) ## 4. End to End Testing From 54656b8d0992e9946b9cd20d86e843814d3b1f18 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:29:52 -0600 Subject: [PATCH 005/135] Update README.md - Updating markdown anchors --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5a25ba0e6..ed7c03d22 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,16 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check 6. Contributing if you're interested in contributing for this project :sparkles: +:sparkles: Check 6. [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: ## Contents -#### 1. [Creating logos](#1.-Creating-logos) -> Information regarding app Logo -#### 2. [Updating the UI only](#2.-Updating-the-UI-only) -> For UI changes ONLY -#### 3. [Updating the e-mission-* plugins or adding new plugins](#3.-Updating-the-e-mission-\*-plugins-or-adding-new-plugins) -> Work with native code -#### 4. [End to End Testing](#4.-End-to-End-Testing) -#### 5. [Beta-testing debugging](#5.-Beta-testing-debugging) -#### 6. [Contributing](#6.-Contributing) +#### 1. [Creating logos](#1-creating-logos) -> Information regarding app Logo +#### 2. [Updating the UI only](#2-updating-the-ui-only) -> For UI changes ONLY +#### 3. [Updating the e-mission-* plugins or adding new plugins](#3-updating-the-e-mission--plugins-or-adding-new-plugins) -> Work with native code +#### 4. [End to End Testing](#4-end-to-end-testing) +#### 5. [Beta-testing debugging](#5-beta-testing-debugging) +#### 6. [Contributing](#6-contributing) + --- ## 1. Creating logos From 290dcc5038c671abf733fb3288600435c3129973 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:34:56 -0600 Subject: [PATCH 006/135] Update README.md Updating markdown anchors as 4,5, & 6 weren't working --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed7c03d22..e9eb61229 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check 6. [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: +:sparkles: Check [6. Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: ## Contents #### 1. [Creating logos](#1-creating-logos) -> Information regarding app Logo From 809fc5d4fe89e17c9f5d40dced40c41eb46f0a8a Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:46:50 -0600 Subject: [PATCH 007/135] Update README.md - touch up - fixed non-working markdown anchors --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e9eb61229..e267f8e71 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check [6. Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: +:sparkles: Check [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: ## Contents -#### 1. [Creating logos](#1-creating-logos) -> Information regarding app Logo -#### 2. [Updating the UI only](#2-updating-the-ui-only) -> For UI changes ONLY -#### 3. [Updating the e-mission-* plugins or adding new plugins](#3-updating-the-e-mission--plugins-or-adding-new-plugins) -> Work with native code +#### 1. [Creating logos](#1-creating-logos) +#### 2. [Updating the UI only](#2-updating-the-ui-only) +#### 3. [Updating the e-mission-* plugins or adding new plugins](#3-updating-the-e-mission--plugins-or-adding-new-plugins) #### 4. [End to End Testing](#4-end-to-end-testing) #### 5. [Beta-testing debugging](#5-beta-testing-debugging) #### 6. [Contributing](#6-contributing) From b64ab621b0325c95166950473807d67942dcb9b3 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 25 Sep 2023 00:29:30 -0600 Subject: [PATCH 008/135] Update README.md Removed manual numbering and updated markdown anchors --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e267f8e71..76d56ea88 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,16 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone :sparkles: Check [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: ## Contents -#### 1. [Creating logos](#1-creating-logos) -#### 2. [Updating the UI only](#2-updating-the-ui-only) -#### 3. [Updating the e-mission-* plugins or adding new plugins](#3-updating-the-e-mission--plugins-or-adding-new-plugins) -#### 4. [End to End Testing](#4-end-to-end-testing) -#### 5. [Beta-testing debugging](#5-beta-testing-debugging) -#### 6. [Contributing](#6-contributing) +#### 1. [Creating logos](#creating-logos) +#### 2. [Updating the UI only](#updating-the-ui-only) +#### 3. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) +#### 4. [End to End Testing](#end-to-end-testing) +#### 5. [Beta-testing debugging](#beta-testing-debugging) +#### 6. [Contributing](#contributing) --- -## 1. Creating logos +## Creating logos If you are building your own version of the app, you must have your own logo to avoid app store conficts. Updating the logo is very simple using the [`ionic @@ -31,7 +31,7 @@ command. **Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work -## 2. Updating the UI only +## Updating the UI only [![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). @@ -84,7 +84,7 @@ source setup/activate_serve.sh **Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. -## 3. Updating the e-mission-\* plugins or adding new plugins +## Updating the e-mission-\* plugins or adding new plugins [![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) [![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) @@ -233,7 +233,7 @@ npm run build-dev-android Your output should look something like this: ![Build Successful Message screenshot](Build_ss.png) -## 4. End to End Testing +## End to End Testing A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: @@ -248,7 +248,7 @@ In order to make end to end testing easy, if the local server is started on a HT One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. -## 5. Beta-testing debugging +## Beta-testing debugging If users run into problems, they have the ability to email logs to the maintainer. These logs are in the form of an sqlite3 database, so they have to be opened using `sqlite3`. Alternatively, you can export it to a csv with @@ -263,7 +263,7 @@ python bin/csv_export_add_date.py /tmp/loggerDB. less /tmp/loggerDB..withdate.log ``` -## 6. Contributing +## Contributing 1. Add the main repo as upstream From 72922cff1c2fb2b4797106cf5bdcb0ead702700f Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Mon, 25 Sep 2023 00:42:26 -0600 Subject: [PATCH 009/135] 1. Changed expected output for plugins 2. Removed tested on sub-section 3. Revised formatting for activation sub-section -> for better clarity --- README.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 76d56ea88..0b19e492e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: +:sparkles: Check [Contributing](#contributing) if you're interested in contributing for this project :sparkles: ## Contents #### 1. [Creating logos](#creating-logos) @@ -101,13 +101,6 @@ have now: If you have setup failures, please compare the configuration in the **passing CI builds** with your configuration. That is almost certainly the source of the error. -### Tested on -__MacOS__ -- Intel chip, MacOS Ventura 13.6 -- Intel chip, MacOS Ventura 13.5.2 -- Intel chip, MacOS Ventura 13.0 -- Intel chip, MacOS Monterey 12.6.7 - Pre-requisites --- - The version of xcode used by the CI. @@ -196,9 +189,8 @@ If connecting to a development server over http, make sure to turn on http suppo ``` -__3. Run this in every new shell__ +__3. Run this in every new shell for Activation__ -- __Activation__ ``` source setup/activate_native.sh ``` @@ -218,8 +210,9 @@ Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> pa ``` +
-- __Pick a type of build and execute the following:__ + __4. Pick a type of build and execute the following:__ More "versions" are available in [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) ``` @@ -230,8 +223,17 @@ For instance: (build-dev-android) ``` npm run build-dev-android ``` -Your output should look something like this: -![Build Successful Message screenshot](Build_ss.png) +
Your expected output should look something like this + +``` +BUILD SUCCESSFUL in 2m 48s +52 actionable tasks: 52 executed +Built the following apk(s): +/Users//e-mission-phone/platforms/android/app/build/outputs/apk/debug/app-debug.apk +``` + +
+ ## End to End Testing From 728a7f53afe24926ac5c909f6ead55320e62a8c1 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 28 Sep 2023 09:13:49 -0600 Subject: [PATCH 010/135] rename the angular service with ng prefix hoping to avoid naming problems when creating the new ts service by the same name --- www/js/control/{uploadService.js => nguploadService.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename www/js/control/{uploadService.js => nguploadService.js} (100%) diff --git a/www/js/control/uploadService.js b/www/js/control/nguploadService.js similarity index 100% rename from www/js/control/uploadService.js rename to www/js/control/nguploadService.js From 2049c59edf4e5987fe283a27fbcf934dafe43660 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 28 Sep 2023 09:14:57 -0600 Subject: [PATCH 011/135] start uploadService.ts and tests starting to convert the service, but running into a fair number of issues, especially because of complex ionic popups --- www/__tests__/uploadService.tests.ts | 2 + www/js/control/uploadService.ts | 180 +++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 www/__tests__/uploadService.tests.ts create mode 100644 www/js/control/uploadService.ts diff --git a/www/__tests__/uploadService.tests.ts b/www/__tests__/uploadService.tests.ts new file mode 100644 index 000000000..50a253a2e --- /dev/null +++ b/www/__tests__/uploadService.tests.ts @@ -0,0 +1,2 @@ +import {} from "../js/control/uploadService"; + diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts new file mode 100644 index 000000000..e359bed88 --- /dev/null +++ b/www/js/control/uploadService.ts @@ -0,0 +1,180 @@ +import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; +import { useTranslation } from "react-i18next"; + +/** + * @returns A promise that resolves with an upload URL or rejects with an error + */ +function getUploadConfig() { + return new Promise(function (resolve, reject) { + //logInfo( "About to get email config"); + let url = []; + fetch("json/uploadConfig.json").then( function (uploadConfig) { + //logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['data'])); + url.push(uploadConfig["data"].url); + resolve(url); + }).catch(function (err) { + fetch("json/uploadConfig.json.sample"). then(function (uploadConfig) { + //logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])); + console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])) + url.push(uploadConfig["data"].url); + resolve(url); + }).catch(function (err) { + //logError("Error while reading default upload config" + err); + reject(err); + }) + }) + }) +} + +function onReadError(err) { + displayError(err, "Error while reading log"); +} + +function onUploadError(err) { + displayError(err, "Error while uploading log"); +} + +function readDBFile(parentDir, database, callbackFn) { + return new Promise(function(resolve, reject) { + window['resolveLocalFileSystemURL'](parentDir, function(fs) { + fs.filesystem.root.getFile(fs.fullPath+database, null, (fileEntry) => { + console.log(fileEntry); + fileEntry.file(function(file) { + console.log(file); + var reader = new FileReader(); + + reader.onprogress = function(report) { + console.log("Current progress is "+JSON.stringify(report)); + if (callbackFn != undefined) { + callbackFn(report.loaded * 100 / report.total); + } + } + + reader.onerror = function(error) { + console.log(this.error); + reject({"error": {"message": this.error}}); + } + + reader.onload = function() { + console.log("Successful file read with " + this.result.byteLength +" characters"); + resolve(new DataView(this.result)); + } + + reader.readAsArrayBuffer(file); + }, reject); + }, reject); + }); + }); +} + +const sendToServer = function upload(url, binArray, params) { + //attempting to replace angular.identity + var identity = function() { + return arguments[0]; + } + + var config = { + method: "POST", + body: binArray, + headers: {'Content-Type': undefined }, + transformRequest: identity, + params: params + }; + return fetch (url, config); +} + + +//only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") +export function uploadFile(database) { + const { t } = useTranslation(); + getUploadConfig().then((uploadConfig) => { + var parentDir = "unknown"; + + if (window['cordova'].platformId.toLowerCase() == "android") { + parentDir = window['cordova'].file.applicationStorageDirectory+"/databases"; + } + else if (window['cordova'].platformId.toLowerCase() == "ios") { + parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; + } else { + alert("parentDir unexpectedly = " + parentDir + "!") + } + + const newScope = {}; + newScope["data"] = {}; + newScope["fromDirText"] = t('upload-service.upload-from-dir', {parentDir: parentDir}); + newScope["toServerText"] = t('upload-service.upload-to-server', {serverURL: uploadConfig}); + + let didCancel = true; + let detailsPopup = () => console.log("I need a popup"); + + // const detailsPopup = $ionicPopup.show({ + // title: i18next.t("upload-service.upload-database", { db: database }), + // template: newScope.toServerText + // + '', + // scope: newScope, + // buttons: [ + // { + // text: 'Cancel', + // onTap: function(e) { + // didCancel = true; + // detailsPopup.close(); + // } + // }, + // { + // text: 'Upload', + // type: 'button-positive', + // onTap: function(e) { + // if (!newScope.data.reason) { + // //don't allow the user to close unless he enters wifi password + // didCancel = false; + // e.preventDefault(); + // } else { + // didCancel = false; + // return newScope.data.reason; + // } + // } + // } + // ] + // }); + + logInfo("Going to upload " + database); + const readFileAndInfo = [readDBFile(parentDir, database, detailsPopup)]; + Promise.all(readFileAndInfo).then(([binString, reason]) => { + if(!didCancel) + { + console.log("Uploading file of size "+binString['byteLength']); + const progressScope = {...newScope}; //make a child copy of the current scope + const params = { + reason: reason, + tz: Intl.DateTimeFormat().resolvedOptions().timeZone + } + uploadConfig.forEach((url) => { + alert(t("upload-service.upload-database", {db: database}) + + "\n" + + t("upload-service.upload-progress", {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) + ); + // const progressPopup = $ionicPopup.show({ + // title: t("upload-service.upload-database", + // {db: database}), + // template: t("upload-service.upload-progress", + // {filesizemb: binString['byteLength'] / (1000 * 1000), + // serverURL: uploadConfig}) + // + '
', + // scope: progressScope, + // buttons: [ + // { text: 'Cancel', type: 'button-cancel', }, + // ] + // }); + sendToServer(url, binString, params).then((response) => { + console.log(response); + //progressPopup.close(); + displayErrorMsg(t("upload-service.upload-details", + {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}), + t("upload-service.upload-success")); + }).catch(onUploadError); + }); + } + }).catch(onReadError); + }).catch(onReadError); + }; \ No newline at end of file From 7de88284bd14f8d66d5d3fc45a268fbc36ee3e4b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 28 Sep 2023 09:16:34 -0600 Subject: [PATCH 012/135] add logError fcn to Logger.ts the upload service called for a log at the error level, adding support for that in logger.ts --- www/js/plugin/logger.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index c4e476de1..9f6d41d73 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -33,6 +33,9 @@ export const logInfo = (message: string) => export const logWarn = (message: string) => window['Logger'].log(window['Logger'].LEVEL_WARN, message); +export const logError = (message: string) => + window['Logger'].log(window['Logger'].LEVEL_ERROR, message); + export function displayError(error: Error, title?: string) { const errorMsg = error.message ? error.message + '\n' + error.stack : JSON.stringify(error); displayErrorMsg(errorMsg, title); From c0d651cc94c030bc27c3bae9f9be21d0bdc46874 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 28 Sep 2023 11:41:47 -0700 Subject: [PATCH 013/135] Remove old angular service file --- www/index.js | 1 - www/js/splash/customURL.js | 40 -------------------------------------- 2 files changed, 41 deletions(-) delete mode 100644 www/js/splash/customURL.js diff --git a/www/index.js b/www/index.js index 66a0d45df..578b3dc75 100644 --- a/www/index.js +++ b/www/index.js @@ -13,7 +13,6 @@ import './js/config/imperial.js'; import './js/config/server_conn.js'; import './js/stats/clientstats.js'; import './js/splash/referral.js'; -import './js/splash/customURL.js'; import './js/splash/startprefs.js'; import './js/splash/pushnotify.js'; import './js/splash/storedevicesettings.js'; diff --git a/www/js/splash/customURL.js b/www/js/splash/customURL.js deleted file mode 100644 index 521244bc0..000000000 --- a/www/js/splash/customURL.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.splash.customURLScheme', []) - -.factory('CustomURLScheme', function($rootScope) { - var cus = {}; - - var parseURL = function(url) { - var addr = url.split('//')[1]; - var route = addr.split('?')[0]; - var params = addr.split('?')[1]; - var paramsList = params.split('&'); - var rtn = {route: route}; - for (var i = 0; i < paramsList.length; i++) { - var splitList = paramsList[i].split('='); - rtn[splitList[0]] = splitList[1]; - } - return rtn; - }; - - /* - * Register a custom URL handler. - * handler arguments are: - * - * event: - * url: the url that was passed in - * urlComponents: the URL parsed into multiple components - */ - cus.onLaunch = function(handler) { - console.log("onLaunch method from factory called"); - $rootScope.$on("CUSTOM_URL_LAUNCH", function(event, url) { - var urlComponents = parseURL(url); - handler(event, url, urlComponents); - }); - }; - - return cus; -}); From 46301f7db29f191c2489b8c49ec78ad078a3c0dc Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 28 Sep 2023 11:42:11 -0700 Subject: [PATCH 014/135] write new customURL function --- www/js/splash/customURL.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 www/js/splash/customURL.ts diff --git a/www/js/splash/customURL.ts b/www/js/splash/customURL.ts new file mode 100644 index 000000000..46460a6d7 --- /dev/null +++ b/www/js/splash/customURL.ts @@ -0,0 +1,18 @@ +type UrlComponents = { + [key : string] : string +} + +type OnLaunchCustomURL = (rawUrl: string, callback: (url: string, urlComponents: UrlComponents) => void ) => void; + + +export const onLaunchCustomURL: OnLaunchCustomURL = (rawUrl, handler) => { + const url = rawUrl.split('//')[1]; + const [ route, paramString ] = url.split('?'); + const paramsList = paramString.split('&'); + const urlComponents: UrlComponents = { route : route }; + for (let i = 0; i < paramsList.length; i++) { + const [key, value] = paramsList[i].split('='); + urlComponents[key] = value; + } + handler(url, urlComponents); +}; \ No newline at end of file From ea5b214a4d3976274e1634ed7d5f6aab3a601a53 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 28 Sep 2023 11:42:36 -0700 Subject: [PATCH 015/135] Call onLaunchCustomURL directly in join-ctrl because customURL only gets called in join-ctrl. --- www/js/join/join-ctrl.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/www/js/join/join-ctrl.js b/www/js/join/join-ctrl.js index 85d6424c1..e99829b13 100644 --- a/www/js/join/join-ctrl.js +++ b/www/js/join/join-ctrl.js @@ -1,4 +1,5 @@ import angular from 'angular'; +import { onLaunchCustomURL } from '../splash/customURL'; angular.module('emission.join.ctrl', ['emission.splash.startprefs', 'emission.splash.pushnotify', @@ -7,7 +8,7 @@ angular.module('emission.join.ctrl', ['emission.splash.startprefs', 'emission.splash.remotenotify', 'emission.stats.clientstats']) .controller('JoinCtrl', function($scope, $state, $interval, $rootScope, - $ionicPlatform, $ionicPopup, $ionicPopover) { + $ionicPlatform, $ionicPopup, $ionicPopover, ReferralHandler, DynamicConfig) { console.log('JoinCtrl invoked'); // alert("attach debugger!"); // PushNotify.startupInit(); @@ -37,9 +38,15 @@ angular.module('emission.join.ctrl', ['emission.splash.startprefs', function handleOpenURL(url) { console.log("onLaunch method from external function called"); - var c = document.querySelectorAll("[ng-app]")[0]; - var scope = angular.element(c).scope(); - scope.$broadcast("CUSTOM_URL_LAUNCH", url); + onLaunchCustomURL(url, function(url, urlComponents){ + console.log("GOT URL:"+url); + if (urlComponents.route == 'join') { + ReferralHandler.setupGroupReferral(urlComponents); + StartPrefs.loadWithPrefs(); + } else if (urlComponents.route == 'login_token') { + DynamicConfig.initByUser(urlComponents); + } + }) }; $scope.scanCode = function() { From c9f1552feca8c533e2a3d1125402e6e560987fcf Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 28 Sep 2023 11:42:48 -0700 Subject: [PATCH 016/135] Remove calling customURL in app.js because customURL gets called in join-ctrl now --- www/js/app.js | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/www/js/app.js b/www/js/app.js index a52edaa12..2d0b1d08d 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -27,14 +27,12 @@ import 'ng-i18next'; angular.module('emission', ['ionic', 'jm.i18next', 'emission.controllers','emission.services', 'emission.plugin.logger', - 'emission.splash.customURLScheme', 'emission.splash.referral', - 'emission.services.email', + 'emission.splash.referral','emission.services.email', 'emission.intro', 'emission.main', 'emission.config.dynamic', 'emission.config.server_conn', 'emission.join.ctrl', 'pascalprecht.translate', 'LocalStorageModule']) -.run(function($ionicPlatform, $rootScope, $http, Logger, - CustomURLScheme, ReferralHandler, DynamicConfig, localStorageService, ServerConnConfig) { +.run(function($ionicPlatform, $rootScope, $http, Logger, localStorageService, ServerConnConfig) { console.log("Starting run"); // ensure that plugin events are delivered after the ionicPlatform is ready // https://github.com/katzer/cordova-plugin-local-notifications#launch-details @@ -44,17 +42,6 @@ angular.module('emission', ['ionic', 'jm.i18next', // TODO: Although the onLaunch call doesn't need to wait for the platform the // handlers do. Can we rely on the fact that the event is generated from // native code, so will only be launched after the platform is ready? - CustomURLScheme.onLaunch(function(event, url, urlComponents){ - console.log("GOT URL:"+url); - // alert("GOT URL:"+url); - - if (urlComponents.route == 'join') { - ReferralHandler.setupGroupReferral(urlComponents); - StartPrefs.loadWithPrefs(); - } else if (urlComponents.route == 'login_token') { - DynamicConfig.initByUser(urlComponents); - } - }); // END: Global listeners $ionicPlatform.ready(function() { // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard From 92ce70760b3eed6b1c05b4fea9a82f5a1b361649 Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Fri, 29 Sep 2023 09:54:52 -0600 Subject: [PATCH 017/135] 1. reformatted to the original structure 2. removed floating links --- README.md | 56 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0b19e492e..ca3e9c93b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ __This is the phone component of the e-mission system.__ -:sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to [android API 33 and the latest iOS versions](https://github.com/e-mission/e-mission-docs/issues/934), [cordova-lib@10.0.0 and the most recent node and npm versions](). It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ +:sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to the **latest Android & iOS versions**, **cordova-lib@10.0.0 and the most recent node and npm versions**. It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ + +For the latest versions, refer [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) ## Additional Documentation Additional documentation has been moved to its own repository [e-mission-docs](https://github.com/e-mission/e-mission-docs). Specific e-mission-phone wikis can be found here: @@ -22,14 +24,6 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone --- -## Creating logos - -If you are building your own version of the app, you must have your own logo to -avoid app store conficts. Updating the logo is very simple using the [`ionic -cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) -command. - -**Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work ## Updating the UI only [![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) @@ -84,6 +78,21 @@ source setup/activate_serve.sh **Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. +## End to End Testing + +A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: + +1. installing a local server, +2. running it, +3. loading it with test data, and +4. running analysis on it + +are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). + +In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). + +One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. + ## Updating the e-mission-\* plugins or adding new plugins [![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) @@ -109,7 +118,7 @@ Pre-requisites - **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - git - Java 17. Tested with [OpenJDK 17 (Temurin) using AdoptOpenJDK](https://adoptium.net). -- if you are not on the most recent version of OSX: `homebrew` +- Always use [homebrew](https://brew.sh) in addition to CLI - this allows us to install the current version of cocoapods without running into ruby incompatibilities - e.g. https://github.com/CocoaPods/CocoaPods/issues/11763 @@ -234,21 +243,16 @@ Built the following apk(s): +
-## End to End Testing - -A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: - -1. installing a local server, -2. running it, -3. loading it with test data, and -4. running analysis on it - -are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). +## Creating logos -In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +If you are building your own version of the app, you must have your own logo to +avoid app store conficts. Updating the logo is very simple using the [`ionic +cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) +command. -One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. +**Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work ## Beta-testing debugging If users run into problems, they have the ability to email logs to the @@ -288,7 +292,7 @@ less /tmp/loggerDB..withdate.log __\*__Address my review comments__\*__ -Once I merge the pull request, pull the changes to your fork and delete the branch +Once I merge the pull request :smiley: :tada:, pull the changes to your fork and delete the branch ``` git checkout master ``` @@ -304,7 +308,7 @@ git branch -d --- ### Troubleshooting -1. Xcode command line tools +__1. Xcode command line tools__ ``` Warning: No developer tools installed. You should install the Command Line Tools. @@ -313,7 +317,7 @@ You should install the Command Line Tools. xcode-select --install ``` -2. Creating Logos +__2. Creating Logos__ - Make sure to use `npx ionic` and `npx cordova`. This is because the setup script installs all the modules locally in a self-contained environment using `npm install` and not `npm install -g` @@ -323,3 +327,5 @@ xcode-select --install - Another workaround is to delete the local environment and recreate it - javascript errors: `rm -rf node_modules && npm install` - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` + +3. From 3d5e99bb974a641f144f1cbfedf9d7ac7247ea71 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Fri, 29 Sep 2023 10:02:33 -0600 Subject: [PATCH 018/135] Update README.md 1. Removed instructions on JAVA_HOME as it is no longer required 2. Quality of life changes --- README.md | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ca3e9c93b..34184ab09 100644 --- a/README.md +++ b/README.md @@ -132,19 +132,6 @@ export ANDROID_HOME="/Users//Library/Android/sdk" ``` aka the path where you want the SDK to be installed. -To setup JAVA_HOME (after installing the latest JDK ), run this command: -``` -/usr/libexec/java_home -``` -Find the location of the Java installation (Default will look something like this:) -``` -/Library/Java/JavaVirtualMachines/... -``` -and then export the package as: -``` -export JAVA_HOME="" -``` - - android SDK; install manually or use setup script below (**recommended**). Note that you only need to run this once **per computer**. ``` bash setup/prereq_android_sdk_install.sh @@ -178,7 +165,9 @@ __2. Installing (one time only)__ ``` bash setup/setup_android_native.sh +``` AND/OR +``` bash setup/setup_ios_native.sh ``` From 9606f57c4a5aee68c57f0a3fdb55eaa1937c9260 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 29 Sep 2023 11:20:27 -0600 Subject: [PATCH 019/135] change the name in index.js could not find file "uploadService.js", needed to carry over the name change to "nguploadService.js" --- www/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/index.js b/www/index.js index 66a0d45df..371d70ed5 100644 --- a/www/index.js +++ b/www/index.js @@ -43,7 +43,7 @@ import './js/survey/enketo/enketo-add-note-button.js'; import './js/metrics.js'; import './js/control/general-settings.js'; import './js/control/emailService.js'; -import './js/control/uploadService.js'; +import './js/control/nguploadService.js'; import './js/control/collect-settings.js'; import './js/control/sync-settings.js'; import './js/metrics-factory.js'; From eaf4be5dfa45925c638ac9dfdcb23f7a4e2b8e53 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 29 Sep 2023 11:27:07 -0600 Subject: [PATCH 020/135] adapt promise handling to async/await using async await is a clearer way of handling the asynchronous processes in this file, so it is easier to figure out what is happening and properly set up the typescript version of this service. --- www/js/control/uploadService.ts | 172 +++++++++++++------------------- 1 file changed, 68 insertions(+), 104 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index e359bed88..0a7b5c96b 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -4,25 +4,27 @@ import { useTranslation } from "react-i18next"; /** * @returns A promise that resolves with an upload URL or rejects with an error */ -function getUploadConfig() { - return new Promise(function (resolve, reject) { - //logInfo( "About to get email config"); +async function getUploadConfig() { + return new Promise(async function (resolve, reject) { + logInfo( "About to get email config"); let url = []; - fetch("json/uploadConfig.json").then( function (uploadConfig) { - //logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['data'])); + try { + let uploadConfig = await fetch("json/uploadConfig.json"); + logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['data'])); url.push(uploadConfig["data"].url); resolve(url); - }).catch(function (err) { - fetch("json/uploadConfig.json.sample"). then(function (uploadConfig) { - //logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])); + } catch (err) { + try{ + let uploadConfig = await fetch("json/uploadConfig.json.sample"); + logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])); console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])) url.push(uploadConfig["data"].url); resolve(url); - }).catch(function (err) { - //logError("Error while reading default upload config" + err); + } catch (err) { + logError("Error while reading default upload config" + err); reject(err); - }) - }) + } + } }) } @@ -80,101 +82,63 @@ const sendToServer = function upload(url, binArray, params) { transformRequest: identity, params: params }; - return fetch (url, config); + return fetch(url, config); } //only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") -export function uploadFile(database) { +export async function uploadFile(database, reason) { const { t } = useTranslation(); - getUploadConfig().then((uploadConfig) => { - var parentDir = "unknown"; - - if (window['cordova'].platformId.toLowerCase() == "android") { - parentDir = window['cordova'].file.applicationStorageDirectory+"/databases"; - } - else if (window['cordova'].platformId.toLowerCase() == "ios") { - parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; - } else { - alert("parentDir unexpectedly = " + parentDir + "!") - } - - const newScope = {}; - newScope["data"] = {}; - newScope["fromDirText"] = t('upload-service.upload-from-dir', {parentDir: parentDir}); - newScope["toServerText"] = t('upload-service.upload-to-server', {serverURL: uploadConfig}); - - let didCancel = true; - let detailsPopup = () => console.log("I need a popup"); - - // const detailsPopup = $ionicPopup.show({ - // title: i18next.t("upload-service.upload-database", { db: database }), - // template: newScope.toServerText - // + '', - // scope: newScope, - // buttons: [ - // { - // text: 'Cancel', - // onTap: function(e) { - // didCancel = true; - // detailsPopup.close(); - // } - // }, - // { - // text: 'Upload', - // type: 'button-positive', - // onTap: function(e) { - // if (!newScope.data.reason) { - // //don't allow the user to close unless he enters wifi password - // didCancel = false; - // e.preventDefault(); - // } else { - // didCancel = false; - // return newScope.data.reason; - // } - // } - // } - // ] - // }); - - logInfo("Going to upload " + database); - const readFileAndInfo = [readDBFile(parentDir, database, detailsPopup)]; - Promise.all(readFileAndInfo).then(([binString, reason]) => { - if(!didCancel) - { - console.log("Uploading file of size "+binString['byteLength']); - const progressScope = {...newScope}; //make a child copy of the current scope - const params = { - reason: reason, - tz: Intl.DateTimeFormat().resolvedOptions().timeZone - } - uploadConfig.forEach((url) => { - alert(t("upload-service.upload-database", {db: database}) - + "\n" - + t("upload-service.upload-progress", {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) - ); - // const progressPopup = $ionicPopup.show({ - // title: t("upload-service.upload-database", - // {db: database}), - // template: t("upload-service.upload-progress", - // {filesizemb: binString['byteLength'] / (1000 * 1000), - // serverURL: uploadConfig}) - // + '
', - // scope: progressScope, - // buttons: [ - // { text: 'Cancel', type: 'button-cancel', }, - // ] - // }); - sendToServer(url, binString, params).then((response) => { - console.log(response); - //progressPopup.close(); - displayErrorMsg(t("upload-service.upload-details", - {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}), - t("upload-service.upload-success")); - }).catch(onUploadError); - }); + try { + let uploadConfig = await getUploadConfig(); + var parentDir = "unknown"; + + if (window['cordova'].platformId.toLowerCase() == "android") { + parentDir = window['cordova'].file.applicationStorageDirectory+"/databases"; + } + else if (window['cordova'].platformId.toLowerCase() == "ios") { + parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; + } else { + alert("parentDir unexpectedly = " + parentDir + "!") + } + + const newScope = {}; + newScope["data"] = {}; + newScope["fromDirText"] = t('upload-service.upload-from-dir', {parentDir: parentDir}); + newScope["toServerText"] = t('upload-service.upload-to-server', {serverURL: uploadConfig}); + + logInfo("Going to upload " + database); + try { + let binString = await readDBFile(parentDir, database, undefined); + console.log("Uploading file of size "+binString['byteLength']); + const progressScope = {...newScope}; //make a child copy of the current scope + const params = { + reason: reason, + tz: Intl.DateTimeFormat().resolvedOptions().timeZone + } + uploadConfig.forEach(async (url) => { + alert(t("upload-service.upload-database", {db: database}) + + "\n" + + t("upload-service.upload-progress", {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) + ); + + window.alert(t("upload-service.upload-database", {db: database})); + + try { + let response = await sendToServer(url, binString, params); + console.log(response); + window.alert(t("upload-service.upload-details", + {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) + + t("upload-service.upload-success")); + } catch (error) { + onUploadError(error); + } + }); + } + catch (error){ + onReadError(error); } - }).catch(onReadError); - }).catch(onReadError); - }; \ No newline at end of file + } catch (error) { + onReadError(error); + } +}; \ No newline at end of file From a07e7f5269ad08336c76c7f03c01341860798ca8 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 29 Sep 2023 14:26:53 -0700 Subject: [PATCH 021/135] add test with valid url and invalid url --- www/__tests__/customURL.test.ts | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 www/__tests__/customURL.test.ts diff --git a/www/__tests__/customURL.test.ts b/www/__tests__/customURL.test.ts new file mode 100644 index 000000000..68ce3c47d --- /dev/null +++ b/www/__tests__/customURL.test.ts @@ -0,0 +1,38 @@ +import { onLaunchCustomURL } from '../js/splash/customURL'; + +describe('onLaunchCustomURL', () => { + let mockHandler; + + beforeEach(() => { + // create a new mock handler before each test case. + mockHandler = jest.fn(); + }); + + it('tests valid url 1 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { + const validURL = 'emission://login_token?token=nrelop_dev-emulator-program'; + const expectedURL = 'login_token?token=nrelop_dev-emulator-program'; + const expectedComponents = { route: 'login_token', token: 'nrelop_dev-emulator-program' }; + onLaunchCustomURL(validURL, mockHandler); + expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); + }); + + it('tests valid url 2 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { + const validURL = 'emission://test?param1=first¶m2=second'; + const expectedURL = 'test?param1=first¶m2=second'; + const expectedComponents = { route: 'test', param1: 'first', param2: 'second' }; + onLaunchCustomURL(validURL, mockHandler); + expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); + }); + + it('test invalid url 1 - should not call handler callback with invalid URL', () => { + const invalidURL = 'invalid_url'; + onLaunchCustomURL(invalidURL, mockHandler); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('tests invalid url 2 - should not call handler callback with invalid URL', () => { + const invalidURL = ''; + onLaunchCustomURL(invalidURL, mockHandler); + expect(mockHandler).not.toHaveBeenCalled(); + }); +}) \ No newline at end of file From b7bc41e1aaeffafdfcfc309aee9354a634ac5eca Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 29 Sep 2023 14:27:28 -0700 Subject: [PATCH 022/135] add error-handling block --- www/js/splash/customURL.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/www/js/splash/customURL.ts b/www/js/splash/customURL.ts index 46460a6d7..bc3d93f3e 100644 --- a/www/js/splash/customURL.ts +++ b/www/js/splash/customURL.ts @@ -6,13 +6,17 @@ type OnLaunchCustomURL = (rawUrl: string, callback: (url: string, urlComponents: export const onLaunchCustomURL: OnLaunchCustomURL = (rawUrl, handler) => { - const url = rawUrl.split('//')[1]; - const [ route, paramString ] = url.split('?'); - const paramsList = paramString.split('&'); - const urlComponents: UrlComponents = { route : route }; - for (let i = 0; i < paramsList.length; i++) { - const [key, value] = paramsList[i].split('='); - urlComponents[key] = value; + try { + const url = rawUrl.split('//')[1]; + const [ route, paramString ] = url.split('?'); + const paramsList = paramString.split('&'); + const urlComponents: UrlComponents = { route : route }; + for (let i = 0; i < paramsList.length; i++) { + const [key, value] = paramsList[i].split('='); + urlComponents[key] = value; + } + handler(url, urlComponents); + }catch { + console.log('not a valid url'); } - handler(url, urlComponents); }; \ No newline at end of file From 03bc5c368c465c7ba8e6ec68873de51ae2d0a630 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Fri, 29 Sep 2023 16:21:34 -0600 Subject: [PATCH 023/135] Update README.md 1. Changed the content section 2. Updated links in "Contributing" section 3. Updated "end to end testing" section --- README.md | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 34184ab09..6292c41b1 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # [e-mission phone app](https://github.com/e-mission/e-mission-phone/tree/master) -__This is the phone component of the e-mission system.__ +__This is the phone component of the e-mission system.__ :sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to the **latest Android & iOS versions**, **cordova-lib@10.0.0 and the most recent node and npm versions**. It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ +✨ We constantly upgrade the repo to the latest cordova versions of android, iOS, cordova-lib, and the most recent node and npm versions. The CI will be up-to-date. + For the latest versions, refer [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) ## Additional Documentation @@ -15,12 +17,13 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone :sparkles: Check [Contributing](#contributing) if you're interested in contributing for this project :sparkles: ## Contents -#### 1. [Creating logos](#creating-logos) -#### 2. [Updating the UI only](#updating-the-ui-only) -#### 3. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) +#### 1. [Updating the UI only](#updating-the-ui-only) +#### 2. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) +#### 3. [Creating logos](#creating-logos) #### 4. [End to End Testing](#end-to-end-testing) #### 5. [Beta-testing debugging](#beta-testing-debugging) #### 6. [Contributing](#contributing) +#### 7. [Troubleshooting](#troubleshooting) --- @@ -80,7 +83,7 @@ source setup/activate_serve.sh ## End to End Testing -A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: +A lot of the visualizations that we display in the phone client come from the server. In order to do end-to-end testing, we need to run a local server and connect to it. Instructions for: 1. installing a local server, 2. running it, @@ -89,7 +92,11 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +In order to make end-to-end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to modify the [nrel-commute.nrel-op.json](https://github.com/e-mission/nrel-openpath-deploy-configs/blob/482971d9715e8d52862e689658f9b4f2437e6401/configs/nrel-commute.nrel-op.json) file's dynamic config. +``` +"connectUrl": "https://nrel-commute-openpath.nrel.gov/api/" +``` +More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. @@ -115,7 +122,6 @@ Pre-requisites - The version of xcode used by the CI. - to install a particular version, use [xcode-select](https://www.unix.com/man-page/OSX/1/xcode-select/) - or this [supposedly easier to use repo](https://github.com/xcpretty/xcode-install) - - **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - git - Java 17. Tested with [OpenJDK 17 (Temurin) using AdoptOpenJDK](https://adoptium.net). - Always use [homebrew](https://brew.sh) in addition to CLI @@ -263,21 +269,23 @@ less /tmp/loggerDB..withdate.log 1. Add the main repo as upstream ``` -2. git remote add upstream +git remote add upstream https://github.com/e-mission/e-mission-phone ``` -3. Create a new branch (IMPORTANT). Please do not submit pull requests from master +2. Create a new branch (IMPORTANT). Please do not submit pull requests from master ``` -4. git checkout -b +git checkout -b ``` -5. Make changes to the branch and commit them +3. Make changes to the branch and commit them ``` -6. git commit +git commit ``` - 7. Push the changes to your local fork +4. Push the changes to your local fork ``` -8. git push origin +git push origin ``` -9. Generate a pull request from the UI +5. Generate a pull request from the UI + +
__\*__Address my review comments__\*__ @@ -317,4 +325,6 @@ __2. Creating Logos__ - javascript errors: `rm -rf node_modules && npm install` - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` -3. +3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). + +4. From 838f6da87095ef35a34aba1ea2b11c4fef921ccb Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 29 Sep 2023 16:38:55 -0600 Subject: [PATCH 024/135] adding user-input component of upload to profile we need to take a reason, so why not take it and then call the function? This workflow attempts to take user input in a modal and then pass it to the service's uploadFile function -- note, currently broken!!! --- www/js/control/ProfileSettings.jsx | 38 ++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index e3dab82e3..6e878296e 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { Modal, StyleSheet, ScrollView } from "react-native"; -import { Dialog, Button, useTheme, Text, Appbar, IconButton } from "react-native-paper"; +import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from "react-native-paper"; import { angularize, getAngularService } from "../angular-react-helper"; import { useTranslation } from "react-i18next"; import ExpansionSection from "./ExpandMenu"; @@ -15,6 +15,8 @@ import DataDatePicker from "./DataDatePicker"; import AppStatusModal from "./AppStatusModal"; import PrivacyPolicyModal from "./PrivacyPolicyModal"; +import {uploadFile} from "./uploadService"; + let controlUpdateCompleteListenerRegistered = false; //any pure functions can go outside @@ -34,7 +36,6 @@ const ProfileSettings = () => { //angular services needed const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); - const UploadHelper = getAngularService('UploadHelper'); const EmailHelper = getAngularService('EmailHelper'); const ControlCollectionHelper = getAngularService('ControlCollectionHelper'); const ControlSyncHelper = getAngularService('ControlSyncHelper'); @@ -73,6 +74,7 @@ const ProfileSettings = () => { const [consentVis, setConsentVis] = useState(false); const [dateDumpVis, setDateDumpVis] = useState(false); const [privacyVis, setPrivacyVis] = useState(false); + const [uploadVis, setUploadVis] = useState(false); const [collectSettings, setCollectSettings] = useState({}); const [notificationSettings, setNotificationSettings] = useState({}); @@ -85,6 +87,7 @@ const ProfileSettings = () => { const [consentDoc, setConsentDoc] = useState({}); const [dumpDate, setDumpDate] = useState(new Date()); const [toggleTime, setToggleTime] = useState(new Date()); + const [uploadReason, setUploadReason] = useState(""); let carbonDatasetString = t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); @@ -219,8 +222,12 @@ const ProfileSettings = () => { //methods that control the settings const uploadLog = function () { - UploadHelper.uploadFile("loggerDB") - }; + if(uploadReason != "") { + let reason = uploadReason.split('').join(''); + uploadFile("loggerDB", reason); + setUploadVis(false); + } + } const emailLog = function () { // Passing true, we want to send logs @@ -463,7 +470,7 @@ const ProfileSettings = () => { let logUploadSection; console.debug("appConfg: support_upload:", appConfig?.profile_controls?.support_upload); if (appConfig?.profile_controls?.support_upload) { - logUploadSection = ; + logUploadSection = setUploadVis(true)}>; } let timePicker; @@ -598,6 +605,27 @@ const ProfileSettings = () => { + {/* upload reason input */} + setUploadVis(false)} + transparent={true}> + setUploadVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t('upload-service.upload-database')} + + setUploadReason(uploadReason)} + placeholder={t('upload-service.please-fill-in-what-is-wrong')}> + + + + + + + + + {/* opcode viewing popup */} From 43b1b984ae7ff024df1300a9f163062d4a34a474 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 29 Sep 2023 17:01:48 -0600 Subject: [PATCH 025/135] resolve leftover issue from merge --- www/js/control/ProfileSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index bbb713c57..691215366 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -451,7 +451,7 @@ const ProfileSettings = () => { transparent={true}> setUploadVis(false)} - style={styles.dialog(colors.elevation.level3)}> + style={settingStyles.dialog(colors.elevation.level3)}> {t('upload-service.upload-database')} Date: Fri, 29 Sep 2023 20:48:46 -0600 Subject: [PATCH 026/135] Update README.md 1. Updated buttons to point to the right CI 2. Removed point no. 4 in troubleshooting (it was empty) --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 6292c41b1..80abc7872 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone ## Updating the UI only -[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) +[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/serve-install.yml) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). @@ -326,5 +326,3 @@ __2. Creating Logos__ - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` 3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - -4. From bbf18bdb939f9a30670cda10c54de4c66a251c84 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Fri, 29 Sep 2023 20:50:39 -0600 Subject: [PATCH 027/135] Update README.md Made sure the links for the buttons are correct --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80abc7872..25f54d061 100644 --- a/README.md +++ b/README.md @@ -325,4 +325,4 @@ __2. Creating Logos__ - javascript errors: `rm -rf node_modules && npm install` - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` -3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). +3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Mac OS Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). From 4da919f8c37a0802da85476532888a281ce0e33e Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 30 Sep 2023 15:51:09 -0600 Subject: [PATCH 028/135] Update README.md 1. Removed manual numbering 2. Removed any form of version numbers 3. Removed certain Permalinks and swapped it with relative PATHs 4. Made sure the structure is same --- README.md | 79 ++++++++++++++++++++++++++----------------------------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 25f54d061..7f8b62c29 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ __This is the phone component of the e-mission system.__ -:sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to the **latest Android & iOS versions**, **cordova-lib@10.0.0 and the most recent node and npm versions**. It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ +:sparkles: This has been upgraded to the latest **Android**, **iOS**, **cordova-lib**, **node** and **npm** versions. __This is ready to build out of the box.__ ✨ We constantly upgrade the repo to the latest cordova versions of android, iOS, cordova-lib, and the most recent node and npm versions. The CI will be up-to-date. -For the latest versions, refer [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) +For the latest versions, refer [`package.cordovabuild.json`](package.cordovabuild.json) ## Additional Documentation Additional documentation has been moved to its own repository [e-mission-docs](https://github.com/e-mission/e-mission-docs). Specific e-mission-phone wikis can be found here: @@ -35,7 +35,7 @@ If you want to make only UI changes, (as opposed to modifying the existing plugi ### Installing (one-time) -1. Run the setup script +:point_right:Run the setup script ``` bash setup/setup_serve.sh @@ -50,17 +50,16 @@ cp ..... www/json/connectionConfig.json ``` ### Activation (after install, and in every new shell) -2. Run this to activate ``` source setup/activate_serve.sh ``` ### Running -1. Start the phonegap deployment server and note the URL(s) that the server is listening to. +Start the phonegap deployment server and note the URL(s) that the server is listening to. - ``` - npm run serve + + npm run serve .... [phonegap] listening on 10.0.0.14:3000 [phonegap] listening on 192.168.162.1:3000 @@ -68,12 +67,11 @@ source setup/activate_serve.sh [phonegap] ctrl-c to stop the server [phonegap] .... - ``` -2. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" -3. The app will now display the version of e-mission app that is in your local directory - 4. The console logs will be displayed back in the server window (prefaced by `[console]`) - 5. Breakpoints can be added by connecting through the browser +Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" +The app will now display the version of e-mission app that is in your local directory +The console logs will be displayed back in the server window (prefaced by `[console]`) +Breakpoints can be added by connecting through the browser - Safari ([enable develop menu](https://support.apple.com/guide/safari/use-the-safari-develop-menu-sfri20948/mac)): Develop -> Simulator -> index.html - Chrome: chrome://inspect -> Remote target (emulator) @@ -105,18 +103,6 @@ One advantage of using `skip` authentication in development mode is that any use [![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) [![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) -__Important__ - -Most of the recent issues encountered have been due to incompatible setup. We -have now: -- locked down the dependencies, -- created setup and teardown scripts to setup self-contained environments with - those dependencies, and -- CI enabled to validate that they continue work. - -If you have setup failures, please compare the configuration in the **passing CI -builds** with your configuration. That is almost certainly the source of the error. - Pre-requisites --- - The version of xcode used by the CI. @@ -129,7 +115,19 @@ Pre-requisites running into ruby incompatibilities - e.g. https://github.com/CocoaPods/CocoaPods/issues/11763 -__1. Export statements__ +:triangular_flag_on_post: __Important__ + +Most of the recent issues encountered have been due to incompatible setup. We +have now: +- locked down the dependencies, +- created setup and teardown scripts to setup self-contained environments with + those dependencies, and +- CI enabled to validate that they continue work. + +If you have setup failures, please compare the configuration in the **passing CI +builds** with your configuration. That is almost certainly the source of the error. + +__Export statements__ ``` export ANDROID_SDK_ROOT="/Users//Library/Android/sdk" ``` @@ -165,7 +163,7 @@ aka the path where you want the SDK to be installed. -__2. Installing (one time only)__ +__Installing (one time only)__ - Run the setup script for the platform you want to build @@ -193,7 +191,7 @@ If connecting to a development server over http, make sure to turn on http suppo ``` -__3. Run this in every new shell for Activation__ +__Run this in every new shell for Activation__ ``` source setup/activate_native.sh @@ -202,9 +200,9 @@ source setup/activate_native.sh ``` Activating nvm -Using version 19.5.0 -Now using node v19.5.0 (npm v9.3.1) -npm version = 9.3.1 +Using version +Now using node (npm ) +npm version = Adding cocoapods to the path Verifying /Users//Library/Android/sk or /Users//Library/Android/sdk is set Activating sdkman, and by default, gradle @@ -216,9 +214,9 @@ Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> pa
- __4. Pick a type of build and execute the following:__ + __Pick a type of build and execute the following:__ -More "versions" are available in [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) +More "versions" are available in [`package.cordovabuild.json`](package.cordovabuild.json) ``` npm run ``` @@ -266,24 +264,23 @@ less /tmp/loggerDB..withdate.log ## Contributing - -1. Add the main repo as upstream +:point_right:Add the main repo as upstream ``` git remote add upstream https://github.com/e-mission/e-mission-phone ``` -2. Create a new branch (IMPORTANT). Please do not submit pull requests from master +:point_right:Create a new branch (IMPORTANT). Please do not submit pull requests from master ``` git checkout -b ``` -3. Make changes to the branch and commit them +:point_right:Make changes to the branch and commit them ``` git commit ``` -4. Push the changes to your local fork +:point_right:Push the changes to your local fork ``` git push origin ``` -5. Generate a pull request from the UI +:point_right:Generate a pull request from the UI
@@ -305,7 +302,7 @@ git branch -d --- ### Troubleshooting -__1. Xcode command line tools__ +:point_right:Xcode command line tools ``` Warning: No developer tools installed. You should install the Command Line Tools. @@ -314,7 +311,7 @@ You should install the Command Line Tools. xcode-select --install ``` -__2. Creating Logos__ +:point_right:Creating Logos - Make sure to use `npx ionic` and `npx cordova`. This is because the setup script installs all the modules locally in a self-contained environment using `npm install` and not `npm install -g` @@ -325,4 +322,4 @@ __2. Creating Logos__ - javascript errors: `rm -rf node_modules && npm install` - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` -3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Mac OS Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). +(For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Mac OS Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). From e8cbcb5cb4a2feaf20f7d6762e43ba8d47b44533 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sun, 1 Oct 2023 12:06:04 -0600 Subject: [PATCH 029/135] Update README.md iter 1 - fix end to end testing --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7f8b62c29..f079fe014 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,9 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end-to-end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to modify the [nrel-commute.nrel-op.json](https://github.com/e-mission/nrel-openpath-deploy-configs/blob/482971d9715e8d52862e689658f9b4f2437e6401/configs/nrel-commute.nrel-op.json) file's dynamic config. -``` -"connectUrl": "https://nrel-commute-openpath.nrel.gov/api/" -``` -More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic [config](configs) where each file upholds a function. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) + +If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server field of their dynamic config file. One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. From 498e04e9da46c5ad55ff2c9df98ff20c59aea840 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 2 Oct 2023 08:53:27 -0600 Subject: [PATCH 030/135] Delete Build_ss.png Not required anymore --- Build_ss.png | Bin 83719 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 Build_ss.png diff --git a/Build_ss.png b/Build_ss.png deleted file mode 100644 index 18ab48b232d04ae28e72f5677d393045be584659..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 83719 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 From f4f3e6858e6696b3d0cdae4f6f7ab37d90333a44 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 2 Oct 2023 09:07:07 -0600 Subject: [PATCH 031/135] Update README.md - Better wordings --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f079fe014..6de8fcec1 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,9 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic [config](configs) where each file upholds a function. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) +In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic [config](configs) to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) -If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server field of their dynamic config file. +If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server of your dynamic config file. One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. From e1a6a32db1861ce686c85abc81d2a84db4ce3a35 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 2 Oct 2023 09:08:22 -0600 Subject: [PATCH 032/135] Update README.md - Removed relative PATH for dynamic links as they don't seem to work --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6de8fcec1..db401d00b 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic [config](configs) to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) +In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic config to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server of your dynamic config file. From 693852dda0d1310998029a2f1da4ce8a3347b2e8 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 2 Oct 2023 13:13:58 -0600 Subject: [PATCH 033/135] Update README.md Modified the server to `server` --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index db401d00b..9ac9ceb1d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ are available in the [e-mission-server README](https://github.com/e-mission/e-mi In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic config to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) -If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server of your dynamic config file. +If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and you want to connect to it, you would have to specify that URL or IP in the `server` field of your dynamic config file. One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. From 5a8d24854e0d507ac6b8505fdb86b856494c55b6 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 2 Oct 2023 15:26:25 -0600 Subject: [PATCH 034/135] correct message in reason modal --- www/js/control/ProfileSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 691215366..cd8906ea3 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -452,7 +452,7 @@ const ProfileSettings = () => { setUploadVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> - {t('upload-service.upload-database')} + {t('upload-service.upload-database', {db: "loggerDB"})} Date: Mon, 2 Oct 2023 15:27:45 -0600 Subject: [PATCH 035/135] upload service working This now appears to be working with the dev server, as the POST requests are going through successfully The next step is testing and potentially polishing the message popups to the user --- www/js/control/uploadService.ts | 61 +++++++++++++-------------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index 0a7b5c96b..e0c00a713 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,6 +1,7 @@ import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; -import { useTranslation } from "react-i18next"; +import i18next from "i18next"; +// const { t } = useTranslation(); /** * @returns A promise that resolves with an upload URL or rejects with an error */ @@ -9,16 +10,18 @@ async function getUploadConfig() { logInfo( "About to get email config"); let url = []; try { - let uploadConfig = await fetch("json/uploadConfig.json"); - logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['data'])); - url.push(uploadConfig["data"].url); + let response = await fetch("json/uploadConfig.json"); + let uploadConfig = await response.json(); + logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['url'])); + url.push(uploadConfig["url"]); resolve(url); } catch (err) { try{ - let uploadConfig = await fetch("json/uploadConfig.json.sample"); - logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])); - console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])) - url.push(uploadConfig["data"].url); + let response = await fetch("json/uploadConfig.json.sample"); + let uploadConfig = await response.json(); + logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['url'])); + console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['url'])) + url.push(uploadConfig["url"]); resolve(url); } catch (err) { logError("Error while reading default upload config" + err); @@ -70,25 +73,18 @@ function readDBFile(parentDir, database, callbackFn) { } const sendToServer = function upload(url, binArray, params) { - //attempting to replace angular.identity - var identity = function() { - return arguments[0]; - } - - var config = { - method: "POST", - body: binArray, + //this was the best way I could find to contact the database, + //had to modify the way it gets handled on the other side + //the original way it could not find "reason" + return fetch(url, { + method: 'POST', headers: {'Content-Type': undefined }, - transformRequest: identity, - params: params - }; - return fetch(url, config); + body: JSON.stringify({ data: binArray, params: params }) + } ) } - //only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") export async function uploadFile(database, reason) { - const { t } = useTranslation(); try { let uploadConfig = await getUploadConfig(); var parentDir = "unknown"; @@ -102,34 +98,25 @@ export async function uploadFile(database, reason) { alert("parentDir unexpectedly = " + parentDir + "!") } - const newScope = {}; - newScope["data"] = {}; - newScope["fromDirText"] = t('upload-service.upload-from-dir', {parentDir: parentDir}); - newScope["toServerText"] = t('upload-service.upload-to-server', {serverURL: uploadConfig}); - logInfo("Going to upload " + database); try { let binString = await readDBFile(parentDir, database, undefined); console.log("Uploading file of size "+binString['byteLength']); - const progressScope = {...newScope}; //make a child copy of the current scope const params = { reason: reason, tz: Intl.DateTimeFormat().resolvedOptions().timeZone } uploadConfig.forEach(async (url) => { - alert(t("upload-service.upload-database", {db: database}) - + "\n" - + t("upload-service.upload-progress", {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) - ); - - window.alert(t("upload-service.upload-database", {db: database})); + //have alert for starting upload, but not progress + window.alert(i18next.t("upload-service.upload-database", {db: database})); try { + //const binArray = {byteLength: binString.byteLength, byteOffset: binString.byteOffset} let response = await sendToServer(url, binString, params); console.log(response); - window.alert(t("upload-service.upload-details", - {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) - + t("upload-service.upload-success")); + window.alert(i18next.t("upload-service.upload-details", + {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) + + i18next.t("upload-service.upload-success")); } catch (error) { onUploadError(error); } From a5451e9bfd377ec511e355984a07c52ae197f1a5 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 2 Oct 2023 16:41:52 -0600 Subject: [PATCH 036/135] resolve errors there were two errors in VScode, which were also tripping the tests up when I tried to run the uploadFile function -- resolving these did not make the tests run, but it did get past the first of the errors! --- www/js/control/uploadService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index e0c00a713..a09fdaed3 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -61,8 +61,8 @@ function readDBFile(parentDir, database, callbackFn) { } reader.onload = function() { - console.log("Successful file read with " + this.result.byteLength +" characters"); - resolve(new DataView(this.result)); + console.log("Successful file read with " + this.result['byteLength'] +" characters"); + resolve(new DataView(this.result as ArrayBuffer)); } reader.readAsArrayBuffer(file); From 8e28d47a2da8be00c834912102e7adadaf18bedf Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 3 Oct 2023 11:43:21 -0600 Subject: [PATCH 037/135] add await to forceTransition process there was an issue with the alert, which was showing a promise object rather than the actual result. This change works to resolve that issue --- www/js/control/ControlCollectionHelper.tsx | 2 +- www/js/control/ProfileSettings.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx index 99318b1ac..d94f12448 100644 --- a/www/js/control/ControlCollectionHelper.tsx +++ b/www/js/control/ControlCollectionHelper.tsx @@ -21,7 +21,7 @@ type collectionConfig = { export async function forceTransition(transition) { try { - let result = forceTransitionWrapper(transition); + let result = await forceTransitionWrapper(transition); window.alert('success -> '+result); } catch (err) { window.alert('error -> '+err); diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 8035c9462..5f4347e21 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -252,7 +252,7 @@ const ProfileSettings = () => { async function userStartStopTracking() { const transitionToForce = collectSettings.trackingOn ? 'STOP_TRACKING' : 'START_TRACKING'; - forceTransition(transitionToForce); + await forceTransition(transitionToForce); refreshCollectSettings(); } From a7d4ec3b07e0f7f472d1a288c9362ad426caeb95 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 3 Oct 2023 16:30:49 -0600 Subject: [PATCH 038/135] set up testing for uploadService tests are not fully running yet, but making progress and need to save before getting the mocks from a remote branch --- www/__tests__/uploadService.tests.ts | 29 +++++++++++++++++++++++++++- www/json/uploadConfig.json.sample | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/www/__tests__/uploadService.tests.ts b/www/__tests__/uploadService.tests.ts index 50a253a2e..0544d22cd 100644 --- a/www/__tests__/uploadService.tests.ts +++ b/www/__tests__/uploadService.tests.ts @@ -1,2 +1,29 @@ -import {} from "../js/control/uploadService"; +import {uploadFile} from "../js/control/uploadService"; +import { mockLogger } from '../__mocks__/globalMocks'; +mockLogger(); + +// mock for JavaScript 'fetch' +// we emulate a 100ms delay when i) fetching data and ii) parsing it as text +global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { + setTimeout(() => rs({ + text: () => new Promise((rs, rj) => { + setTimeout(() => rs(new Response('sent ' + options.method + options.body + ' to ' + url)), 100); + }) + })); +}) as any; + +global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ url: "http://localhost:5647/phonelogs" }), + }), +) as any + +//this is never used in production right now +//however, tests are still important to make sure the code works +//at some point we hope to restore this functionality +it('posts the logs to the configured database', async () => { + const posted = await uploadFile("loggerDB", "HelloWorld"); + expect(posted).toEqual(expect.stringContaining("HelloWorld")); + expect(posted).toEqual(expect.stringContaining("POST")); +}); \ No newline at end of file diff --git a/www/json/uploadConfig.json.sample b/www/json/uploadConfig.json.sample index 53cead55e..a3c8b7210 100644 --- a/www/json/uploadConfig.json.sample +++ b/www/json/uploadConfig.json.sample @@ -1,3 +1,3 @@ { - "url": "http://fill.me.in " + "url": "http://localhost:5647/phonelogs" } From 0e3a0e5bb9328d9790f12175342bed97f821c698 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 07:46:42 -0600 Subject: [PATCH 039/135] more mocks for testing needed to mock window['resolvelocalfilesystem'] as well as window['cordova'].file --- www/__mocks__/cordovaMocks.ts | 5 +++++ www/__mocks__/fileSystemMocks.ts | 5 +++++ www/__tests__/uploadService.tests.ts | 9 +++++++++ 3 files changed, 19 insertions(+) create mode 100644 www/__mocks__/fileSystemMocks.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 44c21677c..34cb732bb 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -22,6 +22,11 @@ export const mockGetAppVersion = () => { window['cordova'].getAppVersion = mockGetAppVersion; } +export const mockFile = () => { + window['cordova'].file = { "dataDirectory" : "../path/to/data/directory", + "applicationStorageDirectory" : "../path/to/app/storage/directory"}; +} + export const mockBEMUserCache = () => { const _cache = {}; const messages = []; diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts new file mode 100644 index 000000000..99682cba9 --- /dev/null +++ b/www/__mocks__/fileSystemMocks.ts @@ -0,0 +1,5 @@ +export const mockFileSystem = () => { + window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { + return new DataView({byteLength: 100} as ArrayBuffer) + } + } \ No newline at end of file diff --git a/www/__tests__/uploadService.tests.ts b/www/__tests__/uploadService.tests.ts index 0544d22cd..b1efcea1d 100644 --- a/www/__tests__/uploadService.tests.ts +++ b/www/__tests__/uploadService.tests.ts @@ -1,5 +1,14 @@ import {uploadFile} from "../js/control/uploadService"; import { mockLogger } from '../__mocks__/globalMocks'; +import { mockDevice, mockGetAppVersion, mockCordova, mockFile } from "../__mocks__/cordovaMocks"; +import { mockFileSystem } from "../__mocks__/fileSystemMocks"; + +mockDevice(); +// this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" +mockGetAppVersion(); +mockCordova(); +mockFile(); +mockFileSystem(); mockLogger(); From cd8b3833412874d19a17e8d505b32830d024693f Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 14:37:43 -0600 Subject: [PATCH 040/135] rename component --- www/js/control/ControlCollectionHelper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx index d94f12448..29f175a06 100644 --- a/www/js/control/ControlCollectionHelper.tsx +++ b/www/js/control/ControlCollectionHelper.tsx @@ -132,7 +132,7 @@ const formatConfigForDisplay = function(config, accuracyOptions) { return retVal; } -const ControlSyncHelper = ({ editVis, setEditVis }) => { +const ControlCollectionHelper = ({ editVis, setEditVis }) => { const {colors} = useTheme(); const Logger = getAngularService("Logger"); @@ -282,4 +282,4 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { ); }; -export default ControlSyncHelper; +export default ControlCollectionHelper; From 877f8cb1c4d9c7bdde11ab1b0ac72950c101248f Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 14:38:22 -0600 Subject: [PATCH 041/135] move closing modal to reload function trying to ensure that the updates are complete by moving anything that happens after into the same function, after the await --- www/js/control/ControlCollectionHelper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx index 29f175a06..fe6821f4b 100644 --- a/www/js/control/ControlCollectionHelper.tsx +++ b/www/js/control/ControlCollectionHelper.tsx @@ -172,6 +172,7 @@ const ControlCollectionHelper = ({ editVis, setEditVis }) => { console.log("new config = ", localConfig); try{ let set = await setConfig(localConfig); + setEditVis(false); //TODO find way to not need control.update.complete event broadcast } catch(err) { Logger.displayError("Error while setting collection config", err); @@ -268,8 +269,7 @@ const ControlCollectionHelper = ({ editVis, setEditVis }) => { {geofenceComponent} - From 304ceaa84fcbfa51e7557a1a3d067ea1963aabba Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 14:38:49 -0600 Subject: [PATCH 042/135] only refresh on close, not on open editing modal --- www/js/control/ProfileSettings.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 5f4347e21..072d4267f 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -156,7 +156,10 @@ const ProfileSettings = () => { //ensure ui table updated when editor closes useEffect(() => { - refreshCollectSettings(); + if(editCollection == false) { + console.log("closed editor, time to refresh collect"); + refreshCollectSettings(); + } }, [editCollection]) async function refreshNotificationSettings() { From 019d715e6080ec723831ecd01b19270119ab5dbe Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 16:43:01 -0600 Subject: [PATCH 043/135] working mocks and tests! After a lot of work ironing out a mock for the file system layers, a working mock and test has been established the "reason" passed through remains present throughout the process, confirming in at least a small way that this works --- www/__mocks__/fileSystemMocks.ts | 15 +++++++++- ...Service.tests.ts => uploadService.test.ts} | 28 ++++++++++--------- www/js/control/uploadService.ts | 15 +++++----- 3 files changed, 36 insertions(+), 22 deletions(-) rename www/__tests__/{uploadService.tests.ts => uploadService.test.ts} (62%) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index 99682cba9..fb1258692 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,5 +1,18 @@ export const mockFileSystem = () => { window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { - return new DataView({byteLength: 100} as ArrayBuffer) + const fs = {"filesystem": + {"root": + {"getFile": (path, options, onSuccess) => { + let fileEntry = {"file": (handleFile) => { + let file = new File(["this is a mock"], "loggerDB"); + handleFile(file); + }} + onSuccess(fileEntry); + } + } + } + } + console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); + handleFS(fs); } } \ No newline at end of file diff --git a/www/__tests__/uploadService.tests.ts b/www/__tests__/uploadService.test.ts similarity index 62% rename from www/__tests__/uploadService.tests.ts rename to www/__tests__/uploadService.test.ts index b1efcea1d..13e343efa 100644 --- a/www/__tests__/uploadService.tests.ts +++ b/www/__tests__/uploadService.test.ts @@ -12,27 +12,29 @@ mockFileSystem(); mockLogger(); -// mock for JavaScript 'fetch' -// we emulate a 100ms delay when i) fetching data and ii) parsing it as text +//use this message to verify that the post went through +let message = ""; + global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { + if(options) { + console.log("returning with options!", options); + message = "got " + options.method + options.body; + rs('sent ' + options.method + options.body + ' to ' + url); + } else { setTimeout(() => rs({ - text: () => new Promise((rs, rj) => { - setTimeout(() => rs(new Response('sent ' + options.method + options.body + ' to ' + url)), 100); + json: () => new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); }) })); + } }) as any; -global.fetch = jest.fn(() => - Promise.resolve({ - json: () => Promise.resolve({ url: "http://localhost:5647/phonelogs" }), - }), -) as any - //this is never used in production right now //however, tests are still important to make sure the code works //at some point we hope to restore this functionality it('posts the logs to the configured database', async () => { const posted = await uploadFile("loggerDB", "HelloWorld"); - expect(posted).toEqual(expect.stringContaining("HelloWorld")); - expect(posted).toEqual(expect.stringContaining("POST")); -}); \ No newline at end of file + console.log(posted); + expect(message).toEqual(expect.stringContaining("HelloWorld")); + expect(message).toEqual(expect.stringContaining("POST")); +}, 10000); \ No newline at end of file diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index a09fdaed3..cbd182b3c 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,7 +1,5 @@ import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; -import i18next from "i18next"; -// const { t } = useTranslation(); /** * @returns A promise that resolves with an upload URL or rejects with an error */ @@ -42,6 +40,7 @@ function onUploadError(err) { function readDBFile(parentDir, database, callbackFn) { return new Promise(function(resolve, reject) { window['resolveLocalFileSystemURL'](parentDir, function(fs) { + console.log("resolving file system as ", fs); fs.filesystem.root.getFile(fs.fullPath+database, null, (fileEntry) => { console.log(fileEntry); fileEntry.file(function(file) { @@ -108,15 +107,15 @@ export async function uploadFile(database, reason) { } uploadConfig.forEach(async (url) => { //have alert for starting upload, but not progress - window.alert(i18next.t("upload-service.upload-database", {db: database})); + // window.alert(i18next.t("upload-service.upload-database", {db: database})); try { - //const binArray = {byteLength: binString.byteLength, byteOffset: binString.byteOffset} let response = await sendToServer(url, binString, params); - console.log(response); - window.alert(i18next.t("upload-service.upload-details", - {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) - + i18next.t("upload-service.upload-success")); + console.log("after post got", response); + return response; + // window.alert(i18next.t("upload-service.upload-details", + // {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) + // + i18next.t("upload-service.upload-success")); } catch (error) { onUploadError(error); } From d3c08298ce3c5c4f2c1d9d8b107bcd04c34f9127 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 17:14:58 -0600 Subject: [PATCH 044/135] fix bad merge conflifct the dev ui server wouldn't run because there were two imports from react-nativ-paper, probably form a bad merge resolution --- www/js/control/ProfileSettings.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 10cd9f80a..2552b37f0 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useContext, useRef } from "react"; import { Modal, StyleSheet, ScrollView } from "react-native"; import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from "react-native-paper"; -import { Dialog, Button, useTheme, Text, Appbar, IconButton } from "react-native-paper"; import { getAngularService } from "../angular-react-helper"; import { useTranslation } from "react-i18next"; import ExpansionSection from "./ExpandMenu"; From e3ebe8519be4209c5e277f3ec00f62b18f5c6894 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 5 Oct 2023 10:47:58 -0600 Subject: [PATCH 045/135] restored alerts, added mock, added comments wrapping up the basic implementation and testing of this upload service, adding comments for clarity --- www/__tests__/uploadService.test.ts | 24 +++++++++++++++--------- www/js/control/uploadService.ts | 10 +++++----- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index 13e343efa..c8c0e6ebc 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -1,23 +1,25 @@ +//this is never used in production right now +//however, tests are still important to make sure the code works +//at some point we hope to restore this functionality + import {uploadFile} from "../js/control/uploadService"; import { mockLogger } from '../__mocks__/globalMocks'; import { mockDevice, mockGetAppVersion, mockCordova, mockFile } from "../__mocks__/cordovaMocks"; import { mockFileSystem } from "../__mocks__/fileSystemMocks"; mockDevice(); -// this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" mockGetAppVersion(); mockCordova(); -mockFile(); -mockFileSystem(); mockLogger(); +mockFile(); //mocks the base directory +mockFileSystem(); //comnplex mock, allows the readDBFile to work in testing //use this message to verify that the post went through let message = ""; global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { if(options) { - console.log("returning with options!", options); message = "got " + options.method + options.body; rs('sent ' + options.method + options.body + ' to ' + url); } else { @@ -29,12 +31,16 @@ global.fetch = (url: string, options: {method: string, headers: {}, body: string } }) as any; -//this is never used in production right now -//however, tests are still important to make sure the code works -//at some point we hope to restore this functionality +window.alert = (message) => { + console.log(message); +} + +//very basic tests - difficult to do too much since there's a lot of mocking involved it('posts the logs to the configured database', async () => { - const posted = await uploadFile("loggerDB", "HelloWorld"); - console.log(posted); + let posted = await uploadFile("loggerDB", "HelloWorld"); expect(message).toEqual(expect.stringContaining("HelloWorld")); expect(message).toEqual(expect.stringContaining("POST")); + posted = await uploadFile("loggerDB", "second test"); + expect(message).toEqual(expect.stringContaining("second test")); + expect(message).toEqual(expect.stringContaining("POST")); }, 10000); \ No newline at end of file diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index cbd182b3c..7eee1217a 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,4 +1,5 @@ import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; +import i18next from "i18next"; /** * @returns A promise that resolves with an upload URL or rejects with an error @@ -107,15 +108,14 @@ export async function uploadFile(database, reason) { } uploadConfig.forEach(async (url) => { //have alert for starting upload, but not progress - // window.alert(i18next.t("upload-service.upload-database", {db: database})); + window.alert(i18next.t("upload-service.upload-database", {db: database})); try { let response = await sendToServer(url, binString, params); - console.log("after post got", response); + window.alert(i18next.t("upload-service.upload-details", + {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) + + i18next.t("upload-service.upload-success")); return response; - // window.alert(i18next.t("upload-service.upload-details", - // {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) - // + i18next.t("upload-service.upload-success")); } catch (error) { onUploadError(error); } From e0dd682f8afc78ec14aa5276d7419abb5edcedf9 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 5 Oct 2023 11:13:17 -0600 Subject: [PATCH 046/135] remove old angular upload service --- www/index.js | 1 - www/js/control/nguploadService.js | 171 ------------------------------ www/js/main.js | 3 +- 3 files changed, 1 insertion(+), 174 deletions(-) delete mode 100644 www/js/control/nguploadService.js diff --git a/www/index.js b/www/index.js index 17974900c..68c89ea9c 100644 --- a/www/index.js +++ b/www/index.js @@ -26,7 +26,6 @@ import './js/survey/enketo/infinite_scroll_filters.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-add-note-button.js'; import './js/control/emailService.js'; -import './js/control/nguploadService.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; diff --git a/www/js/control/nguploadService.js b/www/js/control/nguploadService.js deleted file mode 100644 index 6f95503c1..000000000 --- a/www/js/control/nguploadService.js +++ /dev/null @@ -1,171 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.services.upload', ['emission.plugin.logger']) - - .service('UploadHelper', function ($window, $http, $rootScope, $ionicPopup, Logger) { - const getUploadConfig = function () { - return new Promise(function (resolve, reject) { - Logger.log(Logger.LEVEL_INFO, "About to get email config"); - var url = []; - $http.get("json/uploadConfig.json").then(function (uploadConfig) { - Logger.log(Logger.LEVEL_DEBUG, "uploadConfigString = " + JSON.stringify(uploadConfig.data)); - url.push(uploadConfig.data.url) - resolve(url); - }).catch(function (err) { - $http.get("json/uploadConfig.json.sample").then(function (uploadConfig) { - Logger.log(Logger.LEVEL_DEBUG, "default uploadConfigString = " + JSON.stringify(uploadConfig.data)); - url.push(uploadConfig.data.url) - resolve(url); - }).catch(function (err) { - Logger.log(Logger.LEVEL_ERROR, "Error while reading default upload config" + err); - reject(err); - }); - }); - }); - } - - const onReadError = function(err) { - Logger.displayError("Error while reading log", err); - } - - const onUploadError = function(err) { - Logger.displayError("Error while uploading log", err); - } - - const readDBFile = function(parentDir, database, callbackFn) { - return new Promise(function(resolve, reject) { - window.resolveLocalFileSystemURL(parentDir, function(fs) { - fs.filesystem.root.getFile(fs.fullPath+database, null, (fileEntry) => { - console.log(fileEntry); - fileEntry.file(function(file) { - console.log(file); - var reader = new FileReader(); - - reader.onprogress = function(report) { - console.log("Current progress is "+JSON.stringify(report)); - if (callbackFn != undefined) { - callbackFn(report.loaded * 100 / report.total); - } - } - - reader.onerror = function(error) { - console.log(this.error); - reject({"error": {"message": this.error}}); - } - - reader.onload = function() { - console.log("Successful file read with " + this.result.byteLength +" characters"); - resolve(new DataView(this.result)); - } - - reader.readAsArrayBuffer(file); - }, reject); - }, reject); - }); - }); - } - - const sendToServer = function upload(url, binArray, params) { - var config = { - headers: {'Content-Type': undefined }, - transformRequest: angular.identity, - params: params - }; - return $http.post(url, binArray, config); - } - - this.uploadFile = function (database) { - getUploadConfig().then((uploadConfig) => { - var parentDir = "unknown"; - - if (ionic.Platform.isAndroid()) { - parentDir = cordova.file.applicationStorageDirectory+"/databases"; - } - if (ionic.Platform.isIOS()) { - parentDir = cordova.file.dataDirectory + "../LocalDatabase"; - } - - if (parentDir === "unknown") { - alert("parentDir unexpectedly = " + parentDir + "!") - } - - const newScope = $rootScope.$new(); - newScope.data = {}; - newScope.fromDirText = i18next.t('upload-service.upload-from-dir', {parentDir: parentDir}); - newScope.toServerText = i18next.t('upload-service.upload-to-server', {serverURL: uploadConfig}); - - var didCancel = true; - - const detailsPopup = $ionicPopup.show({ - title: i18next.t("upload-service.upload-database", { db: database }), - template: newScope.toServerText - + '', - scope: newScope, - buttons: [ - { - text: 'Cancel', - onTap: function(e) { - didCancel = true; - detailsPopup.close(); - } - }, - { - text: 'Upload', - type: 'button-positive', - onTap: function(e) { - if (!newScope.data.reason) { - //don't allow the user to close unless he enters wifi password - didCancel = false; - e.preventDefault(); - } else { - didCancel = false; - return newScope.data.reason; - } - } - } - ] - }); - - Logger.log(Logger.LEVEL_INFO, "Going to upload " + database); - const readFileAndInfo = [readDBFile(parentDir, database), detailsPopup]; - Promise.all(readFileAndInfo).then(([binString, reason]) => { - if(!didCancel) - { - console.log("Uploading file of size "+binString.byteLength); - const progressScope = $rootScope.$new(); - const params = { - reason: reason, - tz: Intl.DateTimeFormat().resolvedOptions().timeZone - } - uploadConfig.forEach((url) => { - const progressPopup = $ionicPopup.show({ - title: i18next.t("upload-service.upload-database", - {db: database}), - template: i18next.t("upload-service.upload-progress", - {filesizemb: binString.byteLength / (1000 * 1000), - serverURL: uploadConfig}) - + '
', - scope: progressScope, - buttons: [ - { text: 'Cancel', type: 'button-cancel', }, - ] - }); - sendToServer(url, binString, params).then((response) => { - console.log(response); - progressPopup.close(); - const successPopup = $ionicPopup.alert({ - title: i18next.t("upload-service.upload-success"), - template: i18next.t("upload-service.upload-details", - {filesizemb: binString.byteLength / (1000 * 1000), - serverURL: uploadConfig}) - }); - }).catch(onUploadError); - }); - } - }).catch(onReadError); - }).catch(onReadError); - }; -}); diff --git a/www/js/main.js b/www/js/main.js index 94bb8aeaf..91437a07a 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -7,8 +7,7 @@ angular.module('emission.main', ['emission.main.diary', 'emission.splash.notifscheduler', 'emission.main.metrics.factory', 'emission.main.metrics.mappings', - 'emission.services', - 'emission.services.upload']) + 'emission.services']) .config(function($stateProvider) { $stateProvider.state('root.main', { From 51533ef94365f7f1baa96c577ec70e869663cb38 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 5 Oct 2023 16:40:18 -0600 Subject: [PATCH 047/135] update fetch POST need to pass the file in its original form and not stringify it in order for the data to actually make it to the server This change is pending restoration of the reason and tz paramenters --- www/js/control/uploadService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index 7eee1217a..e51d64068 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -79,7 +79,7 @@ const sendToServer = function upload(url, binArray, params) { return fetch(url, { method: 'POST', headers: {'Content-Type': undefined }, - body: JSON.stringify({ data: binArray, params: params }) + body: binArray } ) } From 69ada008b37a4f0dfb38fca060b79038f8dcdb74 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 6 Oct 2023 13:20:34 -0600 Subject: [PATCH 048/135] restore url encoding for additional params --- www/js/control/uploadService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index e51d64068..93557bdfe 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -76,7 +76,8 @@ const sendToServer = function upload(url, binArray, params) { //this was the best way I could find to contact the database, //had to modify the way it gets handled on the other side //the original way it could not find "reason" - return fetch(url, { + const urlParams = "?reason=" + params.reason + "&tz=" + params.tz; + return fetch(url+urlParams, { method: 'POST', headers: {'Content-Type': undefined }, body: binArray From 75aeb48e91ecc2c647c4e3b8d842abdf6e5e172e Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 6 Oct 2023 13:28:37 -0600 Subject: [PATCH 049/135] update mock In the previous commits, I altered the call to POST, so I needed to alter the way I mocked that call! --- www/__tests__/uploadService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index c8c0e6ebc..c0c7f9d04 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -20,7 +20,7 @@ let message = ""; global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { if(options) { - message = "got " + options.method + options.body; + message = "sent " + options.method + options.body + " for " + url;; rs('sent ' + options.method + options.body + ' to ' + url); } else { setTimeout(() => rs({ From 69f327a8d803309df6ad9ef8b1a0996f9e90e1e9 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 9 Oct 2023 10:13:45 -0600 Subject: [PATCH 050/135] band-aid fix for collect settings setting a delay does prevent the collect settings changes from turning off tracking, but this is not a very principled fix, still looking for a better way to fix this --- www/js/control/ControlCollectionHelper.tsx | 1 - www/js/control/ProfileSettings.jsx | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx index fe6821f4b..d93c498a9 100644 --- a/www/js/control/ControlCollectionHelper.tsx +++ b/www/js/control/ControlCollectionHelper.tsx @@ -173,7 +173,6 @@ const ControlCollectionHelper = ({ editVis, setEditVis }) => { try{ let set = await setConfig(localConfig); setEditVis(false); - //TODO find way to not need control.update.complete event broadcast } catch(err) { Logger.displayError("Error while setting collection config", err); } diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 072d4267f..0b00efc75 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -157,8 +157,10 @@ const ProfileSettings = () => { //ensure ui table updated when editor closes useEffect(() => { if(editCollection == false) { - console.log("closed editor, time to refresh collect"); - refreshCollectSettings(); + setTimeout(function() { + console.log("closed editor, time to refresh collect"); + refreshCollectSettings(); + }, 1000); } }, [editCollection]) From 32596a8e36174d0f98f7e9ad211627a3f9c99c7e Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Mon, 9 Oct 2023 12:00:03 -0600 Subject: [PATCH 051/135] Updated README file: Restored to the original - 1. Removed versions 2. Added CI badge 3. Plugins installation guide updated --- README.md | 238 +++++++++++++++++++++++++----------------------------- 1 file changed, 108 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 9ac9ceb1d..7fca214aa 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,41 @@ -# [e-mission phone app](https://github.com/e-mission/e-mission-phone/tree/master) +e-mission phone app +-------------------- -__This is the phone component of the e-mission system.__ +This is the phone component of the e-mission system. :sparkles: This has been upgraded to the latest **Android**, **iOS**, **cordova-lib**, **node** and **npm** versions. __This is ready to build out of the box.__ -✨ We constantly upgrade the repo to the latest cordova versions of android, iOS, cordova-lib, and the most recent node and npm versions. The CI will be up-to-date. - For the latest versions, refer [`package.cordovabuild.json`](package.cordovabuild.json) -## Additional Documentation +Additional Documentation +--- Additional documentation has been moved to its own repository [e-mission-docs](https://github.com/e-mission/e-mission-docs). Specific e-mission-phone wikis can be found here: https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check [Contributing](#contributing) if you're interested in contributing for this project :sparkles: - ## Contents #### 1. [Updating the UI only](#updating-the-ui-only) -#### 2. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) -#### 3. [Creating logos](#creating-logos) -#### 4. [End to End Testing](#end-to-end-testing) +#### 2. [End to End Testing](#end-to-end-testing) +#### 3. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) +#### 4. [Creating logos](#creating-logos) #### 5. [Beta-testing debugging](#beta-testing-debugging) #### 6. [Contributing](#contributing) -#### 7. [Troubleshooting](#troubleshooting) +Updating the UI only --- - - -## Updating the UI only -[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/serve-install.yml) +[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). ### Installing (one-time) -:point_right:Run the setup script +Run the setup script ``` bash setup/setup_serve.sh ``` + **(optional)** Configure by changing the files in `www/json`. Defaults are in `www/json/*.sample` @@ -50,15 +46,16 @@ cp ..... www/json/connectionConfig.json ``` ### Activation (after install, and in every new shell) + ``` source setup/activate_serve.sh ``` ### Running -Start the phonegap deployment server and note the URL(s) that the server is listening to. +1. Start the phonegap deployment server and note the URL(s) that the server is listening to. - + ``` npm run serve .... [phonegap] listening on 10.0.0.14:3000 @@ -67,11 +64,12 @@ Start the phonegap deployment server and note the URL(s) that the server is list [phonegap] ctrl-c to stop the server [phonegap] .... + ``` -Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" -The app will now display the version of e-mission app that is in your local directory -The console logs will be displayed back in the server window (prefaced by `[console]`) -Breakpoints can be added by connecting through the browser +1. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" +1. The app will now display the version of e-mission app that is in your local directory + 1. The console logs will be displayed back in the server window (prefaced by `[console]`) + 1. Breakpoints can be added by connecting through the browser - Safari ([enable develop menu](https://support.apple.com/guide/safari/use-the-safari-develop-menu-sfri20948/mac)): Develop -> Simulator -> index.html - Chrome: chrome://inspect -> Remote target (emulator) @@ -79,9 +77,9 @@ Breakpoints can be added by connecting through the browser **Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. -## End to End Testing - -A lot of the visualizations that we display in the phone client come from the server. In order to do end-to-end testing, we need to run a local server and connect to it. Instructions for: +End to end testing +--- +A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: 1. installing a local server, 2. running it, @@ -90,51 +88,25 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic config to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) - -If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and you want to connect to it, you would have to specify that URL or IP in the `server` field of your dynamic config file. +In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. -## Updating the e-mission-\* plugins or adding new plugins - +Updating the e-mission-\* plugins or adding new plugins +--- [![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) [![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) +[![osx-android-prereq-sdk-install](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml) Pre-requisites --- -- The version of xcode used by the CI. +- the version of xcode used by the CI - to install a particular version, use [xcode-select](https://www.unix.com/man-page/OSX/1/xcode-select/) - or this [supposedly easier to use repo](https://github.com/xcpretty/xcode-install) + - **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - git -- Java 17. Tested with [OpenJDK 17 (Temurin) using AdoptOpenJDK](https://adoptium.net). -- Always use [homebrew](https://brew.sh) in addition to CLI - - this allows us to install the current version of cocoapods without - running into ruby incompatibilities - e.g. - https://github.com/CocoaPods/CocoaPods/issues/11763 - -:triangular_flag_on_post: __Important__ - -Most of the recent issues encountered have been due to incompatible setup. We -have now: -- locked down the dependencies, -- created setup and teardown scripts to setup self-contained environments with - those dependencies, and -- CI enabled to validate that they continue work. - -If you have setup failures, please compare the configuration in the **passing CI -builds** with your configuration. That is almost certainly the source of the error. - -__Export statements__ -``` -export ANDROID_SDK_ROOT="/Users//Library/Android/sdk" -``` -``` -export ANDROID_HOME="/Users//Library/Android/sdk" -``` -aka the path where you want the SDK to be installed. - -- android SDK; install manually or use setup script below (**recommended**). Note that you only need to run this once **per computer**. +- Java 17. Tested with [OpenJDK 17 (Temurin) using Adoptium](https://adoptium.net). +- android SDK; install manually or use setup script below. Note that you only need to run this once **per computer**. ``` bash setup/prereq_android_sdk_install.sh ``` @@ -159,11 +131,26 @@ aka the path where you want the SDK to be installed. ``` +- if you are not on the most recent version of OSX, `homebrew` + - this allows us to install the current version of cocoapods without + running into ruby incompatibilities - e.g. + https://github.com/CocoaPods/CocoaPods/issues/11763 +Important +--- +Most of the recent issues encountered have been due to incompatible setup. We +have now: +- locked down the dependencies, +- created setup and teardown scripts to setup self-contained environments with + those dependencies, and +- CI enabled to validate that they continue work. -__Installing (one time only)__ +If you have setup failures, please compare the configuration in the passing CI +builds with your configuration. That is almost certainly the source of the error. -- Run the setup script for the platform you want to build +Installing (one time only) +--- +Run the setup script for the platform you want to build ``` bash setup/setup_android_native.sh @@ -182,18 +169,12 @@ cp www/json/startupConfig.json.sample www/json/startupConfig.json cp ..... www/json/connectionConfig.json ``` -If connecting to a development server over http, make sure to turn on http support on android - -``` - - - -``` -__Run this in every new shell for Activation__ +### Activation (after install, and in every new shell) ``` source setup/activate_native.sh ``` +
Expected Output ``` @@ -210,20 +191,30 @@ Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> pa ```
-
- __Pick a type of build and execute the following:__ -More "versions" are available in [`package.cordovabuild.json`](package.cordovabuild.json) +### Activation (after install, and in every new shell) + +If connecting to a development server over http, make sure to turn on http support on android + ``` -npm run + + + ``` -For instance: (build-dev-android) +### Run in the emulator + ``` -npm run build-dev-android +npm run ``` -
Your expected output should look something like this +AND/OR +``` +npm run +``` +for builds, refer [`package.cordovabuild.json`](package.cordovabuild.json) + +
Expected output ``` BUILD SUCCESSFUL in 2m 48s @@ -234,10 +225,8 @@ Built the following apk(s):
-
- -## Creating logos - +Creating logos +--- If you are building your own version of the app, you must have your own logo to avoid app store conficts. Updating the logo is very simple using the [`ionic cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) @@ -245,7 +234,21 @@ command. **Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work -## Beta-testing debugging + +Troubleshooting +--- +- Make sure to use `npx ionic` and `npx cordova`. This is + because the setup script installs all the modules locally in a self-contained + environment using `npm install` and not `npm install -g` +- Check the CI to see whether there is a known issue +- Run the commands from the script one by one and see which fails + - compare the failed command with the CI logs +- Another workaround is to delete the local environment and recreate it + - javascript errors: `rm -rf node_modules && npm install` + - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` + +Beta-testing debugging +--- If users run into problems, they have the ability to email logs to the maintainer. These logs are in the form of an sqlite3 database, so they have to be opened using `sqlite3`. Alternatively, you can export it to a csv with @@ -253,38 +256,37 @@ dates using the `bin/csv_export_add_date.py` script. ``` -mv ~/Downloads/loggerDB /tmp/logger. -pwd +$ mv ~/Downloads/loggerDB /tmp/logger. +$ pwd .../e-mission-phone -python bin/csv_export_add_date.py /tmp/loggerDB. -less /tmp/loggerDB..withdate.log +$ python bin/csv_export_add_date.py /tmp/loggerDB. +$ less /tmp/loggerDB..withdate.log ``` -## Contributing +Contributing +--- -:point_right:Add the main repo as upstream -``` -git remote add upstream https://github.com/e-mission/e-mission-phone -``` -:point_right:Create a new branch (IMPORTANT). Please do not submit pull requests from master -``` -git checkout -b -``` -:point_right:Make changes to the branch and commit them -``` -git commit -``` -:point_right:Push the changes to your local fork -``` -git push origin -``` -:point_right:Generate a pull request from the UI +Add the main repo as upstream + + git remote add upstream https://github.com/e-mission/e-mission-phone.git + +Create a new branch (IMPORTANT). Please do not submit pull requests from master + + git checkout -b mybranch -
+Make changes to the branch and commit them -__\*__Address my review comments__\*__ + git commit -Once I merge the pull request :smiley: :tada:, pull the changes to your fork and delete the branch +Push the changes to your local fork + + git push origin mybranch + +Generate a pull request from the UI + +Address my review comments + +Once I merge the pull request, pull the changes to your fork and delete the branch ``` git checkout master ``` @@ -297,27 +299,3 @@ git push origin master ``` git branch -d ``` - ---- -### Troubleshooting -:point_right:Xcode command line tools -``` -Warning: No developer tools installed. -You should install the Command Line Tools. -``` -``` -xcode-select --install -``` - -:point_right:Creating Logos -- Make sure to use `npx ionic` and `npx cordova`. This is - because the setup script installs all the modules locally in a self-contained - environment using `npm install` and not `npm install -g` -- Check the CI to see whether there is a known issue -- Run the commands from the script one by one and see which fails - - compare the failed command with the CI logs -- Another workaround is to delete the local environment and recreate it - - javascript errors: `rm -rf node_modules && npm install` - - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` - -(For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Mac OS Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). From 37be42648bf500aca3bf35150c4a2dc563a16716 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 12 Oct 2023 13:16:09 -0600 Subject: [PATCH 052/135] Adding default label-options confirmHelper.ts - Removed archaic behavior or using old Angular i18nUtils module to get the filename of the translations' trip_confirm_options.json file - The "else" default "no label_options found in config" behavior is almost identical to the if(appConfig.label_options), but it just uses the default label options URL at label-options.json.sample - Pulled the language-specific text handling behavior out of the if statement, because either way (label_options or no label_options) the JSON data model will look the same - The for loop for filling in the translatied text for label-options checks 1. The label-options file first, then 2. The i18next file for the translation label-options.json.sample - Created new file - Replaces trip_confirm_options.json.sample (this location in confirmHelper.ts was the only place in the codebase using this file) - Modeled after https://github.com/e-mission/nrel-openpath-deploy-configs/blob/main/label_options/example-program-label-options.json - Only has translations for EN and ES trip_confirm_options.json.sample - Removed this file --- www/js/survey/multilabel/confirmHelper.ts | 37 +++---- www/json/label-options.json.sample | 124 ++++++++++++++++++++++ www/json/trip_confirm_options.json.sample | 52 --------- 3 files changed, 140 insertions(+), 73 deletions(-) create mode 100644 www/json/label-options.json.sample delete mode 100644 www/json/trip_confirm_options.json.sample diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 6350745eb..c7cf74c26 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -36,29 +36,24 @@ export async function getLabelOptions(appConfigParam?) { if (appConfig.label_options) { const labelOptionsJson = await fetchUrlCached(appConfig.label_options); + logDebug("label_options found in config, using dynamic label options at " + appConfig.label_options); labelOptions = JSON.parse(labelOptionsJson) as LabelOptions; - /* fill in the translations to the 'text' fields of the labelOptions, - according to the current language */ - const lang = i18next.language; - for (const opt in labelOptions) { - labelOptions[opt]?.forEach?.((o, i) => { - const translationKey = o.value; - const translation = labelOptions.translations[lang][translationKey]; - labelOptions[opt][i].text = translation; - }); - } } else { - // backwards compat: if dynamic config doesn't have label_options, use the old way - const i18nUtils = getAngularService("i18nUtils"); - const optionFileName = await i18nUtils.geti18nFileName("json/", "trip_confirm_options", ".json"); - try { - const optionJson = await fetch(optionFileName).then(r => r.json()); - labelOptions = optionJson as LabelOptions; - } catch (e) { - logDebug("error "+JSON.stringify(e)+" while reading confirm options, reverting to defaults"); - const optionJson = await fetch("json/trip_confirm_options.json.sample").then(r => r.json()); - labelOptions = optionJson as LabelOptions; - } + const defaultLabelOptionsURL = 'json/label-options.json.sample'; + logDebug("No label_options found in config, using default label options at " + defaultLabelOptionsURL); + const defaultLabelOptionsJson = await fetchUrlCached(defaultLabelOptionsURL); + labelOptions = JSON.parse(defaultLabelOptionsJson) as LabelOptions; + } + /* fill in the translations to the 'text' fields of the labelOptions, + according to the current language */ + const lang = i18next.language; + for (const opt in labelOptions) { + labelOptions[opt]?.forEach?.((o, i) => { + const translationKey = o.value; + // If translation exists in i18next, use that. Otherwise, use the one in the labelOptions. + const translation = labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`); + labelOptions[opt][i].text = translation; + }); } return labelOptions; } diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample new file mode 100644 index 000000000..9d3447bda --- /dev/null +++ b/www/json/label-options.json.sample @@ -0,0 +1,124 @@ +{ + "MODE": [ + {"value":"walk", "baseMode":"WALKING", "met_equivalent":"WALKING", "kgCo2PerKm": 0}, + {"value":"e-bike", "baseMode":"E_BIKE", "met": {"ALL": {"range": [0, -1], "mets": 4.9}}, "kgCo2PerKm": 0.00728}, + {"value":"bike", "baseMode":"BICYCLING", "met_equivalent":"BICYCLING", "kgCo2PerKm": 0}, + {"value":"bikeshare", "baseMode":"BICYCLING", "met_equivalent":"BICYCLING", "kgCo2PerKm": 0}, + {"value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.00894}, + {"value":"drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.22031}, + {"value":"shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.11015}, + {"value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.08216}, + {"value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.04108}, + {"value":"moped", "baseMode":"MOPED", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.05555}, + {"value":"taxi", "baseMode":"TAXI", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.30741}, + {"value":"bus", "baseMode":"BUS", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.20727}, + {"value":"train", "baseMode":"TRAIN", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.12256}, + {"value":"free_shuttle", "baseMode":"BUS", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.20727}, + {"value":"air", "baseMode":"AIR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.09975}, + {"value":"not_a_trip", "baseMode":"UNKNOWN", "met_equivalent":"UNKNOWN", "kgCo2PerKm": 0}, + {"value":"other", "baseMode":"OTHER", "met_equivalent":"UNKNOWN", "kgCo2PerKm": 0} + ], + "PURPOSE": [ + {"value":"home"}, + {"value":"work"}, + {"value":"at_work"}, + {"value":"school"}, + {"value":"transit_transfer"}, + {"value":"shopping"}, + {"value":"meal"}, + {"value":"pick_drop_person"}, + {"value":"pick_drop_item"}, + {"value":"personal_med"}, + {"value":"access_recreation"}, + {"value":"exercise"}, + {"value":"entertainment"}, + {"value":"religious"}, + {"value":"other"} + ], + "REPLACED_MODE": [ + {"value":"no_travel"}, + {"value":"walk"}, + {"value":"bike"}, + {"value":"bikeshare"}, + {"value":"scootershare"}, + {"value":"drove_alone"}, + {"value":"shared_ride"}, + {"value":"e_car_drove_alone"}, + {"value":"e_car_shared_ride"}, + {"value":"taxi"}, + {"value":"bus"}, + {"value":"train"}, + {"value":"free_shuttle"}, + {"value":"other"} + ], + "translations": { + "en": { + "walk": "Walk", + "e-bike": "E-bike", + "bike": "Regular Bike", + "bikeshare": "Bikeshare", + "scootershare": "Scooter share", + "drove_alone": "Gas Car Drove Alone", + "shared_ride": "Gas Car Shared Ride", + "e_car_drove_alone": "E-Car Drove Alone", + "e_car_shared_ride": "E-Car Shared Ride", + "moped": "Moped", + "taxi": "Taxi/Uber/Lyft", + "bus": "Bus", + "train": "Train", + "free_shuttle": "Free Shuttle", + "air": "Air", + "not_a_trip": "Not a trip", + "no_travel": "No travel", + "home": "Home", + "work": "To Work", + "at_work": "At Work", + "school": "School", + "transit_transfer": "Transit transfer", + "shopping": "Shopping", + "meal": "Meal", + "pick_drop_person": "Pick-up/ Drop off Person", + "pick_drop_item": "Pick-up/ Drop off Item", + "personal_med": "Personal/ Medical", + "access_recreation": "Access Recreation", + "exercise": "Recreation/ Exercise", + "entertainment": "Entertainment/ Social", + "religious": "Religious", + "other": "Other" + }, + "es": { + "walk": "Caminando", + "e-bike": "e-bicicleta", + "bike": "Bicicleta", + "bikeshare": "Bicicleta compartida", + "scootershare": "Motoneta compartida", + "drove_alone": "Coche de Gas, Condujo solo", + "shared_ride": "Coche de Gas, Condujo con otros", + "e_car_drove_alone": "e-coche, Condujo solo", + "e_car_shared_ride": "e-coche, Condujo con ontras", + "moped": "Ciclomotor", + "taxi": "Taxi/Uber/Lyft", + "bus": "Autobús", + "train": "Tren", + "free_shuttle": "Colectivo gratuito", + "air": "Avión", + "not_a_trip": "No es un viaje", + "no_travel": "No viajar", + "home": "Inicio", + "work": "Trabajo", + "at_work": "En el trabajo", + "school": "Escuela", + "transit_transfer": "Transbordo", + "shopping": "Compras", + "meal": "Comida", + "pick_drop_person": "Recoger/ Entregar Individuo", + "pick_drop_item": "Recoger/ Entregar Objeto", + "personal_med": "Personal/ Médico", + "access_recreation": "Acceder a Recreación", + "exercise": "Recreación/ Ejercicio", + "entertainment": "Entretenimiento/ Social", + "religious": "Religioso", + "other": "Otros" + } + } +} \ No newline at end of file diff --git a/www/json/trip_confirm_options.json.sample b/www/json/trip_confirm_options.json.sample deleted file mode 100644 index 1e90bc1bb..000000000 --- a/www/json/trip_confirm_options.json.sample +++ /dev/null @@ -1,52 +0,0 @@ -{ - "MODE" : [ - {"text":"Walk", "value":"walk", "baseMode":"WALKING", "met_equivalent": "WALKING", "kgCo2PerKm": 0}, - {"text":"E-bike","value":"e-bike", "baseMode": "E_BIKE", "met": { - "ALL": {"range": [0, -1], "mets": 4.9} - }, "kgCo2PerKm": 0.00728}, - {"text":"Regular Bike","value":"bike", "baseMode":"BICYCLING", "met_equivalent": "BICYCLING", "kgCo2PerKm": 0}, - {"text":"Bikeshare","value":"bikeshare", "baseMode":"BICYCLING", "met_equivalent": "BICYCLING", "kgCo2PerKm": 0}, - {"text":"Scooter share","value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.00894}, - {"text":"Gas Car Drove Alone","value":"drove_alone", "baseMode":"CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.22031}, - {"text":"Gas Car Shared Ride","value":"shared_ride", "baseMode":"CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.11015}, - {"text":"E-Car Drove Alone","value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.08216}, - {"text":"E-Car Shared Ride","value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.04108}, - {"text":"Taxi/Uber/Lyft","value":"taxi", "baseMode":"TAXI", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.30741}, - {"text":"Bus","value":"bus", "baseMode":"BUS", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.20727}, - {"text":"Train","value":"train", "baseMode":"TRAIN", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.12256}, - {"text":"Free Shuttle","value":"free_shuttle", "baseMode":"BUS", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.20727}, - {"text":"Air","value":"air", "baseMode":"AIR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.09975}, - {"text":"Not a Trip","value":"not_a_trip", "baseMode":"UNKNOWN", "met_equivalent": "UNKNOWN", "kgCo2PerKm": 0}, - {"text":"Other","value":"other", "baseMode":"OTHER", "met_equivalent": "UNKNOWN", "kgCo2PerKm": 0}], - "REPLACED_MODE" : [ - {"text":"No travel", "value":"no_travel"}, - {"text":"Walk", "value":"walk"}, - {"text":"Regular Bike","value":"bike"}, - {"text":"Bikeshare","value":"bikeshare"}, - {"text":"Scooter share","value":"scootershare"}, - {"text":"Gas Car, drove alone","value":"drove_alone"}, - {"text":"Gas Car, with others","value":"shared_ride"}, - {"text":"E-Car, drove alone","value":"e_car_drove_alone"}, - {"text":"E-Car, with others","value":"e_car_shared_ride"}, - {"text":"Taxi/Uber/Lyft","value":"taxi"}, - {"text":"Bus","value":"bus"}, - {"text":"Train","value":"train"}, - {"text":"Free Shuttle","value":"free_shuttle"}, - {"text":"Other","value":"other"}], - "PURPOSE" : [ - {"text":"Home", "value":"home"}, - {"text":"To Work","value":"work"}, - {"text":"At Work","value":"at_work"}, - {"text":"School","value":"school"}, - {"text":"Transit transfer", "value":"transit_transfer"}, - {"text":"Shopping","value":"shopping"}, - {"text":"Meal","value":"meal"}, - {"text":"Pick-up/ Drop off Person","value":"pick_drop_person"}, - {"text":"Pick-up/ Drop off Item","value":"pick_drop_item"}, - {"text":"Personal/ Medical","value":"personal_med"}, - {"text":"Access Recreation","value":"access_recreation"}, - {"text":"Recreation/ Exercise","value":"exercise"}, - {"text":"Entertainment/ Social","value":"entertainment"}, - {"text":"Religious", "value":"religious"}, - {"text":"Other","value":"other"}] -} From 650851763f6146ec8cb934ba63d0e1683e37e6b9 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 12 Oct 2023 13:17:08 -0600 Subject: [PATCH 053/135] Adding examples of multilabel translations to en.json en.json - Added multilabel filed filled with a few multilabel translations in EN --- www/i18n/en.json | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/www/i18n/en.json b/www/i18n/en.json index e47fdd62d..07d31c2b3 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -146,6 +146,41 @@ "no-travel-hint": "To see more, change the filters above or go record some travel!" }, + "multilabel":{ + "walk": "Walk", + "e-bike": "E-bike", + "bike": "Regular Bike", + "bikeshare": "Bikeshare", + "scootershare": "Scooter share", + "drove_alone": "Gas Car Drove Alone", + "shared_ride": "Gas Car Shared Ride", + "e_car_drove_alone": "E-Car Drove Alone", + "e_car_shared_ride": "E-Car Shared Ride", + "moped": "Moped", + "taxi": "Taxi/Uber/Lyft", + "bus": "Bus", + "train": "Train", + "free_shuttle": "Free Shuttle", + "air": "Air", + "not_a_trip": "Not a trip", + "no_travel": "No travel", + "home": "Home", + "work": "To Work", + "at_work": "At Work", + "school": "School", + "transit_transfer": "Transit transfer", + "shopping": "Shopping", + "meal": "Meal", + "pick_drop_person": "Pick-up/ Drop off Person", + "pick_drop_item": "Pick-up/ Drop off Item", + "personal_med": "Personal/ Medical", + "access_recreation": "Access Recreation", + "exercise": "Recreation/ Exercise", + "entertainment": "Entertainment/ Social", + "religious": "Religious", + "other": "Other" + }, + "main-metrics":{ "summary": "My Summary", "chart": "Chart", From 4d86b3727623413f113eb3207ac1691ad3f9499e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 13 Oct 2023 01:37:28 -0400 Subject: [PATCH 054/135] Merge branch 'service_rewrite_2023' of https://github.com/e-mission/e-mission-phone into rewrite_angular_service --- .bowerrc | 3 - .gitignore | 4 - bin/download_settings_controls.js | 33 - package.cordovabuild.json | 5 +- package.serve.json | 4 +- resources/android/ic_mood_question.png | Bin 516 -> 0 bytes .../drawable-hdpi-v11/ic_mood_question.png | Bin 1005 -> 0 bytes .../drawable-hdpi-v9/ic_mood_question.png | Bin 671 -> 0 bytes .../drawable-hdpi/ic_mood_question.png | Bin 1455 -> 0 bytes .../drawable-mdpi-v11/ic_mood_question.png | Bin 571 -> 0 bytes .../drawable-mdpi-v9/ic_mood_question.png | Bin 469 -> 0 bytes .../drawable-mdpi/ic_mood_question.png | Bin 834 -> 0 bytes .../drawable-xhdpi-v11/ic_mood_question.png | Bin 1540 -> 0 bytes .../drawable-xhdpi-v9/ic_mood_question.png | Bin 1272 -> 0 bytes .../drawable-xhdpi/ic_mood_question.png | Bin 2140 -> 0 bytes .../drawable-xxhdpi-v11/ic_mood_question.png | Bin 1632 -> 0 bytes .../drawable-xxhdpi-v9/ic_mood_question.png | Bin 1359 -> 0 bytes .../drawable-xxhdpi/ic_mood_question.png | Bin 1706 -> 0 bytes .../drawable-hdpi-v11/ic_question_answer.png | Bin 318 -> 0 bytes .../drawable-hdpi-v9/ic_question_answer.png | Bin 236 -> 0 bytes .../drawable-hdpi/ic_question_answer.png | Bin 895 -> 0 bytes .../drawable-mdpi-v11/ic_question_answer.png | Bin 238 -> 0 bytes .../drawable-mdpi-v9/ic_question_answer.png | Bin 265 -> 0 bytes .../drawable-mdpi/ic_question_answer.png | Bin 631 -> 0 bytes .../drawable-xhdpi-v11/ic_question_answer.png | Bin 351 -> 0 bytes .../drawable-xhdpi-v9/ic_question_answer.png | Bin 390 -> 0 bytes .../drawable-xhdpi/ic_question_answer.png | Bin 1218 -> 0 bytes .../ic_question_answer.png | Bin 509 -> 0 bytes .../drawable-xxhdpi-v9/ic_question_answer.png | Bin 357 -> 0 bytes .../drawable-xxhdpi/ic_question_answer.png | Bin 439 -> 0 bytes resources/minus.gif | Bin 4635 -> 0 bytes resources/plus.gif | Bin 4633 -> 0 bytes scss/ionic.app.scss | 23 - setup/setup_shared_native.sh | 2 - webpack.config.js | 1 + www/css/appstatus.css | 12 - www/css/intro.css | 87 - www/css/main.recent.css | 12 - www/css/style.css | 17 - www/i18n/en.json | 86 +- .../enketo => img}/enketo_bare_150x56.png | Bin www/index.html | 15 +- www/index.js | 21 +- www/js/App.tsx | 88 + www/js/app.js | 111 - www/js/appTheme.ts | 5 +- www/js/appstatus/PermissionsControls.tsx | 65 + www/js/appstatus/permissioncheck.js | 429 -- www/js/components/ActionMenu.tsx | 41 + www/js/components/BarChart.tsx | 206 +- www/js/components/Carousel.tsx | 42 + www/js/components/Chart.tsx | 196 + .../{LeafletView.jsx => LeafletView.tsx} | 13 +- www/js/components/LineChart.tsx | 11 + www/js/components/QrCode.jsx | 19 - www/js/components/QrCode.tsx | 41 + www/js/components/charting.ts | 161 + www/js/config/dynamicConfig.ts | 248 + www/js/config/dynamic_config.js | 348 -- www/js/config/imperial.js | 51 - www/js/config/serverConn.ts | 13 + www/js/config/server_conn.js | 36 - www/js/config/useImperialConfig.ts | 65 +- www/js/control/AlertBar.jsx | 2 +- www/js/control/AppStatusModal.tsx | 466 +- www/js/control/ControlCollectionHelper.tsx | 285 + www/js/control/ControlSyncHelper.tsx | 284 + www/js/control/DemographicsSettingRow.jsx | 42 +- www/js/control/LogPage.tsx | 153 + www/js/control/PopOpCode.jsx | 7 +- www/js/control/PrivacyPolicyModal.tsx | 182 +- www/js/control/ProfileSettings.jsx | 352 +- www/js/control/SensedPage.tsx | 91 + www/js/control/SettingRow.jsx | 2 +- www/js/control/general-settings.js | 44 - www/js/diary.js | 4 +- www/js/diary/LabelTab.tsx | 27 +- www/js/diary/cards/PlaceCard.tsx | 2 +- www/js/diary/cards/TimestampBadge.tsx | 16 +- www/js/diary/cards/TripCard.tsx | 2 +- www/js/diary/diaryHelper.ts | 29 +- www/js/diary/list/LabelListScreen.tsx | 2 +- www/js/diary/services.js | 5 +- www/js/intro.js | 243 - www/js/join/join-ctrl.js | 109 - www/js/main.js | 97 +- www/js/metrics-factory.js | 9 +- www/js/metrics-mappings.js | 10 +- www/js/metrics.js | 1449 ----- www/js/metrics/ActiveMinutesTableCard.tsx | 99 + www/js/metrics/CarbonFootprintCard.tsx | 168 + www/js/metrics/CarbonTextCard.tsx | 151 + www/js/metrics/ChangeIndicator.tsx | 79 + www/js/metrics/DailyActiveMinutesCard.tsx | 64 + www/js/metrics/MetricsCard.tsx | 133 + www/js/metrics/MetricsDateSelect.tsx | 71 + www/js/metrics/MetricsTab.tsx | 152 + www/js/metrics/WeeklyActiveMinutesCard.tsx | 78 + www/js/metrics/metricsHelper.ts | 212 + www/js/metrics/metricsTypes.ts | 14 + www/js/ngApp.js | 82 + www/js/onboarding/ConsentPage.tsx | 43 + www/js/onboarding/OnboardingStack.tsx | 53 + www/js/onboarding/PrivacyPolicy.tsx | 177 + www/js/onboarding/SaveQrPage.tsx | 92 + www/js/onboarding/StudySummary.tsx | 47 + www/js/onboarding/SummaryPage.tsx | 34 + www/js/onboarding/SurveyPage.tsx | 95 + www/js/onboarding/WelcomePage.tsx | 202 + www/js/onboarding/onboardingHelper.ts | 61 + www/js/recent.js | 157 - www/js/splash/notifScheduler.js | 8 +- www/js/splash/startprefs.js | 92 +- www/js/survey/enketo/EnketoModal.tsx | 20 +- www/js/survey/enketo/answer.js | 10 +- .../survey/enketo/enketo-add-note-button.js | 2 - www/js/survey/enketo/enketo-demographics.js | 150 - www/js/survey/enketo/enketo-preview.js | 67 - www/js/survey/enketo/enketo-trip-button.js | 1 - www/js/survey/enketo/enketoHelper.ts | 23 + www/js/survey/enketo/launch.js | 147 - www/js/survey/enketo/service.js | 243 - www/js/survey/external/launch.js | 250 - www/js/survey/external/time_insert.js | 22 - www/js/survey/external/uuid_insert_id.js | 22 - www/js/survey/external/uuid_insert_xpath.js | 27 - .../multilabel/MultiLabelButtonGroup.tsx | 2 +- www/js/survey/multilabel/confirmHelper.ts | 10 +- www/js/survey/multilabel/multi-label-ui.js | 5 +- www/js/useAppConfig.ts | 28 +- www/js/usePermissionStatus.ts | 357 ++ www/manual_lib/fontawesome/css/all.min.css | 5 - .../fontawesome/webfonts/fa-brands-400.eot | Bin 129352 -> 0 bytes .../fontawesome/webfonts/fa-brands-400.svg | 3442 ------------ .../fontawesome/webfonts/fa-brands-400.ttf | Bin 129048 -> 0 bytes .../fontawesome/webfonts/fa-brands-400.woff | Bin 87352 -> 0 bytes .../fontawesome/webfonts/fa-brands-400.woff2 | Bin 74508 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.eot | Bin 34388 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.svg | 803 --- .../fontawesome/webfonts/fa-regular-400.ttf | Bin 34092 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.woff | Bin 16804 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.woff2 | Bin 13580 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.eot | Bin 192116 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.svg | 4649 ----------------- .../fontawesome/webfonts/fa-solid-900.ttf | Bin 191832 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.woff | Bin 98020 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.woff2 | Bin 75440 -> 0 bytes www/templates/appstatus/permissioncheck.html | 80 - www/templates/caloriePopup.html | 24 - www/templates/control/app-status-modal.html | 15 - www/templates/control/main-consent.html | 5 - www/templates/control/qrc.html | 28 - www/templates/intro/changes.html | 21 - www/templates/intro/consent-text.html | 136 - www/templates/intro/consent.html | 21 - www/templates/intro/intro.html | 19 - www/templates/intro/reconsent.html | 10 - www/templates/intro/saveTokenFile.html | 31 - www/templates/intro/sensor_explanation.html | 21 - www/templates/intro/summary.html | 24 - www/templates/intro/survey.html | 1 - www/templates/join/about-app.html | 42 - www/templates/join/request_join.html | 39 - www/templates/main-metrics.html | 225 - www/templates/main.html | 26 - .../metrics/arrow-greater-lesser.html | 38 - www/templates/metrics/metrics-control.html | 80 - www/templates/metrics/range-display.html | 2 - www/templates/recent/log.html | 21 - www/templates/recent/sensedData.html | 21 - www/templates/splash/splash.html | 7 - .../survey/enketo/demographics-button.html | 6 - www/templates/survey/enketo/form-base.html | 43 - www/templates/survey/enketo/inline.html | 40 - www/templates/survey/enketo/modal.html | 13 - www/templates/survey/enketo/preview.html | 6 - 176 files changed, 4564 insertions(+), 15558 deletions(-) delete mode 100644 .bowerrc delete mode 100755 bin/download_settings_controls.js delete mode 100644 resources/android/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-hdpi-v11/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-hdpi-v9/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-hdpi/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-mdpi-v11/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-mdpi-v9/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-mdpi/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xhdpi-v11/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xhdpi-v9/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xhdpi/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xxhdpi-v11/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xxhdpi-v9/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xxhdpi/ic_mood_question.png delete mode 100644 resources/android/ic_question_answer/drawable-hdpi-v11/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-hdpi-v9/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-hdpi/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-mdpi-v11/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-mdpi-v9/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-mdpi/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xhdpi-v11/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xhdpi-v9/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xhdpi/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xxhdpi-v11/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xxhdpi-v9/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xxhdpi/ic_question_answer.png delete mode 100644 resources/minus.gif delete mode 100644 resources/plus.gif delete mode 100644 scss/ionic.app.scss delete mode 100644 www/css/appstatus.css delete mode 100644 www/css/intro.css delete mode 100644 www/css/main.recent.css rename www/{templates/survey/enketo => img}/enketo_bare_150x56.png (100%) create mode 100644 www/js/App.tsx delete mode 100644 www/js/app.js create mode 100644 www/js/appstatus/PermissionsControls.tsx delete mode 100644 www/js/appstatus/permissioncheck.js create mode 100644 www/js/components/ActionMenu.tsx create mode 100644 www/js/components/Carousel.tsx create mode 100644 www/js/components/Chart.tsx rename www/js/components/{LeafletView.jsx => LeafletView.tsx} (92%) create mode 100644 www/js/components/LineChart.tsx delete mode 100644 www/js/components/QrCode.jsx create mode 100644 www/js/components/QrCode.tsx create mode 100644 www/js/components/charting.ts create mode 100644 www/js/config/dynamicConfig.ts delete mode 100644 www/js/config/dynamic_config.js delete mode 100644 www/js/config/imperial.js create mode 100644 www/js/config/serverConn.ts delete mode 100644 www/js/config/server_conn.js create mode 100644 www/js/control/ControlCollectionHelper.tsx create mode 100644 www/js/control/ControlSyncHelper.tsx create mode 100644 www/js/control/LogPage.tsx create mode 100644 www/js/control/SensedPage.tsx delete mode 100644 www/js/control/general-settings.js delete mode 100644 www/js/intro.js delete mode 100644 www/js/join/join-ctrl.js delete mode 100644 www/js/metrics.js create mode 100644 www/js/metrics/ActiveMinutesTableCard.tsx create mode 100644 www/js/metrics/CarbonFootprintCard.tsx create mode 100644 www/js/metrics/CarbonTextCard.tsx create mode 100644 www/js/metrics/ChangeIndicator.tsx create mode 100644 www/js/metrics/DailyActiveMinutesCard.tsx create mode 100644 www/js/metrics/MetricsCard.tsx create mode 100644 www/js/metrics/MetricsDateSelect.tsx create mode 100644 www/js/metrics/MetricsTab.tsx create mode 100644 www/js/metrics/WeeklyActiveMinutesCard.tsx create mode 100644 www/js/metrics/metricsHelper.ts create mode 100644 www/js/metrics/metricsTypes.ts create mode 100644 www/js/ngApp.js create mode 100644 www/js/onboarding/ConsentPage.tsx create mode 100644 www/js/onboarding/OnboardingStack.tsx create mode 100644 www/js/onboarding/PrivacyPolicy.tsx create mode 100644 www/js/onboarding/SaveQrPage.tsx create mode 100644 www/js/onboarding/StudySummary.tsx create mode 100644 www/js/onboarding/SummaryPage.tsx create mode 100644 www/js/onboarding/SurveyPage.tsx create mode 100644 www/js/onboarding/WelcomePage.tsx create mode 100644 www/js/onboarding/onboardingHelper.ts delete mode 100644 www/js/recent.js delete mode 100644 www/js/survey/enketo/enketo-demographics.js delete mode 100644 www/js/survey/enketo/enketo-preview.js delete mode 100644 www/js/survey/enketo/launch.js delete mode 100644 www/js/survey/enketo/service.js delete mode 100644 www/js/survey/external/launch.js delete mode 100644 www/js/survey/external/time_insert.js delete mode 100644 www/js/survey/external/uuid_insert_id.js delete mode 100644 www/js/survey/external/uuid_insert_xpath.js create mode 100644 www/js/usePermissionStatus.ts delete mode 100644 www/manual_lib/fontawesome/css/all.min.css delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.eot delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.svg delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.ttf delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.woff delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.woff2 delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.eot delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.svg delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.ttf delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.woff delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.woff2 delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.eot delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.svg delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.ttf delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.woff delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.woff2 delete mode 100644 www/templates/appstatus/permissioncheck.html delete mode 100644 www/templates/caloriePopup.html delete mode 100644 www/templates/control/app-status-modal.html delete mode 100644 www/templates/control/main-consent.html delete mode 100644 www/templates/control/qrc.html delete mode 100644 www/templates/intro/changes.html delete mode 100644 www/templates/intro/consent-text.html delete mode 100644 www/templates/intro/consent.html delete mode 100644 www/templates/intro/intro.html delete mode 100644 www/templates/intro/reconsent.html delete mode 100644 www/templates/intro/saveTokenFile.html delete mode 100644 www/templates/intro/sensor_explanation.html delete mode 100644 www/templates/intro/summary.html delete mode 100644 www/templates/intro/survey.html delete mode 100644 www/templates/join/about-app.html delete mode 100644 www/templates/join/request_join.html delete mode 100644 www/templates/main-metrics.html delete mode 100644 www/templates/main.html delete mode 100644 www/templates/metrics/arrow-greater-lesser.html delete mode 100644 www/templates/metrics/metrics-control.html delete mode 100644 www/templates/metrics/range-display.html delete mode 100644 www/templates/recent/log.html delete mode 100644 www/templates/recent/sensedData.html delete mode 100644 www/templates/splash/splash.html delete mode 100644 www/templates/survey/enketo/demographics-button.html delete mode 100644 www/templates/survey/enketo/form-base.html delete mode 100644 www/templates/survey/enketo/inline.html delete mode 100644 www/templates/survey/enketo/modal.html delete mode 100644 www/templates/survey/enketo/preview.html diff --git a/.bowerrc b/.bowerrc deleted file mode 100644 index e28246d45..000000000 --- a/.bowerrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "directory": "www/lib" -} diff --git a/.gitignore b/.gitignore index e626e9a48..6801f890d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,5 @@ app-settings.json *.app.zip *.ipa www/dist/ -www/js/control/collect-settings.js -www/templates/control/main-collect-settings.html -www/js/control/sync-settings.js -www/templates/control/main-sync-settings.html config.xml package.json diff --git a/bin/download_settings_controls.js b/bin/download_settings_controls.js deleted file mode 100755 index fce3fb675..000000000 --- a/bin/download_settings_controls.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node - -var https = require('https'); -var fs = require('fs'); - -var download = function(url, dest, cb) { - var file = fs.createWriteStream(dest); - var request = https.get(url, function(response) { - response.pipe(file); - file.on('finish', function() { - file.close(cb); // close() is async, call cb after close completes. - }); - }).on('error', function(err) { // Handle errors - fs.unlink(dest); // Delete the file async. (But we don't check the result) - if (cb) cb(err.message); - }); -}; - -download("https://raw.githubusercontent.com/e-mission/e-mission-data-collection/master/www/ui/ionic/js/collect-settings.js", "www/js/control/collect-settings.js", function(message) { - console.log("Data collection settings javascript updated"); -}); - -download("https://raw.githubusercontent.com/e-mission/e-mission-data-collection/master/www/ui/ionic/templates/main-collect-settings.html", "www/templates/control/main-collect-settings.html", function(message) { - console.log("Data collection settings template updated"); -}); - -download("https://raw.githubusercontent.com/e-mission/cordova-server-sync/master/www/ui/ionic/js/sync-settings.js", "www/js/control/sync-settings.js", function(message) { - console.log("Sync collection settings javascript updated"); -}); - -download("https://raw.githubusercontent.com/e-mission/cordova-server-sync/master/www/ui/ionic/templates/main-sync-settings.html", "www/templates/control/main-sync-settings.html", function(message) { - console.log("Sync collection settings template updated"); -}); diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 20672bad0..b5d69872f 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -8,7 +8,6 @@ "url": "git+https://github.com/e-mission/e-mission-phone.git" }, "scripts": { - "setup-native": "./bin/download_settings_controls.js", "build": "npx webpack --config webpack.prod.js && npx cordova build", "build-dev": "npx webpack --config webpack.dev.js && npx cordova build", "build-dev-android": "npx webpack --config webpack.dev.js && npx cordova build android", @@ -109,7 +108,6 @@ "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", - "angular-nvd3": "^1.0.7", "angular-sanitize": "1.6.7", "angular-simple-logger": "^0.1.7", "angular-translate": "^2.18.1", @@ -127,7 +125,7 @@ "cordova-plugin-app-version": "0.1.14", "cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-device": "2.1.0", - "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.7.9", + "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.8.0", "cordova-plugin-em-opcodeauth": "git+https://github.com/e-mission/cordova-jwt-auth.git#v1.7.2", "cordova-plugin-em-server-communication": "git+https://github.com/e-mission/cordova-server-communication.git#v1.2.6", "cordova-plugin-em-serversync": "git+https://github.com/e-mission/cordova-server-sync.git#v1.3.2", @@ -158,7 +156,6 @@ "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", "npm": "^9.6.3", - "nvd3": "^1.8.6", "phonegap-plugin-barcodescanner": "git+https://github.com/phonegap/phonegap-plugin-barcodescanner#v8.1.0", "prop-types": "^15.8.1", "react": "^18.2.*", diff --git a/package.serve.json b/package.serve.json index f16c8bd66..1a2ef6cb0 100644 --- a/package.serve.json +++ b/package.serve.json @@ -8,7 +8,7 @@ "url": "git+https://github.com/e-mission/e-mission-phone.git" }, "scripts": { - "setup-serve": "./bin/download_settings_controls.js && ./bin/setup_autodeploy.js", + "setup-serve": "./bin/setup_autodeploy.js", "serve": "webpack --config webpack.dev.js && concurrently -k \"phonegap --verbose serve\" \"webpack --config webpack.dev.js --watch\"", "serve-prod": "webpack --config webpack.prod.js && concurrently -k \"phonegap --verbose serve\" \"webpack --config webpack.prod.js --watch\"", "serve-only": "phonegap --verbose serve", @@ -55,7 +55,6 @@ "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", - "angular-nvd3": "^1.0.7", "angular-sanitize": "1.6.7", "angular-simple-logger": "^0.1.7", "angular-translate": "^2.18.1", @@ -83,7 +82,6 @@ "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", "npm": "^9.6.3", - "nvd3": "^1.8.6", "prop-types": "^15.8.1", "react": "^18.2.*", "react-chartjs-2": "^5.2.0", diff --git a/resources/android/ic_mood_question.png b/resources/android/ic_mood_question.png deleted file mode 100644 index 8c7790f2e60045b85171650b80d7d8345213b95e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 516 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 JPDHLkV1kv7`E&pP diff --git a/resources/android/ic_mood_question/drawable-mdpi-v9/ic_mood_question.png b/resources/android/ic_mood_question/drawable-mdpi-v9/ic_mood_question.png deleted file mode 100644 index ecbbe1b3a0502af4ba0faf0efbcab8e10567620c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 469 zcmV;`0V@89P)X1^@s6IQ*`u0004;NklsQ_IkZu)HKa(x7)Rx$Vg6VTg{zL=a_iJJa04_ zlUl9zA|YpTHlSgv)e3JjKO2TIE0s!#N~IFS=$DXlxm->Hn*kcFL1eUcyZwOqUBsIv zl$ta^d%v=d>_@?_R;!76y&hszooOzW`jzCQ1|%g~COPo{jFe@>3FIK>IHP_xn@vrr z>zLjE#00{MQ*s#(fWZPFm*p=OiviAYbro_U0Oz?}Zol8}A5b5Q#{)3Bg<%O$#y(`C z7@cm2GdC{)^kejQ9suHU1+^=%Q^KT^$?vP1cUM54Rsm6xHpQfR>#`0|gZe-Lr$kFW zvho@Wg~Cu?(YM_Lx~^YiI&!;H|3Euf`zq-Y#xO!WlP)4AZh;!?eBtE8{3Mt9<_Jle=#t^Xy00000 LNkvXXu0mjfROZi7 diff --git a/resources/android/ic_mood_question/drawable-mdpi/ic_mood_question.png b/resources/android/ic_mood_question/drawable-mdpi/ic_mood_question.png deleted file mode 100644 index 5030bedfcd9a6b672dc338c84cdad464ebe43a1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 834 zcmV-I1HJr-P)P000>X1^@s6#OZ}&0009CNklI?F|%@+k`j_AER-m_cTYsQ z4pVBT`~}bJ`PQ6_Gv0;SnR+_T_wsqp?|0@L6BYIAJ|>gNoSvTkH!Upt&CN}z|J+YrxQ$1BeZ3MP1nlqc3*d7u zD8`v*ozv+I$&sg-R99D{uC7kh+S-cy`+J<8p32{2V`Cz&j(Lv37@zFy>hlAoTQmio!bNyQ*fGbt@CMR|F- zXl-pxHkFTERaJ$ZogM7%?xLoqM)DjtHa0LbGlPnX3Nd9p`+^MeG?U`uVtkxZ5jUK# zi>nF7{wLP4ersz>#mLi43JVJ*L9MK;;Njr`X0utvsPXY}*{73}6STLttGLBtk^1H3 zWfdb&Gs(-#LqS1-XlQ6i_F;HN1G4eE%tgI~L z=H`m{xYyU$I5;>!NlA%{b9Hlbb2vIWl52x8<_`}K#rShAd74RTYASC}rGbF~`2BvY zuCAh}s7PY@`T6oSt*op>Mn;CjJsywL_4oIO^pmHV7>!0GCnqcAwpCE52Xeth_pwmX>7Yot>S?%*_0@hdj+BIyxE&2?>aaiHTU` zkf)iXq@;Mck+HF{Fc=I!7W>H|FWf(^KOc9HZ+3PTk%}Dhe%~*le}oZ{T6Y2~j{pDw M07*qoM6N<$g5>L*Q~&?~ diff --git a/resources/android/ic_mood_question/drawable-xhdpi-v11/ic_mood_question.png b/resources/android/ic_mood_question/drawable-xhdpi-v11/ic_mood_question.png deleted file mode 100644 index 78f2f253891c9254efe40bbec325da0ef7712e03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1540 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 MvA#Kyp!)##Urr=Em;e9( diff --git a/resources/android/ic_mood_question/drawable-xxhdpi-v11/ic_mood_question.png b/resources/android/ic_mood_question/drawable-xxhdpi-v11/ic_mood_question.png deleted file mode 100644 index 913025e64ba5fe8dc71d5502ac69a4295387e688..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1632 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 diff --git a/resources/android/ic_mood_question/drawable-xxhdpi/ic_mood_question.png b/resources/android/ic_mood_question/drawable-xxhdpi/ic_mood_question.png deleted file mode 100644 index cd2b1614093dd2ee6e3826a61ea7c73ba85a6a45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1706 zcmV;b237fqP)002t}1^@s6I8J)%000JYNkl3CH#EBCpPMkP#;>3v)Cr+F=apJ_u-y{?Y#R`LA20Sh-6IKiBgesxNLO-t+%7qf4 zShyCnuK?SHNreF_lg}zN;+SdC`=b#giqms z@V(G3922^PZi_Ci9}$`vZ|1IqsX~T6XCNpmMYvUX0L3W%AqU}J;bviYP+zw=VU#dS zxLepLd?fq~ZLkgBR3W@3lnKvSlySX+aoeo9Kfz|9QYaL1cy4SUUQZMiFHGW<+Ze_w z_P~?EB4LVfr7%J`&te4Er!Y>P{9f>i`o0ys@^L{?)+iM9pinF96ix}9LIXStxmZIw z^OA*gERwmN&bVC5>dd|LzCA*tP%Ye#!bS!`S+lUHT7Gf;A}53&g@-|Z`M7^!xymEZ zBAgU@dEQpx1y!!!cfAzLN9{yW9l|c*6Cn@!Ls@g6j?kh5g*EU>-lb08m%AjUKiBe6 zr?9A91d*9%>A6`1xJ_7C56`benG^iB`zh1#7ay|NPF_8=sLZpqXkx4^EUbm+s(c{a zBV_w#cO>OVAV6$pJ6G8uJS5EikJh19v;ar1a#Z+^@I%)(*eNSu%2I!H8!1SLfN>9@ z!C#JC{v9Tig30|}Aq*H3;TdC@_o_A5gb>3#@tuy_N1mxtPX;mFDJx*gS|Yqjk{co= z(a1d4Gj6nS2zsGa_#x6x$p&$V@n){cGt)d{^C}_FOglCLn-UjMhN)aS^#-^j(3f9w33|8(3Wv$25J6N1n39j(wX80`Q zXN-ltjBktNv&Jy*B{BnL0wj3GrU_RGt60x53XALfpsXsKdKZf;qgpV;GcLjUf_K0q zJ|{(ZMiS#ROSlm(fH=?CA;N`(G9lKb>#OpEvZ}F?ZWdR{mrMMg){{t{TFQF5SzonN zR(NG4Tgv*KO~ffH%zDm6S<5YDsXyHs7@rm49;w15B;)x6xmsc=;ByZDpBBZvhI)Kf zC-K>)!0d)mWC7aVdY|C7jq;Ak?>?sdb3+vO^7U>QRh$*!9yihb(OBvW;d9|LVK))J zh6+nHQI6tX>9ofxtl-r*aaIWp(B{)c@Dc8@h`g^1pEsA-s+eNlNq&soQI@qAV~wU| zrrqwPeo$61onkvTEb-=s5$>Tyx@WMSC0N>6OIauIJ55pCOLN1gNT5T!g4+IH?3b#; zJWthOFA;bAnbg`zjV=?OAZgaE|10f1MySpARdrBHo#vDkFlE`5(t}o|w1HODoPThy z%V|DT;HPxsc$yyMNDWC&Ggm{5g`P_30UEEm#z9V50aKRg?&x+spR)%h7;K^IqTQE_ zC>5W`Yj`mV8cMD=DBKsOyEB>RwDR0ug5@Gw|Nd&_v*>&R%aFC@H(@PV{qduOxvq-3 z4l6uKzp|Ht*D~JBJqlIUshOGVpusr#7e;w0IO6%%Ine%) z15m{48N4&4ee9AM#9`)yv3**(nKDYa)5@Km)5`nVCF=<`R}e>y_w}xKhOehNyKc_1 zuVI^Sq8Hd^wdaqs(|t`nYdy8ITVTNd(SZ4a=C34*Dd&_^__P1QLTme*xc)ljo5!%w zseMKCPMkP#;>3v)Cr+F=apJ^@6DLlbIQdil0MMkZ?jm*9iU0rr07*qoM6N<$f@%^+ AdH?_b diff --git a/resources/android/ic_question_answer/drawable-hdpi-v11/ic_question_answer.png b/resources/android/ic_question_answer/drawable-hdpi-v11/ic_question_answer.png deleted file mode 100644 index 3ae9173bdd553eedb13988839bfa12a6f82283b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 318 zcmV-E0m1%>P)l?jFF&)lE8j0B3PWXGYw@u7XO*ApdRf^Nn5uDDx>C`^^FzK2WX#Aamse+ z!Uke^yDDf#FN=K;p^AjMIssD7sEV=+(ol&LAnoyWUHXq84|>7>QOWeY0G=nP)bo?- Q;Q#;t07*qoM6N<$f*F^CHUIzs diff --git a/resources/android/ic_question_answer/drawable-hdpi-v9/ic_question_answer.png b/resources/android/ic_question_answer/drawable-hdpi-v9/ic_question_answer.png deleted file mode 100644 index 3d580d05ff1bf03248c59ad96cba3e6bd9ebff6e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 236 zcmVCF5JmIp*=T&B3qj)-HT|lomP%7v|6%0D`Sb zV2lB!R5R&bl>k$osS-#j0n23q?>!`(b6XcdK5MPNrxHQ{mQu&{0?s*LDRumm;8rDI zj6r5umKpQ8*>@a1#Fn25V(~}0wH8`=bv!8v=7JE->A3Nwt*f9lgpiq13YjiTYcJ`x m8IvReUDt1tjGvH^k#`=!sa_-i diff --git a/resources/android/ic_question_answer/drawable-hdpi/ic_question_answer.png b/resources/android/ic_question_answer/drawable-hdpi/ic_question_answer.png deleted file mode 100644 index 4ddd1ed8b33fcc3a67b969812ee55c8bce4d1057..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 895 zcmeAS@N?(olHy`uVBq!ia0vp^Y9P$P1|(P5zFfz^!0hJf;uunK>+KA0@2o(HHtD&E zDHFW{FS2$wu{jE6UijG-SfJyYuXt$%Gwzg@Iwx2w2$QfN8{Ag)iV4Et?r@OlMDJ5)9N={CGQnq{PoO$yuUA`>ry7;31^H;CBu3fuUGWR7@*|O@M zWy_W=v9YteH2>W1-`2A7@~^EfvjWX4DGAY8X;$&vjV&oFeOJTTuHK%W38$Yl zA#?Gf`Q0~{Ss=`Jyjf2)LB5~Dw<96-X@uOtRZ9c0|kPWf1vZf{`9+vj@%U`~HdFA!q zy|zYH+qZAG0!m8Q$y~X1&FkR5MYVf(?$ne^?>Te!Y;PYhSi&MBf8O>t{qg$djT-{e z&)kd4%G^Fy#0UjfA51%X>h$TOto7Cm$<4h-lUCM0dSwcX@TE(ahJ=Mpn;H@x-tGJC z?OWfDmCu1{*Mwv+G~{{QrcDLOoUPxRXlalnkl;OXk; Jvd$@?2>`KctTq4u diff --git a/resources/android/ic_question_answer/drawable-mdpi-v11/ic_question_answer.png b/resources/android/ic_question_answer/drawable-mdpi-v11/ic_question_answer.png deleted file mode 100644 index f21a94577c984e9cc5fe22ec3b5b5a5147570413..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 238 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjTRmMILn>}1CrFq*`2YXEkHZ>< zBezr%d=;LlU1OT?aQ(l@Z2y=FSvQJh{8#7qk(Co;$kbQJV|bLJ@N7XVYw*H9_6trd zU&^Ymz&499RObi7*@_<@j1~G+k2hU*&~Z)*bl$?M-VnhVRbnxr;ks}#>c(#MY=!&0t1*)FP+T itETgIM7H@SGchnqnnrwCcYF@e0}P(7elF{r5}E)bcUS!Y diff --git a/resources/android/ic_question_answer/drawable-mdpi-v9/ic_question_answer.png b/resources/android/ic_question_answer/drawable-mdpi-v9/ic_question_answer.png deleted file mode 100644 index ccc3c7f0a94480b006503f94628145245f0090de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 265 zcmV+k0rvihP)X1^@s6IQ*`u0002cNkl|42C&D57C~2gD~_qUBVlI0^%WcZ{>@1?g`xALs$Y>q8&P5=~FBv{*ow$AUNVr zVjRcoG)=Sx);uv@76onFQqwf7(f56{D8M9@Wyu<2ffUDa9BMs-yGK~-3GS}A z>pI>-`oR-eYfmmulH>(g1ARfe+TIv*!)fJJX!o}xlCK03Xr}P^4gDxSEZbQH<-g$d P00000NkvXXu0mjfGrerw diff --git a/resources/android/ic_question_answer/drawable-mdpi/ic_question_answer.png b/resources/android/ic_question_answer/drawable-mdpi/ic_question_answer.png deleted file mode 100644 index a5943266e768a7cdcb5ae8651b90607587b68953..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 631 zcmV--0*L*IP)P000>X1^@s6#OZ}&0006$Nkl(2-83`P?b!JGbx<4u^^9 zbSk(6uYI%Ggw19HjYflappPl>`Fs!v1c=FGBDf?Pjlys^q&em|2l|*2m&-*5koWg@ z!6m_95c>T-%`wM0(8rWG91d{1-Nbl27F^=@`=Q(I(j0S~1AR=1)oO*mPbq=7rjW7U z@4@5oz}wp!ZDWpeppPkeczB=#$Y3y_(003pVzEdM*EwRb7}#t!Xt&#RKIS+F`j`@v z$pmJzndtR;u-onE4k?vNP%f9h<@58CN-)Pc(8rV*3g{wdJTuefl6LqU-=yRm=dK@32L?azrk1g%gYND z;o5Nf(Z`g?<#JFc6c?dVso?qf8CtCtzYl#(iBu{DnM`&uD0VuX{5JZS5|Ky*cXxLn z7K^V2J?LXfG#brsi^T#Gi3DzMZ!ZS(=t1APkJhhBrSgRztZP9J`hMP@&>z?&44JB~ Ra|Zwb002ovPDHLkV1nebD@yFVk1214BWrKyp#-D6lzgtI5lSJKn z&6&=rZ>hUEpSor-6N|~n$V6|HQc2W6UF2D;gQ_TQ8lab-p}Vp>H#9{B(}1pn<^717 zX@IBPPF(;35I{r#`gh>$f+ncU6`<#g+MEG;JOKzm00Izz0Kx`%pbe_H1RT)9P;=Bn zDOZ3k8d`p#U=6U=%cBHGz>1bnJ9wpI9I&M2v%d3@#l|K9diep$1fq4oL2zJ2!bh$vY%_As3mAr|<@D15#E&GBjfbZKp^^tRH^T zB&7eZE1m9Svk{3zB9UlR6h#+p+uk)zV=#Ezb=|cV0EL%j`BoGKFnFlz`bi4_!;LTu zAqWB%r)k=218{|tlO*|L09lq9zV8E<&k6u1WM5SkjDF4Y{B}_QYES2JqJLo4!)gFz z%9;i6JP)`W$MGJ3+Uhj{I11ppE^rwq+a7@0>a~9XFyLzVs0Dzpis3j89C&BjwxMeb z+2Zj^1yK8?tZxCZlOA`p8;);{-vI2m!nFYoM~>?@oNC$~e*}Qf?aRpV9B_LUfP&}B zjyDX8034HLSuflWS^%fYG|gum#5)=$FA9K<^>Tz*2Vkxes{l-uK+`_4Jb+pWTo>+F k1E5Nf{&-&ssI2007*qoM6N<$f{j0(;{X5v diff --git a/resources/android/ic_question_answer/drawable-xhdpi/ic_question_answer.png b/resources/android/ic_question_answer/drawable-xhdpi/ic_question_answer.png deleted file mode 100644 index c2b8a6368ba8ed857670b6abc30377b29b20e3ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1218 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2o;fuylI5IEGZ*dOPc+mx{MY+i%7r zkuF??VRHfs7r6*N7ks{O&Z$a=5El`V!lg|eU0lC7lsfz$x!u`Ocyo2ru_C6U5AXcq zzkWd-v}3>%|i%3Z7=N?|(km$Rj*loR^R9(uE5XQXAvT%gr^LEoAtlBqTQ2 z0L^&$=FO9CuO<8U&reHB%gD@p=^YUhv*hK=%z5G4<_Uf<+2yuk`}Xj|SFVV}#>HKF z{aU-$b?=@%7mglfo&R~$*Pe#uMjqMO+Vks`xVgFG#UAZ7xPRnGONp&A$SHU3oOwR~ z#GgMlGXs;8lP^J3CnqbHmXu`7m_6HAmBUZ>W|iB4%P-na+*UKPw$Anv*b)R136@;4 zZe8DYUl!TZJxJS6;1*9~E?JJxk2SK-8i_Obr%w3ur$$Rh=gNnT zwsolsnG@?|QrAX4V*&=$q}j7iXS$}m{g}VsATBnxQ2OYfty{NVx~2nm?#XlKo^4Uu z@wuzCy!?3iqqAqv`fuL2@!~4G6 zn?KG61qLeSo~?TP5NOT3d47?Rky5vA-Kx`nP_{d>ynOnnPoI_~|7R&t+PGoay$WEk z0Yf#&&8@BN`?nuIew?sw3}#xte$AQ-H*emIFRrW8%Z2*o$&(Z>Z|}C?%pn7%yo8k zExK{zMn0=-Mt*+(ym|Bf3A20`+xs1u8dQvoCM{aLc>mlZM_lq=nwy*NZ+@_Dy}2CF zShLx^?3K)0uFRb)TUA&0OrfIUk~B9kJ~}!$>Sfe?svL`RBRqH|j04klFu% zr`P_{Z4~@VaocY6_*>CyfZM$|=?b)~Q-(}{=j`<&&=D+_RT=UiUD}T(Z WwnvY-U)%teO$?r{elF{r5}E)K;zwKn diff --git a/resources/android/ic_question_answer/drawable-xxhdpi-v11/ic_question_answer.png b/resources/android/ic_question_answer/drawable-xxhdpi-v11/ic_question_answer.png deleted file mode 100644 index 2586cd25db2bb2d923f489356fa5c56d56f5957b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 509 zcmV;3a?)f|vHdccooT0y1|ftHLI?>a&-14ASlX7R(#%oyp3bEQF@Xx4 zv2>RE%0&7oy^IM|;CRiuv>OvBbvSb7^C^9f36$mj(*p<~fB*srAb@HGB-d{P+mqB5 zzel=_syk3G@ZOniQSKE9pz`SUsSQwhbR?w!ElvgyKmY**5I_I{1Q0*~0R#|0n~46h z+5xB>rz^dy0?FkX~;&KRy@*=*>os&N+L5AL@FJI^8xH9 zr}cZz0qFZfw^n_W=l)N`t{>=+n0l;a0#G@Sp1ILuCF6>Wq^$!r4JqdTI&{8#aS7yP z^?Wk-4q&`69P^X480BG?#E(2(_Ezn0(EA9jdLI@#*kZ`^Lp)u;_`+pyu00000NkvXXu0mjfg^SpE diff --git a/resources/android/ic_question_answer/drawable-xxhdpi-v9/ic_question_answer.png b/resources/android/ic_question_answer/drawable-xxhdpi-v9/ic_question_answer.png deleted file mode 100644 index e80a4e042cf3c8247bb276ced0f6638a2759f006..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 357 zcmeAS@N?(olHy`uVBq!ia0vp^20-l1!3HGFp0l(vFfi(Px;TbZ+F=$=RT3s-mums#*G5mm4x}&6xK*Z1Fb}wS?Vf!tXfZ`jo7nwluEX`xFSEAg zUf=h=){N=*>sO|$S7jw?Z}8k8cHvjpB@mYZ$G)Q#!N9+D+t-QE_>6&^{gqGRcLP-j)rbKTI73It+MT>gcrTyjQv5({)ZIqIY+n%8j#c xz6t5D_j@_Dh1~G-H|M;joS@q1XaVtW!ed7MHBGL*F_Tn50-mmZF6*2UngC|cjHLho diff --git a/resources/android/ic_question_answer/drawable-xxhdpi/ic_question_answer.png b/resources/android/ic_question_answer/drawable-xxhdpi/ic_question_answer.png deleted file mode 100644 index 799f0e8bad39bb70ca1dfcac4a7068d8796bd3c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 439 zcmeAS@N?(olHy`uVBq!ia0vp^-XP4u1|%)~s$MZLFxGjxIEGZ*dV6!P7qcUS>%#^Y zT^8+)0*e;4PL1dkxVWM>H}25$4I2d3)G%!f&e`Il7~SEY$Pp1C{V~4fBcD$~zYSa6JhksC<8G$Y z{HMjh%HV`$^4<*54VS8~p4+-I@nBZA`8(b()yHqGGPB}|$o-y^Z~gc+C*NyL{uKfn z=gD3P^Sdt0b8U9>t)994xA%YNDHmkAHoZB@f6nzY2_^v%Vp(fXTSjoMG0uJ3U%Vkn zqFT&Lno(Oe+e!O>aa`&5n1srNa~bTnlp|h7q_T(k+GxJ}&bRgVf{jLh0<>=Jy9RV# z&b>y{gP)kA3lho`em3fsF*2WKUc(udAAeFTV(;G<^A|m=kKWkLA_xVdE9JfB%xRHu SvNQok2!p4qpUXO@geCxd=CM)$ diff --git a/resources/minus.gif b/resources/minus.gif deleted file mode 100644 index 0115810b967dea899c60d902875799227699703e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4635 zcma);=U>u`n29p7n}hw03JjE`uh5to0|jzAv`?1 zu&@w?LgDfF-rnBD#l?w<2`m|KtDvWcT<$0DuBc{y*h^ zO@N<=RY>{Ey}+%e;mJmi*U1S#&&8mI&9F@#v&hT%q+}|q%xC0@P9e9-&$Rv?ZMrUC z^h-$Z5;`RabGQ4&#O48yNQ#JzijIkmi%&>QN=`{lBfm_~ z$jqWpvvYFu@(T)!ic3n%$}1|Xs%vWN>KkbE#-`?$*0y#=M`u^}tDatFU;n`1>!D%R z2zzvlGd{t6Gx>Jv?|1K~XJ+T-KP)UREw8Np^YPQaYyYipeE#xvb8GwC_njXw= z9~}Pv!vi1)X=8d(UnKmfssoE&JP?bLH_bC{EP0)X(e#*SHI@#iVy`Ahn>3Y;q$}Uz z)i{ham5);JF6=y$<_b=399VjKL=(6X1P>6#Ac~ZUFynO9`<+wD#9~N*+-|T1jsyY& z$^>?6?R%QB7=XePnV>=&vWy!O&nvhif1&gEBW>#|Fc`81w!r22yVT+ ztQ(!!wz=qqyfvPm4!A6_(gV*Cb9b@eSD9voJv(i_mj(C+E5`FfK_G#KMFFn9@2~G4 z#Ll8lEm#EXY^`1f77K((rQg1n!t{%JvH=LSa*;T%f(JtXc#{AgWDdPX2;|4(9U|W4WUshq#qQ6*)!;3OEm6O{*)ys2t3Nv;yd@q{yMlrA@>v?@G%vd=C2=Qq2LAFar?s{CWWPNY~B`k3i^lvpLF@ApJvq3cE|Z*rkBLYaG76*zL58AQU>84ze_z+qqJQo@$R%q z=?z@lkrFa)XG}{@4AG>LAMa^lekuiC;2GslJZhD49N-6}Eg@PV*UBsJv`ayepYYPp z4}R_&8u8jLtfYRqZ?wj|;Ayql^F%@A;>!0)8ovwoQn>fle;lqPO z-XGY-8BzW4Jpk*P=Nij5{*V+vaP$bcjuI*{dLj0YAoL4xdB&2CohwcL{fS`Ek;E0= z$Q6R~uK+vS#nIluvVx?mbkfqz=;zGh^?lUUnT|6N*&AaBwDOh9qUdwd^RckZU6^dd zwb-Db755vgBd4qz6D$rR#V<4u@!;JQRiFS4xgsp&8<0z-uze~{_mUasaYKn24$r5SY`bR@>;YTowQ z&XciHQr}ru_?lJX@d}cwXp6WP}? zj;j3Y_fSOUyQUu>(LI7xDz~sl3|e2N=2epPA49xgri zggOF@in8dn@?W!UpKjegeX(ARAnSaq19e9-CjX>@=)t@3H8F{>GMtk32Ao_FllC`p zolnzXHojyi>piNDaH;D}lcQX7?sl!F-K?!e%R6)vzo13E)y%B)IaIG_l!w;QyDpza zRXRV^JyJwFwB3NvcnkA}KA8u%7~H=xsnKSB<&WpgZ30hIEQWsEfY>kIuoW47KIZN1 z{cb+sK^OBH$1b~Xj?wb|)kltDn3U$cD}kc9D&|4t=-RfH#IzlRZ7O(ePxoEyCPI{ANk7 zm;2Y#pY|wxz0-q5+Rq#W>4FH`v!dr>qS9S!llFC>Wv3kRxI223-@=ijX=2whJOy3; zVg+epk2R;Nkeq2tR56{e2{A{zJ_-LqOAu0!@u`9=tD;>t%T3cZNH9VDI+%6hQjEJ+ zPJZn2azk0RXlhDSQMQJwJ@wvr1^m21$i+2ha|t{rN~2mumc!9! zi2a@Qtf}&%z-?Wn*=}Mh@@Da85!yXWQA#5D`sf!EWr>qj1%^~5p{TdqE@;fUiOGG7 zoVSK68j@-h)Un@~LH_@ zE@`ghf3Yj08hj!PJyx-YJbWIh{n$qM zWH{d&-cf^%y&d9`#HmgfJ5GPWO}P(VeH|yDNsh;D^zc{C7`!F>cxotoXg%(_@8OUA z%7D*UJsuvIky8IzW+{WRgPBcR(Qnn47JW6|`yumb9A0HFjdtYp(J5CTOx)|uCs;js za2$NVb{D#E%)HxqX}2s)TKJ`t;Xm#mOh&U%ui$@yM1_c!ql|H^9p(z#H{Ex7b-y*-;H39oY)5X?Z^N zw*-p<8={1$aY+QwQ!kh_e%SDrIAZ1<21kWWx!zSvi)f;Nu|a;u*CWWx2&Xq8+Hum9 z*O6uhxXV>Oj+VsDh*165D3zipQ}ytM)u@}wQDkiNEjzui_Yyd6l+=6IT7a0G7KsP~ zAGQP^S<@miZ~8Bb`q{+>1jPnXV~u2S{`io(9k>Tv+~Mr)|NQ{>m)=P6UaSpsp<+x{r zXcK2w0vu#LozSqGK=6*Y!zLOV2vo;CVZ4s97RqLBm~TXmv4h~IoB!{l2AFM^wX>}aC83gVuM zl-&im(}+wNi@#!8tQ?<}kIsrB`dDk|dZxt_i?UMtvani!R{ZIB2Gr*Q1^<8&hgS;G zrbgYR#&pD<>40(>sTKN^H}ID-q?pPjst<^&v>aWfpS=ammgytgfVy=lt0g`ilK_359 zsA^EyC?U8Rprc!oq+W%5`ptPE`(J63Dakea*kY|i8 zwO&V>?u(xbDR0{sjwrYwez~*KBtY^GqN*8RafDkD#TKZ=RmPj%s3cb;vk|U3;&GDIZF>b-s>tUx zk=d$9cAV&^_tp8tjDE$M#;s~uw;Gy$4UgDYRkIJP3Mr$72-NM17wgngh&2-NwOjm^ z##YtcYIy4Vv!*(Ps2SuBCO?#Ul$wbhD zi)axBb$oO1)qQb74m}j>>jSD5Mus*oHyrJ{dc(hw3|Qti`bu`$K~>2o4&XtEVzF|3#har$*t@x{>Ok89g~((E&uPzo4i{ahP~=^TO<@rZLog~1uM5Ynel_&T3vFl>G`#V0;K8c zHvFEH<3^j)68V8z`$=%ipVv(RX7YLm&h4G;j`(IUlHu!h>8=^Wr>Mzcxg`z>ZM+hl zym9>bMjUw#`n1C!#J+?2KHa~z&3eR8MgdNhGE0|%HDwO+l5 z_NLYDU3>5cYcg+pu2&6p>d2K-U}-+SL+s~JrkL z#ibz`azJ{m^xL^UWrA|tO9ixfzZ?MvA>!OMyjP_}^}NOYdY}ZL`h}Y)-CfupNCg{> z9$DN=6VSkn8Uu8ppZ$1n(@kS-gLXylL`5!LQc(W9e6Ll!?Cqpp?L)b{NxjHiCG>;F zIJ3@JH>_1s6Fpvfx1=A1fBj8_*}gHfReap_WQ)&dnVr5N9I*v8*4rHVy7R4cNF8|C z6x*-PI^#7=^Oy4PZl74!739(t>Ux{EB_fho=Yhsfd5H&lEI4<-><;E-(g+R(Bsho% zsr?Bz0yz%w!@z3+) zUk}H(q$j@VP3$;K{0y7e&70Woo;aMJ_;WY`$Z)|IxqSDz(C1uuJ{R$dD<~u)3jn(R E2mOAAEdT%j diff --git a/resources/plus.gif b/resources/plus.gif deleted file mode 100644 index 6879c87437a5e0f5e79cf1227dd074e178f87a69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4633 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 div { --accent-dark: hsl(200 100% 30%); } -body.platform-ios { - padding-top: calc(env(safe-area-inset-top) / 2); - margin: 0 10px 0 0; -} - .view-container.tab-content { height: auto !important; bottom: 50px !important; @@ -746,15 +738,6 @@ timestamp-badge[light-bg] { padding: 5% 10%; } -svg { - display: block; -} -#chart, #chart svg { - margin-right: 10px; -} -.nvd3, nv-noData { - font-weight: 300 !important; -} .metric-datepicker { /*height: 33px;*/ display: flex; /* establish flex container */ diff --git a/www/i18n/en.json b/www/i18n/en.json index 758960ade..3299a2207 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -12,7 +12,7 @@ }, "control":{ - "profile": "Profile", + "profile-tab": "Profile", "edit-demographics": "Edit Demographics", "tracking": "Tracking", "app-status": "App Status", @@ -36,7 +36,9 @@ "nuke-all": "Nuke all buffers and cache", "test-notification": "Test local notification", "check-log": "Check log", + "log-title" : "Log", "check-sensed-data": "Check sensed data", + "sensed-title": "Sensed Data: Transitions", "collection": "Collection", "sync": "Sync", "button-accept": "I accept", @@ -71,6 +73,7 @@ }, "metrics":{ + "dashboard-tab": "Dashboard", "cancel": "Cancel", "confirm": "Confirm", "get": "Get", @@ -101,13 +104,16 @@ "less-than": " less than ", "less": " less ", "week-before": "vs. week before", + "this-week": "this week", "pick-a-date": "Pick a date", "trips": "trips", "hours": "hours", + "minutes": "minutes", "custom": "Custom" }, "diary": { + "label-tab": "Label", "distance-in-time": "{{distance}} {{distsuffix}} in {{time}}", "distance": "Distance", "time": "Time", @@ -140,46 +146,42 @@ "no-travel-hint": "To see more, change the filters above or go record some travel!" }, - "user-gender": "Gender", - "gender-male": "Male", - "gender-female": "Female", - "user-height": "Height", - "user-weight": "Weight", - "user-age": "Age", - "main-metrics":{ - "dashboard": "Dashboard", "summary": "My Summary", "chart": "Chart", "change-data": "Change dates:", - "distance": "My Distance", - "trips": "My Trips", - "duration": "My Duration", + "distance": "Distance", + "trips": "Trips", + "duration": "Duration", "fav-mode": "My Favorite Mode", "speed": "My Speed", "footprint": "My Footprint", + "estimated-emissions": "Estimated CO₂ emissions", "how-it-compares": "Ballpark comparisons", - "optimal": "Optimal (perfect mode choice for all my trips):", - "average": "Average for group:", - "avoided": "CO₂ avoided (vs. all 'taxi'):", + "optimal": "Optimal (perfect mode choice for all my trips)", + "average": "Group Avg.", + "worst-case": "Worse Case", "label-to-squish": "Label trips to collapse the range into a single number", + "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", "lastweek": "My last week value:", - "us-2030-goal": "US 2030 Goal Estimate:", - "us-2050-goal": "US 2050 Goal Estimate:", - "calories": "My Calories", - "calibrate": "Calibrate", + "us-2030-goal": "2030 Guideline¹", + "us-2050-goal": "2050 Guideline¹", + "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", + "past-week" : "Past Week", + "prev-week" : "Prev. Week", "no-summary-data": "No summary data", "mean-speed": "My Average Speed", - "equals-cookies_one": "Equals at least {{count}} homemade chocolate chip cookie", - "equals-cookies_other": "Equals at least {{count}} homemade chocolate chip cookies", - "equals-icecream_one": "Equals at least {{count}} half cup vanilla ice cream", - "equals-icecream_other": "Equals at least {{count}} half cups vanilla ice cream", - "equals-bananas_one": "Equals at least {{count}} banana", - "equals-bananas_other": "Equals at least {{count}} bananas" - }, - - "main-inf-scroll" : { - "tab": "Label" + "user-totals": "My Totals", + "group-totals": "Group Totals", + "active-minutes": "Active Minutes", + "weekly-active-minutes": "Weekly minutes of active travel", + "daily-active-minutes": "Daily minutes of active travel", + "active-minutes-table": "Table of active minutes metrics", + "weekly-goal": "Weekly Goal³", + "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", + "labeled": "Labeled", + "unlabeled": "Unlabeled²", + "footprint-label": "Footprint (kg CO₂)" }, "details":{ @@ -222,6 +224,7 @@ }, "intro": { + "proceed": "Proceed", "appstatus": { "fix": "Fix", "refresh":"Refresh", @@ -327,22 +330,23 @@ "enketo-timestamps-invalid": "The times you entered are invalid. Please ensure that the start time is before the end time." }, "join": { - "welcome-to-nrel-openpath": "Welcome to NREL OpenPATH", - "proceed-further": "To proceed further, you need to enter a valid OPcode (token)", - "what-is-opcode": "The OPcode is a long string starting with 'nrelop' that has been provided by your program admin through a website, email, text or printout.", - "or": "or", - "scan-button": "Scan the QR code ", - "scan-details": "The OPcode will be written at the top of the image", - "paste-button": "Paste the OPcode", - "paste-details": "We suggest copy-pasting instead of typing since the OPcode is long and jumbled", + "welcome-to-app": "Welcome to {{appName}}!", + "app-name": "NREL OpenPATH", + "to-proceed-further": "To proceed further, please scan or paste an access code that has been provided by your program administrator through a website, email, text message, or printout.", + "code-hint": "The code begins with ‘nrelop’ and may be in barcode or text format.", + "scan-code": "Scan code", + "paste-code": "Paste code", + "scan-hint": "Scan the barcode with your phone camera", + "paste-hint": "Or, paste the code as text", + "about-app-title": "About {{appName}}", "about-app-para-1": "The National Renewable Energy Laboratory’s Open Platform for Agile Trip Heuristics (NREL OpenPATH) enables people to track their travel modes—car, bus, bike, walking, etc.—and measure their associated energy use and carbon footprint.", "about-app-para-2": "The app empowers communities to understand their travel mode choices and patterns, experiment with options to make them more sustainable, and evaluate the results. Such results can inform effective transportation policy and planning and be used to build more sustainable and accessible cities.", "about-app-para-3": "It does so by building an automatic diary of all your trips, across all transportation modes. It reads multiple sensors, including location, in the background, and turns GPS tracking on and off automatically for minimal power consumption. The choice of the travel pattern information and the carbon footprint display style are study-specific.", + "tips-title": "Tip(s) for correct operation:", "all-green-status": "Make sure that all status checks are green", "dont-force-kill": "Do not force kill the app", "background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off", - "close": "Close", - "tips-title": "Tip(s) for correct operation:" + "close": "Close" }, "config": { "unable-read-saved-config": "Unable to read saved config", @@ -360,7 +364,9 @@ "errors": { "while-populating-composite": "Error while populating composite trips", "while-loading-another-week": "Error while loading travel of {{when}} week", - "while-loading-specific-week": "Error while loading travel for the week of {{day}}" + "while-loading-specific-week": "Error while loading travel for the week of {{day}}", + "while-log-messages": "While getting messages from the log ", + "while-max-index" : "While getting max index " }, "consent-text": { "title":"NREL OPENPATH PRIVACY POLICY/TERMS OF USE", diff --git a/www/templates/survey/enketo/enketo_bare_150x56.png b/www/img/enketo_bare_150x56.png similarity index 100% rename from www/templates/survey/enketo/enketo_bare_150x56.png rename to www/img/enketo_bare_150x56.png diff --git a/www/index.html b/www/index.html index d5d3266ad..451c3047f 100644 --- a/www/index.html +++ b/www/index.html @@ -11,19 +11,8 @@ - - - - - - - + +
diff --git a/www/index.js b/www/index.js index 578b3dc75..d91357d18 100644 --- a/www/index.js +++ b/www/index.js @@ -1,16 +1,9 @@ import './manual_lib/ionic/css/ionic.css'; import './css/style.css'; -import './css/intro.css'; -import './css/appstatus.css'; -import './css/main.recent.css'; import './css/main.diary.css'; -import './manual_lib/fontawesome/css/all.min.css'; import 'leaflet/dist/leaflet.css'; -import './js/app.js'; -import './js/config/dynamic_config.js'; -import './js/config/imperial.js'; -import './js/config/server_conn.js'; +import './js/ngApp.js'; import './js/stats/clientstats.js'; import './js/splash/referral.js'; import './js/splash/startprefs.js'; @@ -19,34 +12,22 @@ import './js/splash/storedevicesettings.js'; import './js/splash/localnotify.js'; import './js/splash/remotenotify.js'; import './js/splash/notifScheduler.js'; -import './js/join/join-ctrl.js'; import './js/controllers.js'; import './js/services.js'; import './js/i18n-utils.js'; -import './js/intro.js'; import './js/main.js'; import './js/survey/input-matcher.js'; import './js/survey/multilabel/infinite_scroll_filters.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; -import './js/recent.js'; import './js/diary/services.js'; -import './js/survey/external/launch.js'; import './js/survey/enketo/answer.js'; -import './js/survey/enketo/launch.js'; -import './js/survey/enketo/service.js'; import './js/survey/enketo/infinite_scroll_filters.js'; import './js/survey/enketo/enketo-trip-button.js'; -import './js/survey/enketo/enketo-demographics.js'; import './js/survey/enketo/enketo-add-note-button.js'; -import './js/metrics.js'; -import './js/control/general-settings.js'; import './js/control/emailService.js'; import './js/control/uploadService.js'; -import './js/control/collect-settings.js'; -import './js/control/sync-settings.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; import './js/plugin/storage.js'; -import './js/appstatus/permissioncheck.js'; diff --git a/www/js/App.tsx b/www/js/App.tsx new file mode 100644 index 000000000..1ace61531 --- /dev/null +++ b/www/js/App.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState, createContext, useMemo } from 'react'; +import { getAngularService } from './angular-react-helper'; +import { BottomNavigation, Button, useTheme } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import LabelTab from './diary/LabelTab'; +import MetricsTab from './metrics/MetricsTab'; +import ProfileSettings from './control/ProfileSettings'; +import useAppConfig from './useAppConfig'; +import OnboardingStack from './onboarding/OnboardingStack'; +import { OnboardingRoute, OnboardingState, getPendingOnboardingState } from './onboarding/onboardingHelper'; +import { setServerConnSettings } from './config/serverConn'; +import AppStatusModal from './control/AppStatusModal'; + +const defaultRoutes = (t) => [ + { key: 'label', title: t('diary.label-tab'), focusedIcon: 'check-bold', unfocusedIcon: 'check-outline' }, + { key: 'metrics', title: t('metrics.dashboard-tab'), focusedIcon: 'chart-box', unfocusedIcon: 'chart-box-outline' }, + { key: 'control', title: t('control.profile-tab'), focusedIcon: 'account', unfocusedIcon: 'account-outline' }, +]; + +export const AppContext = createContext({}); + +const App = () => { + + const [index, setIndex] = useState(0); + const [pendingOnboardingState, setPendingOnboardingState] = useState(null); + const [permissionsPopupVis, setPermissionsPopupVis] = useState(false); + const appConfig = useAppConfig(); + const { colors } = useTheme(); + const { t } = useTranslation(); + + const StartPrefs = getAngularService('StartPrefs'); + + const routes = useMemo(() => { + const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; + return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter(r => r.key != 'metrics'); + }, [appConfig, t]); + + const renderScene = BottomNavigation.SceneMap({ + label: LabelTab, + metrics: MetricsTab, + control: ProfileSettings, + }); + + const refreshOnboardingState = () => getPendingOnboardingState().then(setPendingOnboardingState); + useEffect(() => { refreshOnboardingState() }, []); + + useEffect(() => { + if (!appConfig) return; + setServerConnSettings(appConfig).then(() => { + refreshOnboardingState(); + }); + }, [appConfig]); + + const appContextValue = { + appConfig, + pendingOnboardingState, setPendingOnboardingState, refreshOnboardingState, + permissionsPopupVis, setPermissionsPopupVis, + } + + console.debug('pendingOnboardingState in App', pendingOnboardingState); + + return (<> + + {pendingOnboardingState == null ? + + : + + } + { /* if onboarding is done (state == null), or if is in progress but we are past the + consent page (route > CONSENT), the permissions popup can show if needed */ } + {(pendingOnboardingState == null || pendingOnboardingState.route > OnboardingRoute.CONSENT) && + + } + + ); +} + +export default App; diff --git a/www/js/app.js b/www/js/app.js deleted file mode 100644 index 2d0b1d08d..000000000 --- a/www/js/app.js +++ /dev/null @@ -1,111 +0,0 @@ -// Ionic E-Mission App - -'use strict'; - -import angular from 'angular'; -import 'angular-animate'; -import 'angular-sanitize'; -import 'angular-translate'; -import '../manual_lib/angular-ui-router/angular-ui-router.js'; -import 'angular-local-storage'; -import 'angular-translate-loader-static-files'; - -import 'moment'; -import 'moment-timezone'; -import 'chartjs-adapter-luxon'; - -import 'ionic-toast'; -import 'ionic-datepicker'; -import 'angular-simple-logger'; - -import '../manual_lib/ionic/js/ionic.js'; -import '../manual_lib/ionic/js/ionic-angular.js'; - -import initializedI18next from './i18nextInit'; -window.i18next = initializedI18next; -import 'ng-i18next'; - -angular.module('emission', ['ionic', 'jm.i18next', - 'emission.controllers','emission.services', 'emission.plugin.logger', - 'emission.splash.referral','emission.services.email', - 'emission.intro', 'emission.main', 'emission.config.dynamic', - 'emission.config.server_conn', 'emission.join.ctrl', - 'pascalprecht.translate', 'LocalStorageModule']) - -.run(function($ionicPlatform, $rootScope, $http, Logger, localStorageService, ServerConnConfig) { - console.log("Starting run"); - // ensure that plugin events are delivered after the ionicPlatform is ready - // https://github.com/katzer/cordova-plugin-local-notifications#launch-details - window.skipLocalNotificationReady = true; - // alert("Starting run"); - // BEGIN: Global listeners, no need to wait for the platform - // TODO: Although the onLaunch call doesn't need to wait for the platform the - // handlers do. Can we rely on the fact that the event is generated from - // native code, so will only be launched after the platform is ready? - // END: Global listeners - $ionicPlatform.ready(function() { - // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard - // for form inputs) - Logger.log("ionicPlatform is ready"); - - if (window.StatusBar) { - // org.apache.cordova.statusbar required - StatusBar.styleDefault(); - } - cordova.plugin.http.setDataSerializer('json'); - // backwards compat hack to be consistent with - // https://github.com/e-mission/e-mission-data-collection/commit/92f41145e58c49e3145a9222a78d1ccacd16d2a7#diff-962320754eba07107ecd413954411f725c98fd31cddbb5defd4a542d1607e5a3R160 - // remove during migration to react native - localStorageService.remove("OP_GEOFENCE_CFG"); - cordova.plugins.BEMUserCache.removeLocalStorage("OP_GEOFENCE_CFG"); - }); - console.log("Ending run"); -}) - -.config(function($stateProvider, $urlRouterProvider, $compileProvider) { - console.log("Starting config"); - // alert("config"); - - // Ionic uses AngularUI Router which uses the concept of states - // Learn more here: https://github.com/angular-ui/ui-router - // Set a few states which the app can be in. - // The 'intro' and 'diary' states are found in their respective modules - // Each state's controller can be found in controllers.js - $compileProvider.imgSrcSanitizationWhitelist(/^\s*(https?|ftp|file|blob|ionic):|data:image/); - $stateProvider - // set up a state for the splash screen. This has no parents and no children - // because it is basically just used to load the user's preferred screen. - // This cannot directly use plugins - has to check for them first. - .state('splash', { - url: '/splash', - templateUrl: 'templates/splash/splash.html', - controller: 'SplashCtrl' - }) - - // add the join screen to the list of initially defined states - // we can't put it in intro since it comes before it - // we can't put it in main because it is also a temporary screen that only - // shows up when we have no config. - // so we put it in here - .state('root.join', { - url: '/join', - templateUrl: 'templates/join/request_join.html', - controller: 'JoinCtrl' - }) - - // setup an abstract state for the root. Only children of this can be loaded - // as preferred screens, and all children of this can assume that the device - // is ready. - .state('root', { - url: '/root', - abstract: true, - template: '', - controller: 'RootCtrl' - }); - - // alert("about to fall back to otherwise"); - // if none of the above states are matched, use this as the fallback - $urlRouterProvider.otherwise('/splash'); - - console.log("Ending config"); -}); diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index 7571a564c..a8660e811 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -8,7 +8,7 @@ const AppTheme = { colors: { ...DefaultTheme.colors, primary: '#0080b9', // lch(50% 50 250) - primaryContainer: '#90ceff', // lch(80% 40 250) + primaryContainer: '#c0e2ff', // lch(88% 30 250) onPrimaryContainer: '#001e30', // lch(10% 50 250) secondary: '#c08331', // lch(60% 55 70) secondaryContainer: '#fcefda', // lch(95% 12 80) @@ -26,7 +26,8 @@ const AppTheme = { level4: '#e0f0ff', // lch(94% 50 250) level5: '#d6ebff', // lch(92% 50 250) }, - success: '#38872e', // lch(50% 55 135) + success: '#00a665', // lch(60% 55 155) + warn: '#f8cf53', //lch(85% 65 85) danger: '#f23934' // lch(55% 85 35) }, roundness: 5, diff --git a/www/js/appstatus/PermissionsControls.tsx b/www/js/appstatus/PermissionsControls.tsx new file mode 100644 index 000000000..ded51b898 --- /dev/null +++ b/www/js/appstatus/PermissionsControls.tsx @@ -0,0 +1,65 @@ +//component to view and manage permission settings +import React, { useState } from "react"; +import { StyleSheet, ScrollView, View } from "react-native"; +import { Button, Text } from 'react-native-paper'; +import { useTranslation } from "react-i18next"; +import PermissionItem from "./PermissionItem"; +import usePermissionStatus, { refreshAllChecks } from "../usePermissionStatus"; +import ExplainPermissions from "./ExplainPermissions"; +import AlertBar from "../control/AlertBar"; + +const PermissionsControls = ({ onAccept }) => { + const { t } = useTranslation(); + const [explainVis, setExplainVis] = useState(false); + const { checkList, overallStatus, error, errorVis, setErrorVis, explanationList } = usePermissionStatus(); + + return ( + <> + {t('consent.permissions')} + + {t('intro.appstatus.overall-description')} + + + {checkList?.map((lc) => + + + )} + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + title: { + fontWeight: "bold", + fontSize: 22, + paddingBottom: 10 + }, + buttonBox: { + paddingHorizontal: 15, + paddingVertical: 10, + flexDirection: "row", + justifyContent: "space-evenly" + } + }); + +export default PermissionsControls; \ No newline at end of file diff --git a/www/js/appstatus/permissioncheck.js b/www/js/appstatus/permissioncheck.js deleted file mode 100644 index 84067a701..000000000 --- a/www/js/appstatus/permissioncheck.js +++ /dev/null @@ -1,429 +0,0 @@ -/* - * Directive to enable the permissions required for the app to function properly. - */ - -import angular from 'angular'; - -angular.module('emission.appstatus.permissioncheck', - []) -.directive('permissioncheck', function() { - return { - scope: { - overallstatus: "=", - }, - controller: "PermissionCheckControl", - templateUrl: "templates/appstatus/permissioncheck.html" - }; -}). -controller("PermissionCheckControl", function($scope, $element, $attrs, - $ionicPlatform, $ionicPopup, $window) { - console.log("PermissionCheckControl initialized with status "+$scope.overallstatus); - - $scope.setupLocChecks = function(platform, version) { - if (platform.toLowerCase() == "android") { - return $scope.setupAndroidLocChecks(version); - } else if (platform.toLowerCase() == "ios") { - return $scope.setupIOSLocChecks(version); - } else { - alert("Unknown platform, no tracking"); - } - } - - $scope.setupFitnessChecks = function(platform, version) { - if (platform.toLowerCase() == "android") { - return $scope.setupAndroidFitnessChecks(version); - } else if (platform.toLowerCase() == "ios") { - return $scope.setupIOSFitnessChecks(version); - } else { - alert("Unknown platform, no tracking"); - } - } - - $scope.setupNotificationChecks = function(platform, version) { - return $scope.setupAndroidNotificationChecks(version); - } - - $scope.setupBackgroundRestrictionChecks = function(platform, version) { - if (platform.toLowerCase() == "android") { - $scope.backgroundUnrestrictionsNeeded = true; - return $scope.setupAndroidBackgroundRestrictionChecks(version); - } else if (platform.toLowerCase() == "ios") { - $scope.backgroundUnrestrictionsNeeded = false; - $scope.overallBackgroundRestrictionStatus = true; - $scope.backgroundRestrictionChecks = []; - return true; - } else { - alert("Unknown platform, no tracking"); - } - } - - let iconMap = (statusState) => statusState? "✅" : "❌"; - let classMap = (statusState) => statusState? "status-green" : "status-red"; - - $scope.recomputeOverallStatus = function() { - $scope.overallstatus = $scope.overallLocStatus - && $scope.overallFitnessStatus - && $scope.overallNotificationStatus - && $scope.overallBackgroundRestrictionStatus; - } - - $scope.recomputeLocStatus = function() { - $scope.locChecks.forEach((lc) => { - lc.statusIcon = iconMap(lc.statusState); - lc.statusClass = classMap(lc.statusState) - }); - $scope.overallLocStatus = $scope.locChecks.map((lc) => lc.statusState).reduce((pv, cv) => pv && cv); - console.log("overallLocStatus = "+$scope.overallLocStatus+" from ", $scope.locChecks); - $scope.overallLocStatusIcon = iconMap($scope.overallLocStatus); - $scope.overallLocStatusClass = classMap($scope.overallLocStatus); - $scope.recomputeOverallStatus(); - } - - $scope.recomputeFitnessStatus = function() { - $scope.fitnessChecks.forEach((fc) => { - fc.statusIcon = iconMap(fc.statusState); - fc.statusClass = classMap(fc.statusState) - }); - $scope.overallFitnessStatus = $scope.fitnessChecks.map((fc) => fc.statusState).reduce((pv, cv) => pv && cv); - console.log("overallFitnessStatus = "+$scope.overallFitnessStatus+" from ", $scope.fitnessChecks); - $scope.overallFitnessStatusIcon = iconMap($scope.overallFitnessStatus); - $scope.overallFitnessStatusClass = classMap($scope.overallFitnessStatus); - $scope.recomputeOverallStatus(); - } - - $scope.recomputeNotificationStatus = function() { - $scope.notificationChecks.forEach((nc) => { - nc.statusIcon = iconMap(nc.statusState); - nc.statusClass = classMap(nc.statusState) - }); - $scope.overallNotificationStatus = $scope.notificationChecks.map((nc) => nc.statusState).reduce((pv, cv) => pv && cv); - console.log("overallNotificationStatus = "+$scope.overallNotificationStatus+" from ", $scope.notificationChecks); - $scope.overallNotificationStatusIcon = iconMap($scope.overallNotificationStatus); - $scope.overallNotificationStatusClass = classMap($scope.overallNotificationStatus); - $scope.recomputeOverallStatus(); - } - - $scope.recomputeBackgroundRestrictionStatus = function() { - if (!$scope.backgroundRestrictionChecks) return; - $scope.backgroundRestrictionChecks.forEach((brc) => { - brc.statusIcon = iconMap(brc.statusState); - brc.statusClass = classMap(brc.statusState) - }); - $scope.overallBackgroundRestrictionStatus = $scope.backgroundRestrictionChecks.map((nc) => nc.statusState).reduce((pv, cv) => pv && cv); - console.log("overallBackgroundRestrictionStatus = "+$scope.overallBackgroundRestrictionStatus+" from ", $scope.backgroundRestrictionChecks); - $scope.overallBackgroundRestrictionStatusIcon = iconMap($scope.overallBackgroundRestrictionStatus); - $scope.overallBackgroundRestrictionStatusClass = classMap($scope.overallBackgroundRestrictionStatus); - $scope.recomputeOverallStatus(); - } - - let checkOrFix = function(checkObj, nativeFn, recomputeFn, showError=true) { - return nativeFn() - .then((status) => { - console.log("availability ", status) - $scope.$apply(() => { - checkObj.statusState = true; - recomputeFn(); - }); - return status; - }).catch((error) => { - console.log("Error", error) - if (showError) { - $ionicPopup.alert({ - title: "Error", - template: "
"+error+"
", - okText: "Please fix again" - }); - }; - $scope.$apply(() => { - checkObj.statusState = false; - recomputeFn(); - }); - return error; - }); - } - - let refreshChecks = function(checksList, recomputeFn) { - // without this, even if the checksList is [] - // the reduce in the recomputeFn fails because it is called on a zero - // length array without a default value - // we should be able to also specify a default value of True - // but I don't want to mess with that at this last minute - if (!checksList || checksList.length == 0) { - return Promise.resolve(true); - } - let checkPromises = checksList?.map((lc) => lc.refresh()); - console.log(checkPromises); - return Promise.all(checkPromises) - .then((result) => recomputeFn()) - .catch((error) => recomputeFn()) - } - - $scope.setupAndroidLocChecks = function(platform, version) { - let fixSettings = function() { - console.log("Fix and refresh location settings"); - return checkOrFix(locSettingsCheck, $window.cordova.plugins.BEMDataCollection.fixLocationSettings, - $scope.recomputeLocStatus, true); - }; - let checkSettings = function() { - console.log("Refresh location settings"); - return checkOrFix(locSettingsCheck, $window.cordova.plugins.BEMDataCollection.isValidLocationSettings, - $scope.recomputeLocStatus, false); - }; - let fixPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, $window.cordova.plugins.BEMDataCollection.fixLocationPermissions, - $scope.recomputeLocStatus, true).then((error) => locPermissionsCheck.desc = error); - }; - let checkPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, $window.cordova.plugins.BEMDataCollection.isValidLocationPermissions, - $scope.recomputeLocStatus, false); - }; - var androidSettingsDescTag = "intro.appstatus.locsettings.description.android-gte-9"; - if (version < 9) { - androidSettingsDescTag = "intro.appstatus.locsettings.description.android-lt-9"; - } - var androidPermDescTag = "intro.appstatus.locperms.description.android-gte-12"; - if($scope.osver < 6) { - androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6'; - } else if ($scope.osver < 10) { - androidPermDescTag = "intro.appstatus.locperms.description.android-6-9"; - } else if ($scope.osver < 11) { - androidPermDescTag= "intro.appstatus.locperms.description.android-10"; - } else if ($scope.osver < 12) { - androidPermDescTag= "intro.appstatus.locperms.description.android-11"; - } - console.log("description tags are "+androidSettingsDescTag+" "+androidPermDescTag); - // location settings - let locSettingsCheck = { - name: i18next.t("intro.appstatus.locsettings.name"), - desc: i18next.t(androidSettingsDescTag), - statusState: false, - fix: fixSettings, - refresh: checkSettings - } - let locPermissionsCheck = { - name: i18next.t("intro.appstatus.locperms.name"), - desc: i18next.t(androidPermDescTag), - statusState: false, - fix: fixPerms, - refresh: checkPerms - } - $scope.locChecks = [locSettingsCheck, locPermissionsCheck]; - refreshChecks($scope.locChecks, $scope.recomputeLocStatus); - } - - $scope.setupIOSLocChecks = function(platform, version) { - let fixSettings = function() { - console.log("Fix and refresh location settings"); - return checkOrFix(locSettingsCheck, $window.cordova.plugins.BEMDataCollection.fixLocationSettings, - $scope.recomputeLocStatus, true); - }; - let checkSettings = function() { - console.log("Refresh location settings"); - return checkOrFix(locSettingsCheck, $window.cordova.plugins.BEMDataCollection.isValidLocationSettings, - $scope.recomputeLocStatus, false); - }; - let fixPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, $window.cordova.plugins.BEMDataCollection.fixLocationPermissions, - $scope.recomputeLocStatus, true).then((error) => locPermissionsCheck.desc = error); - }; - let checkPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, $window.cordova.plugins.BEMDataCollection.isValidLocationPermissions, - $scope.recomputeLocStatus, false); - }; - var iOSSettingsDescTag = "intro.appstatus.locsettings.description.ios"; - var iOSPermDescTag = "intro.appstatus.locperms.description.ios-gte-13"; - if($scope.osver < 13) { - iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13'; - } - console.log("description tags are "+iOSSettingsDescTag+" "+iOSPermDescTag); - // location settings - let locSettingsCheck = { - name: i18next.t("intro.appstatus.locsettings.name"), - desc: i18next.t(iOSSettingsDescTag), - statusState: false, - fix: fixSettings, - refresh: checkSettings - } - let locPermissionsCheck = { - name: i18next.t("intro.appstatus.locperms.name"), - desc: i18next.t(iOSPermDescTag), - statusState: false, - fix: fixPerms, - refresh: checkPerms - } - $scope.locChecks = [locSettingsCheck, locPermissionsCheck]; - refreshChecks($scope.locChecks, $scope.recomputeLocStatus); - } - - $scope.setupAndroidFitnessChecks = function(platform, version) { - $scope.fitnessPermNeeded = ($scope.osver >= 10); - - let fixPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, $window.cordova.plugins.BEMDataCollection.fixFitnessPermissions, - $scope.recomputeFitnessStatus, true).then((error) => fitnessPermissionsCheck.desc = error); - }; - let checkPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, $window.cordova.plugins.BEMDataCollection.isValidFitnessPermissions, - $scope.recomputeFitnessStatus, false); - }; - - let fitnessPermissionsCheck = { - name: i18next.t("intro.appstatus.fitnessperms.name"), - desc: i18next.t("intro.appstatus.fitnessperms.description.android"), - fix: fixPerms, - refresh: checkPerms - } - $scope.overallFitnessName = i18next.t("intro.appstatus.overall-fitness-name-android"); - $scope.fitnessChecks = [fitnessPermissionsCheck]; - refreshChecks($scope.fitnessChecks, $scope.recomputeFitnessStatus); - } - - $scope.setupIOSFitnessChecks = function(platform, version) { - $scope.fitnessPermNeeded = true; - - let fixPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, $window.cordova.plugins.BEMDataCollection.fixFitnessPermissions, - $scope.recomputeFitnessStatus, true).then((error) => fitnessPermissionsCheck.desc = error); - }; - let checkPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, $window.cordova.plugins.BEMDataCollection.isValidFitnessPermissions, - $scope.recomputeFitnessStatus, false); - }; - - let fitnessPermissionsCheck = { - name: i18next.t("intro.appstatus.fitnessperms.name"), - desc: i18next.t("intro.appstatus.fitnessperms.description.ios"), - fix: fixPerms, - refresh: checkPerms - } - $scope.overallFitnessName = i18next.t("intro.appstatus.overall-fitness-name-ios"); - $scope.fitnessChecks = [fitnessPermissionsCheck]; - refreshChecks($scope.fitnessChecks, $scope.recomputeFitnessStatus); - } - - $scope.setupAndroidNotificationChecks = function() { - let fixPerms = function() { - console.log("fix and refresh notification permissions"); - return checkOrFix(appAndChannelNotificationsCheck, $window.cordova.plugins.BEMDataCollection.fixShowNotifications, - $scope.recomputeNotificationStatus, true); - }; - let checkPerms = function() { - console.log("fix and refresh notification permissions"); - return checkOrFix(appAndChannelNotificationsCheck, $window.cordova.plugins.BEMDataCollection.isValidShowNotifications, - $scope.recomputeNotificationStatus, false); - }; - let appAndChannelNotificationsCheck = { - name: i18next.t("intro.appstatus.notificationperms.app-enabled-name"), - desc: i18next.t("intro.appstatus.notificationperms.description.android-enable"), - fix: fixPerms, - refresh: checkPerms - } - $scope.notificationChecks = [appAndChannelNotificationsCheck]; - refreshChecks($scope.notificationChecks, $scope.recomputeNotificationStatus); - } - - $scope.setupAndroidBackgroundRestrictionChecks = function() { - let fixPerms = function() { - console.log("fix and refresh backgroundRestriction permissions"); - return checkOrFix(unusedAppsUnrestrictedCheck, $window.cordova.plugins.BEMDataCollection.fixUnusedAppRestrictions, - $scope.recomputeBackgroundRestrictionStatus, true); - }; - let checkPerms = function() { - console.log("fix and refresh backgroundRestriction permissions"); - return checkOrFix(unusedAppsUnrestrictedCheck, $window.cordova.plugins.BEMDataCollection.isUnusedAppUnrestricted, - $scope.recomputeBackgroundRestrictionStatus, false); - }; - let fixBatteryOpt = function() { - console.log("fix and refresh battery optimization permissions"); - return checkOrFix(ignoreBatteryOptCheck, $window.cordova.plugins.BEMDataCollection.fixIgnoreBatteryOptimizations, - $scope.recomputeBackgroundRestrictionStatus, true); - }; - let checkBatteryOpt = function() { - console.log("fix and refresh battery optimization permissions"); - return checkOrFix(ignoreBatteryOptCheck, $window.cordova.plugins.BEMDataCollection.isIgnoreBatteryOptimizations, - $scope.recomputeBackgroundRestrictionStatus, false); - }; - var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-13"; - if ($scope.osver == 12) { - androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-12"; - } - else if ($scope.osver < 12) { - androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-lt-12"; - } - let unusedAppsUnrestrictedCheck = { - name: i18next.t("intro.appstatus.unusedapprestrict.name"), - desc: i18next.t(androidUnusedDescTag), - fix: fixPerms, - refresh: checkPerms - } - let ignoreBatteryOptCheck = { - name: i18next.t("intro.appstatus.ignorebatteryopt.name"), - desc: i18next.t("intro.appstatus.ignorebatteryopt.description.android-disable"), - fix: fixBatteryOpt, - refresh: checkBatteryOpt - } - $scope.backgroundRestrictionChecks = [unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck]; - refreshChecks($scope.backgroundRestrictionChecks, $scope.recomputeBackgroundRestrictionStatus); - } - - $scope.setupPermissionText = function() { - if($scope.platform.toLowerCase() == "ios") { - if($scope.osver < 13) { - $scope.locationPermExplanation = i18next.t("intro.permissions.locationPermExplanation-ios-lt-13"); - } else { - $scope.locationPermExplanation = i18next.t("intro.permissions.locationPermExplanation-ios-gte-13"); - } - } - - $scope.backgroundRestricted = false; - if($window.device.manufacturer.toLowerCase() == "samsung") { - $scope.backgroundRestricted = true; - $scope.allowBackgroundInstructions = i18next.t("intro.allow_background.samsung"); - } - - console.log("Explanation = "+$scope.locationPermExplanation); - } - - $scope.checkLocationServicesEnabled = function() { - console.log("About to see if location services are enabled"); - } - $ionicPlatform.ready().then(function() { - console.log("app is launched, should refresh"); - $scope.platform = $window.device.platform; - $scope.osver = $window.device.version.split(".")[0]; - $scope.setupPermissionText(); - $scope.setupLocChecks($scope.platform, $scope.osver); - $scope.setupFitnessChecks($scope.platform, $scope.osver); - $scope.setupNotificationChecks($scope.platform, $scope.osver); - $scope.setupBackgroundRestrictionChecks($scope.platform, $scope.osver); - }); - - $ionicPlatform.on("resume", function() { - console.log("PERMISSION CHECK: app has resumed, should refresh"); - refreshChecks($scope.locChecks, $scope.recomputeLocStatus); - refreshChecks($scope.fitnessChecks, $scope.recomputeFitnessStatus); - refreshChecks($scope.notificationChecks, $scope.recomputeNotificationStatus); - refreshChecks($scope.backgroundRestrictionChecks, $scope.recomputeBackgroundRestrictionStatus); - }); - - $scope.$on("recomputeAppStatus", function(e, callback) { - console.log("PERMISSION CHECK: recomputing state"); - Promise.all([ - refreshChecks($scope.locChecks, $scope.recomputeLocStatus), - refreshChecks($scope.fitnessChecks, $scope.recomputeFitnessStatus), - refreshChecks($scope.notificationChecks, $scope.recomputeNotificationStatus), - refreshChecks($scope.backgroundRestrictionChecks, $scope.recomputeBackgroundRestrictionStatus) - ]).then( () => { - callback($scope.overallstatus) - } - ); - }); -}); diff --git a/www/js/components/ActionMenu.tsx b/www/js/components/ActionMenu.tsx new file mode 100644 index 000000000..296717a00 --- /dev/null +++ b/www/js/components/ActionMenu.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Modal } from "react-native"; +import { Dialog, Button, useTheme } from "react-native-paper"; +import { useTranslation } from "react-i18next"; +import { settingStyles } from "../control/ProfileSettings"; + +const ActionMenu = ({vis, setVis, title, actionSet, onAction, onExit}) => { + + const { t } = useTranslation(); + const { colors } = useTheme(); + + return ( + setVis(false)} + transparent={true}> + setVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {title} + + {actionSet?.map((e) => + + )} + + + + + + + ) +} + +export default ActionMenu; \ No newline at end of file diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx index 6da3d2a2b..1e957923b 100644 --- a/www/js/components/BarChart.tsx +++ b/www/js/components/BarChart.tsx @@ -1,201 +1,27 @@ +import React from "react"; +import Chart, { Props as ChartProps } from "./Chart"; +import { useTheme } from "react-native-paper"; +import { getGradient } from "./charting"; -import React, { useRef, useState } from 'react'; -import { array, string, bool } from 'prop-types'; -import { angularize } from '../angular-react-helper'; -import { View } from 'react-native'; -import { useTheme } from 'react-native-paper'; -import { Chart, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, TimeScale } from 'chart.js'; -import { Bar } from 'react-chartjs-2'; -import Annotation, { AnnotationOptions } from 'chartjs-plugin-annotation'; - -Chart.register( - CategoryScale, - LinearScale, - TimeScale, - BarElement, - Title, - Tooltip, - Legend, - Annotation, -); - -const BarChart = ({ chartData, axisTitle, lineAnnotations=null, isHorizontal=false }) => { +type Props = Omit & { + meter?: {high: number, middle: number, dash_key: string}, +} +const BarChart = ({ meter, ...rest }: Props) => { const { colors } = useTheme(); - const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); - - const barChartRef = useRef(null); - - const defaultPalette = [ - '#c95465', // red oklch(60% 0.15 14) - '#4a71b1', // blue oklch(55% 0.11 260) - '#d2824e', // orange oklch(68% 0.12 52) - '#856b5d', // brown oklch(55% 0.04 50) - '#59894f', // green oklch(58% 0.1 140) - '#e0cc55', // yellow oklch(84% 0.14 100) - '#b273ac', // purple oklch(64% 0.11 330) - '#f09da6', // pink oklch(78% 0.1 12) - '#b3aca8', // grey oklch(75% 0.01 55) - '#80afad', // teal oklch(72% 0.05 192) - ] - - const indexAxis = isHorizontal ? 'y' : 'x'; - function getChartHeight() { - /* when horizontal charts have more data, they should get taller - so they don't look squished */ - if (isHorizontal) { - // 'ideal' chart height is based on the number of datasets and number of unique index values - const uniqueIndexVals = []; - chartData.forEach(e => e.records.forEach(r => { - if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); - })); - const numIndexVals = uniqueIndexVals.length; - const idealChartHeight = numVisibleDatasets * numIndexVals * 8; - - /* each index val should be at least 20px tall for visibility, - and the graph itself should be at least 250px tall */ - const minChartHeight = Math.max(numIndexVals * 20, 250); - - // return whichever is greater - return { height: Math.max(idealChartHeight, minChartHeight) }; + if (meter) { + rest.getColorForChartEl = (chart, dataset, ctx, colorFor) => { + const darkenDegree = colorFor == 'border' ? 0.25 : 0; + const alpha = colorFor == 'border' ? 1 : 0; + return getGradient(chart, meter, dataset, ctx, alpha, darkenDegree); } - // vertical charts will just match the parent container - return { height: '100%' }; + rest.borderWidth = 3; } return ( - - ({ - label: d.label, - data: d.records, - // cycle through the default palette, repeat if necessary - backgroundColor: defaultPalette[i % defaultPalette.length], - })) - }} - options={{ - indexAxis: indexAxis, - responsive: true, - maintainAspectRatio: false, - resizeDelay: 1, - scales: { - ...(isHorizontal ? { - y: { - offset: true, - type: 'time', - adapters: { - date: { zone: 'utc' }, - }, - time: { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - }, - beforeUpdate: (axis) => { - setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) - }, - reverse: true, - }, - x: { - title: { display: true, text: axisTitle }, - }, - } : { - x: { - offset: true, - type: 'time', - adapters: { - date: { zone: 'utc' }, - }, - time: { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - }, - }, - y: { - title: { display: true, text: axisTitle }, - }, - }), - }, - plugins: { - ...(lineAnnotations?.length > 0 && { - annotation: { - annotations: lineAnnotations.map((a, i) => ({ - type: 'line', - label: { - display: true, - padding: { x: 3, y: 1 }, - borderRadius: 0, - backgroundColor: 'rgba(0,0,0,.7)', - color: 'rgba(255,255,255,1)', - font: { size: 10 }, - position: 'start', - content: a.label, - }, - ...(isHorizontal ? { xMin: a.value, xMax: a.value } - : { yMin: a.value, yMax: a.value }), - borderColor: colors.onBackground, - borderWidth: 2, - borderDash: [3, 3], - } satisfies AnnotationOptions)), - } - }), - } - }} /> - - ) + + ); } -BarChart.propTypes = { - chartData: array, - axisTitle: string, - lineAnnotations: array, - isHorizontal: bool, -}; - -angularize(BarChart, 'BarChart', 'emission.main.barchart'); export default BarChart; - -// const sampleAnnotations = [ -// { value: 35, label: 'Target1' }, -// { value: 65, label: 'Target2' }, -// ]; - -// const sampleChartData = [ -// { -// label: 'Primary', -// records: [ -// { x: moment('2023-06-20'), y: 20 }, -// { x: moment('2023-06-21'), y: 30 }, -// { x: moment('2023-06-23'), y: 80 }, -// { x: moment('2023-06-24'), y: 40 }, -// ], -// }, -// { -// label: 'Secondary', -// records: [ -// { x: moment('2023-06-21'), y: 10 }, -// { x: moment('2023-06-22'), y: 50 }, -// { x: moment('2023-06-23'), y: 30 }, -// { x: moment('2023-06-25'), y: 40 }, -// ], -// }, -// { -// label: 'Tertiary', -// records: [ -// { x: moment('2023-06-20'), y: 30 }, -// { x: moment('2023-06-22'), y: 40 }, -// { x: moment('2023-06-24'), y: 10 }, -// { x: moment('2023-06-25'), y: 60 }, -// ], -// }, -// { -// label: 'Quaternary', -// records: [ -// { x: moment('2023-06-22'), y: 10 }, -// { x: moment('2023-06-23'), y: 20 }, -// { x: moment('2023-06-24'), y: 30 }, -// { x: moment('2023-06-25'), y: 40 }, -// ], -// }, -// ]; diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx new file mode 100644 index 000000000..28a31ff6a --- /dev/null +++ b/www/js/components/Carousel.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { ScrollView, View } from 'react-native'; + +type Props = { + children: React.ReactNode, + cardWidth: number, + cardMargin: number, +} +const Carousel = ({ children, cardWidth, cardMargin }: Props) => { + const numCards = React.Children.count(children); + return ( + + {React.Children.map(children, (child, i) => ( + + {child} + + ))} + + ) +}; + +export const s = { + carouselScroll: (cardMargin) => ({ + // @ts-ignore, RN doesn't recognize `scrollSnapType`, but it does work on RN Web + scrollSnapType: 'x mandatory', + paddingVertical: 10, + }), + carouselCard: (cardWidth, cardMargin, isFirst, isLast) => ({ + marginLeft: isFirst ? cardMargin : cardMargin/2, + marginRight: isLast ? cardMargin : cardMargin/2, + width: cardWidth, + scrollSnapAlign: 'center', + scrollSnapStop: 'always', + }), +}; + +export default Carousel; diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx new file mode 100644 index 000000000..79c6e40e4 --- /dev/null +++ b/www/js/components/Chart.tsx @@ -0,0 +1,196 @@ + +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { View } from 'react-native'; +import { useTheme } from 'react-native-paper'; +import { Chart as ChartJS, registerables } from 'chart.js'; +import { Chart as ChartJSChart } from 'react-chartjs-2'; +import Annotation, { AnnotationOptions, LabelPosition } from 'chartjs-plugin-annotation'; +import { dedupColors, getChartHeight, darkenOrLighten } from './charting'; + +ChartJS.register(...registerables, Annotation); + +type XYPair = { x: number|string, y: number|string }; +type ChartDataset = { + label: string, + data: XYPair[], +}; + +export type Props = { + records: { label: string, x: number|string, y: number|string }[], + axisTitle: string, + type: 'bar'|'line', + getColorForLabel?: (label: string) => string, + getColorForChartEl?: (chart, currDataset: ChartDataset, ctx: ScriptableContext<'bar'|'line'>, colorFor: 'background'|'border') => string|CanvasGradient|null, + borderWidth?: number, + lineAnnotations?: { value: number, label?: string, color?:string, position?: LabelPosition }[], + isHorizontal?: boolean, + timeAxis?: boolean, + stacked?: boolean, +} +const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, borderWidth, lineAnnotations, isHorizontal, timeAxis, stacked }: Props) => { + + const { colors } = useTheme(); + const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); + + const indexAxis = isHorizontal ? 'y' : 'x'; + const chartRef = useRef>(null); + const [chartDatasets, setChartDatasets] = useState([]); + + const chartData = useMemo>(() => { + let labelColorMap; // object mapping labels to colors + if (getColorForLabel) { + const colorEntries = chartDatasets.map(d => [d.label, getColorForLabel(d.label)] ); + labelColorMap = dedupColors(colorEntries); + } + return { + datasets: chartDatasets.map((e, i) => ({ + ...e, + backgroundColor: (barCtx) => ( + labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background') + ), + borderColor: (barCtx) => ( + darkenOrLighten(labelColorMap?.[e.label], -.5) || getColorForChartEl(chartRef.current, e, barCtx, 'border') + ), + borderWidth: borderWidth || 2, + borderRadius: 3, + })), + }; + }, [chartDatasets, getColorForLabel]); + + // group records by label (this is the format that Chart.js expects) + useEffect(() => { + const d = records?.reduce((acc, record) => { + const existing = acc.find(e => e.label == record.label); + if (!existing) { + acc.push({ + label: record.label, + data: [{ + x: record.x, + y: record.y, + }], + }); + } else { + existing.data.push({ + x: record.x, + y: record.y, + }); + } + return acc; + }, [] as ChartDataset[]); + setChartDatasets(d); + }, [records]); + + const annotationsAtTop = isHorizontal && lineAnnotations?.some(a => (!a.position || a.position == 'start')); + + return ( + + { + setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) + }, + ticks: timeAxis ? {} : { + callback: (value, i) => { + const label = chartDatasets[0].data[i].y; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + font: { size: 11 }, // default is 12, we want a tad smaller + }, + reverse: true, + stacked, + }, + x: { + title: { display: true, text: axisTitle }, + stacked, + }, + } : { + x: { + offset: true, + type: timeAxis ? 'time' : 'category', + adapters: timeAxis ? { + date: { zone: 'utc' }, + } : {}, + time: timeAxis ? { + unit: 'day', + tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 + } : {}, + ticks: timeAxis ? {} : { + callback: (value, i) => { + console.log("testing vertical", chartData, i); + const label = chartDatasets[0].data[i].x; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + }, + stacked, + }, + y: { + title: { display: true, text: axisTitle }, + stacked, + }, + }), + }, + plugins: { + ...(lineAnnotations?.length > 0 && { + annotation: { + clip: false, + annotations: lineAnnotations.map((a, i) => ({ + type: 'line', + label: { + display: true, + padding: { x: 3, y: 1 }, + borderRadius: 0, + backgroundColor: 'rgba(0,0,0,.7)', + color: 'rgba(255,255,255,1)', + font: { size: 10 }, + position: a.position || 'start', + content: a.label, + yAdjust: annotationsAtTop ? -12 : 0, + }, + ...(isHorizontal ? { xMin: a.value, xMax: a.value } + : { yMin: a.value, yMax: a.value }), + borderColor: a.color || colors.onBackground, + borderWidth: 3, + borderDash: [3, 3], + } satisfies AnnotationOptions)), + } + }), + } + }} + // if there are annotations at the top of the chart, it overlaps with the legend + // so we need to increase the spacing between the legend and the chart + // https://stackoverflow.com/a/73498454 + plugins={annotationsAtTop && [{ + id: "increase-legend-spacing", + beforeInit(chart) { + const originalFit = (chart.legend as any).fit; + (chart.legend as any).fit = function fit() { + originalFit.bind(chart.legend)(); + this.height += 12; + }; + } + }]} /> + + ) +} +export default Chart; diff --git a/www/js/components/LeafletView.jsx b/www/js/components/LeafletView.tsx similarity index 92% rename from www/js/components/LeafletView.jsx rename to www/js/components/LeafletView.tsx index eb0c0bb78..cf26cb933 100644 --- a/www/js/components/LeafletView.jsx +++ b/www/js/components/LeafletView.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useRef, useState } from "react"; -import { angularize } from "../angular-react-helper"; -import { object, string } from "prop-types"; import { View } from "react-native"; import { useTheme } from "react-native-paper"; +import L from "leaflet"; -const mapSet = new Set(); +const mapSet = new Set(); export function invalidateMaps() { mapSet.forEach(map => map.invalidateSize()); } @@ -55,7 +54,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { const mapElId = `map-${geojson.data.id.replace(/[^a-zA-Z0-9]/g, '')}`; return ( - + + + + + + ); + }); + console.log("Ending run"); +}); diff --git a/www/js/onboarding/ConsentPage.tsx b/www/js/onboarding/ConsentPage.tsx new file mode 100644 index 000000000..08aa3ab48 --- /dev/null +++ b/www/js/onboarding/ConsentPage.tsx @@ -0,0 +1,43 @@ +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { View, ScrollView } from 'react-native'; +import { Button, Surface } from 'react-native-paper'; +import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { AppContext } from '../App'; +import { getAngularService } from '../angular-react-helper'; +import PrivacyPolicy from './PrivacyPolicy'; +import { onboardingStyles } from './OnboardingStack'; + +const ConsentPage = () => { + + const { t } = useTranslation(); + const context = useContext(AppContext); + const { refreshOnboardingState } = context; + + /* If the user does not consent, we boot them back out to the join screen */ + function disagree() { + resetDataAndRefresh(); + }; + + function agree() { + const StartPrefs = getAngularService('StartPrefs'); + StartPrefs.markConsented().then((response) => { + refreshOnboardingState(); + }); + }; + + // privacy policy and data collection info, followed by accept/reject buttons + return (<> + + + + + + + + + + ); +} + +export default ConsentPage; diff --git a/www/js/onboarding/OnboardingStack.tsx b/www/js/onboarding/OnboardingStack.tsx new file mode 100644 index 000000000..643744ed3 --- /dev/null +++ b/www/js/onboarding/OnboardingStack.tsx @@ -0,0 +1,53 @@ +import React, { useContext } from "react"; +import { StyleSheet } from "react-native"; +import { AppContext } from "../App"; +import WelcomePage from "./WelcomePage"; +import ConsentPage from "./ConsentPage"; +import SurveyPage from "./SurveyPage"; +import SaveQrPage from "./SaveQrPage"; +import SummaryPage from "./SummaryPage"; +import { OnboardingRoute } from "./onboardingHelper"; +import { displayErrorMsg } from "../plugin/logger"; + +const OnboardingStack = () => { + + const { pendingOnboardingState } = useContext(AppContext); + + console.debug('pendingOnboardingState in OnboardingStack', pendingOnboardingState); + + if (pendingOnboardingState.route == OnboardingRoute.WELCOME) { + return ; + } else if (pendingOnboardingState.route == OnboardingRoute.SUMMARY) { + return ; + } else if (pendingOnboardingState.route == OnboardingRoute.CONSENT) { + return ; + } else if (pendingOnboardingState.route == OnboardingRoute.SAVE_QR) { + return ; + } else if (pendingOnboardingState.route == OnboardingRoute.SURVEY) { + return ; + } else { + displayErrorMsg('OnboardingStack: unknown route', pendingOnboardingState.route); + } +} + +export const onboardingStyles = StyleSheet.create({ + page: { + flex: 1, + paddingHorizontal: 15, + paddingVertical: 20, + }, + pageSection: { + marginVertical: 15, + alignItems: 'center', + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + marginVertical: 15, + alignItems: 'center', + gap: 8, + margin: 'auto', + }, +}); + +export default OnboardingStack diff --git a/www/js/onboarding/PrivacyPolicy.tsx b/www/js/onboarding/PrivacyPolicy.tsx new file mode 100644 index 000000000..f237e359c --- /dev/null +++ b/www/js/onboarding/PrivacyPolicy.tsx @@ -0,0 +1,177 @@ +import React, { useMemo } from "react"; +import { StyleSheet, Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import useAppConfig from "../useAppConfig"; +import { getTemplateText } from "./StudySummary"; + +const PrivacyPolicy = () => { + const { t, i18n } = useTranslation(); + const appConfig = useAppConfig(); + + let opCodeText; + if(appConfig?.opcode?.autogen) { + opCodeText = {t('consent-text.opcode.autogen')}; + + } else { + opCodeText = {t('consent-text.opcode.not-autogen')}; + } + + let yourRightsText; + if(appConfig?.intro?.app_required) { + yourRightsText = {t('consent-text.rights.app-required', {program_admin_contact: appConfig?.intro?.program_admin_contact})}; + + } else { + yourRightsText = {t('consent-text.rights.app-not-required', {program_or_study: appConfig?.intro?.program_or_study})}; + } + + const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); + + return ( + <> + {t('consent-text.title')} + {t('consent-text.introduction.header')} + {templateText?.short_textual_description} + {'\n'} + {t('consent-text.introduction.what-is-openpath')} + {'\n'} + {t('consent-text.introduction.what-is-NREL', {program_or_study: appConfig?.intro?.program_or_study})} + {'\n'} + {t('consent-text.introduction.if-disagree')} + {'\n'} + + {t('consent-text.why.header')} + {templateText?.why_we_collect} + {'\n'} + + {t('consent-text.what.header')} + {t('consent-text.what.no-pii')} + {'\n'} + {t('consent-text.what.phone-sensor')} + {'\n'} + {t('consent-text.what.labeling')} + {'\n'} + {t('consent-text.what.demographics')} + {'\n'} + {t('consent-text.what.on-nrel-site')} + {/* Linking is broken, look into enabling after migration + + {t('consent-text.what.open-source-data')} + { + Linking.openURL('https://github.com/e-mission/e-mission-data-collection.git'); + }}> + {' '}https://github.com/e-mission/e-mission-data-collection.git{' '} + + {t('consent-text.what.open-source-analysis')} + { + Linking.openURL('https://github.com/e-mission/e-mission-server.git'); + }}> + {' '}https://github.com/e-mission/e-mission-server.git{' '} + + {t('consent-text.what.open-source-dashboard')} + { + Linking.openURL('https://github.com/e-mission/em-public-dashboard.git'); + }}> + {' '}https://github.com/e-mission/em-public-dashboard.git{' '} + + */} + {'\n'} + + {t('consent-text.opcode.header')} + {opCodeText} + {'\n'} + + {t('consent-text.who-sees.header')} + {t('consent-text.who-sees.public-dash')} + {'\n'} + {t('consent-text.who-sees.individual-info')} + {'\n'} + {t('consent-text.who-sees.program-admins', { + deployment_partner_name: appConfig?.intro?.deployment_partner_name, + raw_data_use: templateText?.raw_data_use})} + {t('consent-text.who-sees.nrel-devs')} + {'\n'} + {t('consent-text.who-sees.TSDC-info')} + {/* Linking is broken, look into enabling after migration + { + Linking.openURL('https://nrel.gov/tsdc'); + }}> + {t('consent-text.who-sees.on-website')} + + {t('consent-text.who-sees.and-in')} + { + Linking.openURL('https://www.sciencedirect.com/science/article/pii/S2352146515002999'); + }}> + {t('consent-text.who-sees.this-pub')} + + {t('consent-text.who-sees.and')} + { + Linking.openURL('https://www.nrel.gov/docs/fy18osti/70723.pdf'); + }}> + {t('consent-text.who-sees.fact-sheet')} + */} + {t('consent-text.who-sees.on-nrel-site')} + + {'\n'} + + {t('consent-text.rights.header')} + {yourRightsText} + {'\n'} + {t('consent-text.rights.destroy-data-pt1')} + {/* Linking is broken, look into enabling after migration + { + Linking.openURL("mailto:k.shankari@nrel.gov"); + }}> + k.shankari@nrel.gov + */} + (k.shankari@nrel.gov) + {t('consent-text.rights.destroy-data-pt2')} + + {'\n'} + + {t('consent-text.questions.header')} + {t('consent-text.questions.for-questions', {program_admin_contact: appConfig?.intro?.program_admin_contact})} + {'\n'} + + {t('consent-text.consent.header')} + {t('consent-text.consent.press-button-to-consent', {program_or_study: appConfig?.intro?.program_or_study})} + + ) +} + +const styles = StyleSheet.create({ + hyperlinkStyle: (linkColor) => ({ + color: linkColor + }), + text: { + fontSize: 14, + }, + header: { + fontWeight: "bold", + fontSize: 18 + }, + title: { + fontWeight: "bold", + fontSize: 22, + paddingBottom: 10, + textAlign: "center" + }, + divider: { + marginVertical: 10 + } + }); + +export default PrivacyPolicy; diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx new file mode 100644 index 000000000..157ff4093 --- /dev/null +++ b/www/js/onboarding/SaveQrPage.tsx @@ -0,0 +1,92 @@ +import React, { useContext, useEffect, useState } from "react"; +import { View, StyleSheet } from "react-native"; +import { ActivityIndicator, Button, Surface, Text } from "react-native-paper"; +import { registerUserDone, setRegisterUserDone, setSaveQrDone } from "./onboardingHelper"; +import { AppContext } from "../App"; +import usePermissionStatus from "../usePermissionStatus"; +import { getAngularService } from "../angular-react-helper"; +import { displayError, logDebug } from "../plugin/logger"; +import { useTranslation } from "react-i18next"; +import QrCode, { shareQR } from "../components/QrCode"; +import { onboardingStyles } from "./OnboardingStack"; +import { preloadDemoSurveyResponse } from "./SurveyPage"; + +const SaveQrPage = ({ }) => { + + const { t } = useTranslation(); + const { pendingOnboardingState, refreshOnboardingState } = useContext(AppContext); + const { overallStatus } = usePermissionStatus(); + + useEffect(() => { + if (overallStatus == true && !registerUserDone) { + logDebug('permissions done, going to log in'); + login(pendingOnboardingState.opcode).then((response) => { + logDebug('login done, refreshing onboarding state'); + setRegisterUserDone(true); + preloadDemoSurveyResponse(); + refreshOnboardingState(); + }); + } else { + logDebug('permissions not done, waiting'); + } + }, [overallStatus]); + + function login(token) { + const CommHelper = getAngularService('CommHelper'); + const KVStore = getAngularService('KVStore'); + const EXPECTED_METHOD = "prompted-auth"; + const dbStorageObject = {"token": token}; + return KVStore.set(EXPECTED_METHOD, dbStorageObject).then((r) => { + CommHelper.registerUser((successResult) => { + refreshOnboardingState(); + }, function(errorResult) { + displayError(errorResult, "User registration error"); + }); + }).catch((e) => { + displayError(e, "Sign in error"); + }); + }; + + function onFinish() { + setSaveQrDone(true); + refreshOnboardingState(); + } + + return ( + + + + {t('login.make-sure-save-your-opcode')} + + + {t('login.cannot-retrieve')} + + + + + + {pendingOnboardingState.opcode} + + + + + + + + ); +} + +const s = StyleSheet.create({ + opcodeText: { + fontFamily: 'monospace', + marginVertical: 8, + maxWidth: '100%', + wordBreak: 'break-all', + }, +}); + +export default SaveQrPage; diff --git a/www/js/onboarding/StudySummary.tsx b/www/js/onboarding/StudySummary.tsx new file mode 100644 index 000000000..02a1797b4 --- /dev/null +++ b/www/js/onboarding/StudySummary.tsx @@ -0,0 +1,47 @@ +import React, { useMemo } from "react"; +import { View, StyleSheet } from "react-native"; +import { Text } from "react-native-paper"; +import { useTranslation } from "react-i18next"; +import useAppConfig from "../useAppConfig"; + +export function getTemplateText(configObject, lang) { + if (configObject && (configObject.name)) { + return configObject.intro.translated_text[lang]; + } +} + +const StudySummary = () => { + + const { i18n } = useTranslation(); + const appConfig = useAppConfig(); + + const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); + + return (<> + {templateText?.deployment_name} + {appConfig?.intro?.deployment_partner_name + " " + templateText?.deployment_name} + + {"✔️ " + templateText?.summary_line_1} + {"✔️ " + templateText?.summary_line_2} + {"✔️ " + templateText?.summary_line_3} + + ) +}; + +const styles = StyleSheet.create({ + title: { + fontWeight: "bold", + fontSize: 22, + paddingBottom: 10, + textAlign: "center" + }, + text: { + fontSize: 14, + }, + studyName: { + fontWeight: "bold", + fontSize: 16 + }, +}); + +export default StudySummary; diff --git a/www/js/onboarding/SummaryPage.tsx b/www/js/onboarding/SummaryPage.tsx new file mode 100644 index 000000000..90e8d858e --- /dev/null +++ b/www/js/onboarding/SummaryPage.tsx @@ -0,0 +1,34 @@ +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { View, ScrollView } from 'react-native'; +import { Button, Surface } from 'react-native-paper'; +import { AppContext } from '../App'; +import { onboardingStyles } from './OnboardingStack'; +import StudySummary from './StudySummary'; +import { setSummaryDone } from './onboardingHelper'; + +const SummaryPage = () => { + + const { t } = useTranslation(); + const context = useContext(AppContext); + const { refreshOnboardingState } = context; + + function next() { + setSummaryDone(true); + refreshOnboardingState(); + }; + + // summary of the study, followed by 'next' button + return (<> + + + + + + + + + ); +} + +export default SummaryPage; diff --git a/www/js/onboarding/SurveyPage.tsx b/www/js/onboarding/SurveyPage.tsx new file mode 100644 index 000000000..11e58c94a --- /dev/null +++ b/www/js/onboarding/SurveyPage.tsx @@ -0,0 +1,95 @@ +import React, { useState, useEffect, useContext, useMemo } from "react"; +import { View, StyleSheet } from "react-native"; +import { ActivityIndicator, Button, Surface, Text } from "react-native-paper"; +import EnketoModal from "../survey/enketo/EnketoModal"; +import { DEMOGRAPHIC_SURVEY_DATAKEY, DEMOGRAPHIC_SURVEY_NAME } from "../control/DemographicsSettingRow"; +import { loadPreviousResponseForSurvey } from "../survey/enketo/enketoHelper"; +import { AppContext } from "../App"; +import { markIntroDone } from "./onboardingHelper"; +import { useTranslation } from "react-i18next"; +import { DateTime } from "luxon"; +import { onboardingStyles } from "./OnboardingStack"; + +let preloadedResponsePromise: Promise = null; +export const preloadDemoSurveyResponse = () => { + if (!preloadedResponsePromise) { + preloadedResponsePromise = loadPreviousResponseForSurvey(DEMOGRAPHIC_SURVEY_DATAKEY); + } + return preloadedResponsePromise; +} + +const SurveyPage = () => { + + const { t } = useTranslation(); + const { refreshOnboardingState } = useContext(AppContext); + const [surveyModalVisible, setSurveyModalVisible] = useState(false); + const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); + const prevSurveyResponseDate = useMemo(() => { + if (prevSurveyResponse) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(prevSurveyResponse, "text/xml"); + const surveyEndDt = xmlDoc.querySelector('end')?.textContent; // ISO datetime of survey completion + return DateTime.fromISO(surveyEndDt).toLocaleString(DateTime.DATE_FULL); + } + }, [prevSurveyResponse]); + + useEffect(() => { + /* If we came from the SaveQrPage, we should have already initiated loading the previous survey + response from there, and preloadDemographicsSurvey() will just return the promise that was + already started. + Otherwise, it will start a new promise. Either way, we wait for it to finish before proceeding. */ + preloadDemoSurveyResponse().then((lastSurvey) => { + if (lastSurvey?.data?.xmlResponse) { + setPrevSurveyResponse(lastSurvey.data.xmlResponse); + } else { + // if there is no prev response, we show the blank survey to be filled out for the first time + setSurveyModalVisible(true); + } + }); + }, []); + + function onFinish() { + setSurveyModalVisible(false); + markIntroDone(); + refreshOnboardingState(); + } + + return (<> + + {prevSurveyResponse ? + + + {t('survey.prev-survey-found')} + {prevSurveyResponseDate} + + + + + + + : + + + + {t('survey.loading-prior-survey')} + + + } + + setSurveyModalVisible(false)} + onResponseSaved={onFinish} surveyName={DEMOGRAPHIC_SURVEY_NAME} + opts={{ + /* If there is no prev response, we need an initial response from the user and should + not allow them to dismiss the modal by the "<- Dismiss" button */ + undismissable: !prevSurveyResponse, + prefilledSurveyResponse: prevSurveyResponse, + dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, + }} /> + ); +}; + +export default SurveyPage; diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx new file mode 100644 index 000000000..8e7e43425 --- /dev/null +++ b/www/js/onboarding/WelcomePage.tsx @@ -0,0 +1,202 @@ +import React, { useContext, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { View, Image, Modal, ScrollView, StyleSheet, ViewStyle, useWindowDimensions } from 'react-native'; +import { Button, Dialog, Divider, IconButton, Surface, Text, TextInput, TouchableRipple, useTheme } from 'react-native-paper'; +import color from 'color'; +import { initByUser } from '../config/dynamicConfig'; +import { AppContext } from '../App'; +import { displayError } from "../plugin/logger"; +import { onboardingStyles } from './OnboardingStack'; +import { Icon } from '../components/Icon'; + +const WelcomePage = () => { + + const { t } = useTranslation(); + const { colors } = useTheme(); + const { width: windowWidth } = useWindowDimensions(); + const context = useContext(AppContext); + const { refreshOnboardingState } = context; + const [pasteModalVis, setPasteModalVis] = useState(false); + const [infoPopupVis, setInfoPopupVis] = useState(false); + const [existingToken, setExistingToken] = useState(''); + + const scanCode = function() { + window.cordova.plugins.barcodeScanner.scan( + function (result) { + console.debug("scanned code", result); + if (result.format == "QR_CODE" && + result.cancelled == false) { + let text = result.text.split("=")[1]; + console.log("found code", text); + loginWithToken(text); + } else { + displayError(result.text, "invalid study reference") ; + } + }, + function (error) { + displayError(error, "Scanning failed: "); + }); + }; + + function loginWithToken(token) { + initByUser({token}).then((configUpdated) => { + if (configUpdated) { + setPasteModalVis(false); + refreshOnboardingState(); + } + }).catch(err => { + console.error('Error logging in with token', err); + setExistingToken(''); + }); + } + + return (<> + + + setInfoPopupVis(true)} /> + + + + + }} /> + + + {t('join.to-proceed-further')} + {t('join.code-hint')} + + + + + {t('join.scan-code')} + + {t('join.scan-hint')} + + + + setPasteModalVis(true)} icon='content-paste'> + {t('join.paste-code')} + + {t('join.paste-hint')} + + + + setPasteModalVis(false)}> + setPasteModalVis(false)}> + + + + + + + + setInfoPopupVis(false)}> + setInfoPopupVis(false)}> + + {t('join.about-app-title', {appName: t('join.app-name')})} + + + + {t('join.about-app-para-1')} + {t('join.about-app-para-2')} + {t('join.about-app-para-3')} + {t('join.tips-title')} + - {t('join.all-green-status')} + - {t('join.dont-force-kill')} + - {t('join.background-restrictions')} + + + + + + + + ); +} + +const s: any = StyleSheet.create({ + headerArea: ((windowWidth, colors) => ({ + width: windowWidth * 2.5, + height: windowWidth, + left: -windowWidth * .75, + borderBottomRightRadius: '50%', + borderBottomLeftRadius: '50%', + position: 'absolute', + top: windowWidth * -2/3, + backgroundColor: colors.primary, + boxShadow: `0 16px ${color(colors.primary).alpha(0.3).rgb().string()}`, + })) as ViewStyle, + appIconWrapper: ((colors): ViewStyle => ({ + marginTop: 20, + width: 200, + height: 200, + alignSelf: 'center', + backgroundColor: color(colors.onPrimary).darken(0.1).alpha(0.4).rgb().string(), + padding: 10, + borderRadius: 32, + })) as ViewStyle, + infoButton: { + position: 'absolute', + top: 10, + right: 10, + width: 40, + height: 40, + elevation: 2, + }, + appIcon: ((colors): ViewStyle => ({ + width: '100%', + height: '100%', + backgroundColor: colors.onPrimary, + borderRadius: 24, + })) as ViewStyle, + welcomeTitle: { + marginTop: 20, + textAlign: 'center', + paddingVertical: 20, + }, + buttonsSection: { + flexDirection: 'row', + justifyContent: 'center', + marginVertical: 20, + }, +}); + + +const WelcomePageButton = ({ onPress, icon, children }) => { + + const { colors } = useTheme(); + const { width: windowWidth } = useWindowDimensions(); + + return ( + + + + + {children} + + + + ); +} + +const welcomeButtonStyles: any = StyleSheet.create({ + btn: ((colors): ViewStyle => ({ + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.primary, + borderRadius: 21, + padding: 20, + gap: 8, + })) as ViewStyle, + wrapper: ((colors): ViewStyle => ({ + borderRadius: 26, + padding: 5, + backgroundColor: color(colors.primary).alpha(0.4).rgb().string(), + })) as ViewStyle, +}); + +export default WelcomePage; diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts new file mode 100644 index 000000000..7874ab9f8 --- /dev/null +++ b/www/js/onboarding/onboardingHelper.ts @@ -0,0 +1,61 @@ +import { DateTime } from "luxon"; +import { getAngularService } from "../angular-react-helper"; +import { getConfig } from "../config/dynamicConfig"; + +export const INTRO_DONE_KEY = 'intro_done'; + +// state = null if onboarding is done +// route = WELCOME if no config present +// route = SUMMARY if config present, but not consented and summary not done +// route = CONSENT if config present, but not consented and summary done +// route = SAVE_QR if config present, consented, but save qr not done +// route = SURVEY if config present, consented and save qr done +export enum OnboardingRoute { WELCOME, SUMMARY, CONSENT, SAVE_QR, SURVEY, NONE }; +export type OnboardingState = { + opcode: string, + route: OnboardingRoute, +} + +export let summaryDone = false; +export const setSummaryDone = (b) => summaryDone = b; + +export let saveQrDone = false; +export const setSaveQrDone = (b) => saveQrDone = b; + +export let registerUserDone = false; +export const setRegisterUserDone = (b) => registerUserDone = b; + +export function getPendingOnboardingState(): Promise { + return Promise.all([getConfig(), readConsented(), readIntroDone()]).then(([config, isConsented, isIntroDone]) => { + if (isIntroDone) return null; // onboarding is done; no pending state + let route: OnboardingRoute = OnboardingRoute.NONE; + if (!config) { + route = OnboardingRoute.WELCOME; + } else if (!isConsented && !summaryDone) { + route = OnboardingRoute.SUMMARY; + } else if (!isConsented) { + route = OnboardingRoute.CONSENT; + } else if (!saveQrDone) { + route = OnboardingRoute.SAVE_QR; + } else { + route = OnboardingRoute.SURVEY; + } + return { route, opcode: config?.joined?.opcode }; + }); +}; + +async function readConsented() { + const StartPrefs = getAngularService('StartPrefs'); + return StartPrefs.readConsentState().then(StartPrefs.isConsented) as Promise; +} + +async function readIntroDone() { + const KVStore = getAngularService('KVStore'); + return KVStore.get(INTRO_DONE_KEY).then((read_val) => !!read_val) as Promise; +} + +export async function markIntroDone() { + const currDateTime = DateTime.now().toISO(); + const KVStore = getAngularService('KVStore'); + return KVStore.set(INTRO_DONE_KEY, currDateTime); +} diff --git a/www/js/recent.js b/www/js/recent.js deleted file mode 100644 index 5fbfbf66c..000000000 --- a/www/js/recent.js +++ /dev/null @@ -1,157 +0,0 @@ -angular.module('emission.main.recent', ['emission.services']) - -.controller('logCtrl', function(ControlHelper, $scope, EmailHelper) { - console.log("Launching logCtr"); - var RETRIEVE_COUNT = 100; - $scope.logCtrl = {}; - - $scope.refreshEntries = function() { - window.Logger.getMaxIndex().then(function(maxIndex) { - console.log("maxIndex = "+maxIndex); - $scope.logCtrl.currentStart = maxIndex; - $scope.logCtrl.gotMaxIndex = true; - $scope.logCtrl.reachedEnd = false; - $scope.entries = []; - $scope.addEntries(); - }, function (e) { - var errStr = "While getting max index "+JSON.stringify(e, null, 2); - console.log(errStr); - alert(errStr); - }); - } - - $scope.moreDataCanBeLoaded = function() { - return $scope.logCtrl.gotMaxIndex && !($scope.logCtrl.reachedEnd); - } - - $scope.clear = function() { - window.Logger.clearAll(); - window.Logger.log(window.Logger.LEVEL_INFO, "Finished clearing entries from unified log"); - $scope.refreshEntries(); - } - - $scope.addEntries = function() { - console.log("calling addEntries"); - window.Logger.getMessagesFromIndex($scope.logCtrl.currentStart, RETRIEVE_COUNT) - .then(function(entryList) { - $scope.$apply($scope.processEntries(entryList)); - console.log("entry list size = "+$scope.entries.length); - console.log("Broadcasting infinite scroll complete"); - $scope.$broadcast('scroll.infiniteScrollComplete') - }, function(e) { - var errStr = "While getting messages from the log "+JSON.stringify(e, null, 2); - console.log(errStr); - alert(errStr); - $scope.$broadcast('scroll.infiniteScrollComplete') - } - ) - } - - $scope.processEntries = function(entryList) { - for (let i = 0; i < entryList.length; i++) { - var currEntry = entryList[i]; - currEntry.fmt_time = moment.unix(currEntry.ts).format("llll"); - $scope.entries.push(currEntry); - } - if (entryList.length == 0) { - console.log("Reached the end of the scrolling"); - $scope.logCtrl.reachedEnd = true; - } else { - $scope.logCtrl.currentStart = entryList[entryList.length-1].ID - console.log("new start index = "+$scope.logCtrl.currentStart); - } - } - - $scope.emailLog = function () { - EmailHelper.sendEmail("loggerDB"); - } - - $scope.refreshEntries(); -}) - -.controller('sensedDataCtrl', function($scope, $ionicActionSheet, EmailHelper) { - var currentStart = 0; - - /* Let's keep a reference to the database for convenience */ - var db = window.cordova.plugins.BEMUserCache; - - $scope.config = {} - $scope.config.key_data_mapping = { - "Transitions": { - fn: db.getAllMessages, - key: "statemachine/transition" - }, - "Locations": { - fn: db.getAllSensorData, - key: "background/location" - }, - "Motion Type": { - fn: db.getAllSensorData, - key: "background/motion_activity" - }, - } - - $scope.emailCache = function () { - EmailHelper.sendEmail("userCacheDB"); - } - - $scope.config.keys = [] - for (let key in $scope.config.key_data_mapping) { - $scope.config.keys.push(key); - } - - $scope.selected = {} - $scope.selected.key = $scope.config.keys[0] - - $scope.changeSelection = function() { - $ionicActionSheet.show({ - buttons: [ - { text: 'Locations' }, - { text: 'Motion Type' }, - { text: 'Transitions' }, - ], - buttonClicked: function(index, button) { - $scope.setSelected(button.text); - return true; - } - }); - } - - $scope.setSelected = function(newVal) { - $scope.selected.key = newVal; - $scope.updateEntries(); - } - - $scope.updateEntries = function() { - let usercacheFn, usercacheKey; - if (angular.isUndefined($scope.selected.key)) { - usercacheFn = db.getAllMessages; - usercacheKey = "statemachine/transition"; - } else { - usercacheFn = $scope.config.key_data_mapping[$scope.selected.key]["fn"] - usercacheKey = $scope.config.key_data_mapping[$scope.selected.key]["key"] - } - usercacheFn(usercacheKey, true).then(function(entryList) { - $scope.entries = []; - $scope.$apply(function() { - for (let i = 0; i < entryList.length; i++) { - // $scope.entries.push({metadata: {write_ts: 1, write_fmt_time: "1"}, data: "1"}) - var currEntry = entryList[i]; - currEntry.metadata.write_fmt_time = moment.unix(currEntry.metadata.write_ts) - .tz(currEntry.metadata.time_zone) - .format("llll"); - currEntry.data = JSON.stringify(currEntry.data, null, 2); - // window.Logger.log(window.Logger.LEVEL_DEBUG, - // "currEntry.data = "+currEntry.data); - $scope.entries.push(currEntry); - } - }) - // This should really be within a try/catch/finally block - $scope.$broadcast('scroll.refreshComplete'); - }, function(error) { - window.Logger.log(window.Logger.LEVEL_ERROR, "Error updating entries"+ error); - }) - } - - $scope.updateEntries(); -}) diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 069af7a18..821b6fb09 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -1,15 +1,15 @@ 'use strict'; import angular from 'angular'; +import { getConfig } from '../config/dynamicConfig'; angular.module('emission.splash.notifscheduler', ['emission.services', 'emission.plugin.logger', - 'emission.stats.clientstats', - 'emission.config.dynamic']) + 'emission.stats.clientstats']) .factory('NotificationScheduler', function($http, $window, $ionicPlatform, - ClientStats, DynamicConfig, CommHelper, Logger) { + ClientStats, CommHelper, Logger) { const scheduler = {}; let _config; @@ -258,7 +258,7 @@ angular.module('emission.splash.notifscheduler', } $ionicPlatform.ready().then(async () => { - _config = await DynamicConfig.configReady(); + _config = await getConfig(); if (!_config.reminderSchemes) { Logger.log("No reminder schemes found in config, not scheduling notifications"); return; diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js index 223c82579..e535d179a 100644 --- a/www/js/splash/startprefs.js +++ b/www/js/splash/startprefs.js @@ -1,14 +1,13 @@ import angular from 'angular'; +import { getConfig } from '../config/dynamicConfig'; angular.module('emission.splash.startprefs', ['emission.plugin.logger', 'emission.splash.referral', - 'emission.plugin.kvstore', - 'emission.config.dynamic']) + 'emission.plugin.kvstore']) .factory('StartPrefs', function($window, $state, $interval, $rootScope, $ionicPlatform, - $ionicPopup, KVStore, $http, Logger, ReferralHandler, DynamicConfig) { + $ionicPopup, KVStore, $http, Logger, ReferralHandler) { var logger = Logger; - var nTimesCalled = 0; var startprefs = {}; // Boolean: represents that the "intro" - the one page summary // and the login are done @@ -95,7 +94,7 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', } startprefs.readConfig = function() { - return DynamicConfig.loadSavedConfig().then((savedConfig) => $rootScope.app_ui_label = savedConfig); + return getConfig().then((savedConfig) => $rootScope.app_ui_label = savedConfig); } startprefs.hasConfig = function() { @@ -112,33 +111,6 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', } } - /* - * getNextState() returns a promise, since reading the startupConfig is - * async. The promise returns an onboarding state to navigate to, or - * null for the default state - */ - - startprefs.getPendingOnboardingState = function() { - return startprefs.readStartupState().then(function([is_intro_done, is_consented, has_config]) { - if (!has_config) { - console.assert(!$rootScope.has_config, "in getPendingOnboardingState first check, $rootScope.has_config", JSON.stringify($rootScope.has_config)); - return 'root.join'; - } else if (!is_intro_done) { - console.assert(!$rootScope.intro_done, "in getPendingOnboardingState second check, $rootScope.intro_done", JSON.stringify($rootScope.intro_done)); - return 'root.intro'; - } else { - // intro is done. Now let's check consent - console.assert(is_intro_done, "in getPendingOnboardingState, local is_intro_done", is_intro_done); - console.assert($rootScope.is_intro_done, "in getPendingOnboardingState, $rootScope.intro_done", $rootScope.intro_done); - if (is_consented) { - return null; - } else { - return 'root.reconsent'; - } - } - }); - }; - /* * Read the intro_done and consent_done variables into the $rootScope so that * we can use them without making multiple native calls @@ -179,28 +151,6 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', }); } - startprefs.getNextState = function() { - return startprefs.getPendingOnboardingState().then(function(result){ - if (result == null) { - if (angular.isDefined($rootScope.redirectTo)) { - var redirState = $rootScope.redirectTo; - var redirParams = $rootScope.redirectParams; - $rootScope.redirectTo = undefined; - $rootScope.redirectParams = undefined; - return {state: redirState, params: redirParams}; - } else { - return {state: 'root.main.inf_scroll', params: {}}; - } - } else { - return {state: result, params: {}}; - } - }) - .catch((err) => { - Logger.displayError("error getting next state", err); - return "root.intro"; - }); - }; - var changeState = function(destState) { logger.log('changing state to '+destState); console.log("loading "+destState); @@ -217,39 +167,5 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', }); }; - // Currently loads main or intro based on whether onboarding is complete. - // But easily extensible to storing the last screen that the user was on, - // or the users' preferred screen - - startprefs.loadPreferredScreen = function() { - logger.log("About to navigate to preferred tab"); - startprefs.getNextState().then(changeState).catch(function(error) { - logger.displayError("Error loading preferred tab, loading root.intro", error); - // logger.log("error "+error+" loading finding tab, loading root.intro"); - changeState('root.intro'); - }); - }; - - startprefs.loadWithPrefs = function() { - // alert("attach debugger!"); - console.log("Checking to see whether we are ready to load the screen"); - if (!angular.isDefined($window.Logger)) { - alert("ionic is ready, but logger not present?"); - } - logger = Logger; - startprefs.loadPreferredScreen(); - }; - - startprefs.startWithPrefs = function() { - startprefs.loadWithPrefs(); - } - - $ionicPlatform.ready().then(function() { - Logger.log("ionicPlatform.ready() called " + nTimesCalled+" times!"); - nTimesCalled = nTimesCalled + 1; - startprefs.startWithPrefs(); - Logger.log("startprefs startup done"); - }); - return startprefs; }); diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index b4bf8f024..8b80b6dfe 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -21,7 +21,7 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => const headerEl = useRef(null); const surveyJson = useRef(null); const enketoForm = useRef
(null); - const { appConfig, loading } = useAppConfig(); + const appConfig = useAppConfig(); async function fetchSurveyJson(url) { const responseText = await fetchUrlCached(url); @@ -76,9 +76,9 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => useEffect(() => { if (!rest.visible) return; - if (!appConfig || loading) return console.error('App config not loaded yet'); + if (!appConfig) return console.error('App config not loaded yet'); initSurvey(); - }, [appConfig, loading, rest.visible]); + }, [appConfig, rest.visible]); /* adapted from the template given by enketo-core: https://github.com/enketo/enketo-core/blob/master/src/index.html */ @@ -89,11 +89,13 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => Just make sure to keep a .form-language-selector element into which the form language selector ( -
-
cm
-
ft
-
- -
{{'user-weight'}}
-
- -
-
kg
-
lb
-
-
-
{{'user-age'}}
- diff --git a/www/templates/control/app-status-modal.html b/www/templates/control/app-status-modal.html deleted file mode 100644 index 965b2857d..000000000 --- a/www/templates/control/app-status-modal.html +++ /dev/null @@ -1,15 +0,0 @@ - - -

Permissions

-
- - - -
diff --git a/www/templates/control/main-consent.html b/www/templates/control/main-consent.html deleted file mode 100644 index f991eab7f..000000000 --- a/www/templates/control/main-consent.html +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/www/templates/control/qrc.html b/www/templates/control/qrc.html deleted file mode 100644 index 3d189cece..000000000 --- a/www/templates/control/qrc.html +++ /dev/null @@ -1,28 +0,0 @@ - - - -
-
-
-

{{'general-settings.qrcode'}}

-
-
-
-
- -
-

-
-
- -
-
-

-
-
- -
-
- - -
diff --git a/www/templates/intro/changes.html b/www/templates/intro/changes.html deleted file mode 100644 index 686076b52..000000000 --- a/www/templates/intro/changes.html +++ /dev/null @@ -1,21 +0,0 @@ - - -
-

E-Mission: Data driven carbon emission reduction

-
- -
- Changes between the previously approved protocol and the current one are: -
-
    -
  1. Switch from moves to our own data collection
  2. -
  3. Define policies when used as a platform for an external study
  4. -
  5. Specify policies for time-delayed access of datasets
  6. -
-
- -
diff --git a/www/templates/intro/consent-text.html b/www/templates/intro/consent-text.html deleted file mode 100644 index fa4c2f6d9..000000000 --- a/www/templates/intro/consent-text.html +++ /dev/null @@ -1,136 +0,0 @@ - -
diff --git a/www/templates/intro/consent.html b/www/templates/intro/consent.html deleted file mode 100644 index 3c33eeca3..000000000 --- a/www/templates/intro/consent.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - diff --git a/www/templates/intro/intro.html b/www/templates/intro/intro.html deleted file mode 100644 index eaa806b6d..000000000 --- a/www/templates/intro/intro.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/www/templates/intro/reconsent.html b/www/templates/intro/reconsent.html deleted file mode 100644 index b7adfe168..000000000 --- a/www/templates/intro/reconsent.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/www/templates/intro/saveTokenFile.html b/www/templates/intro/saveTokenFile.html deleted file mode 100644 index b1bbd9d51..000000000 --- a/www/templates/intro/saveTokenFile.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - diff --git a/www/templates/intro/sensor_explanation.html b/www/templates/intro/sensor_explanation.html deleted file mode 100644 index 9f1d725ab..000000000 --- a/www/templates/intro/sensor_explanation.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/www/templates/intro/summary.html b/www/templates/intro/summary.html deleted file mode 100644 index fcff5f5d2..000000000 --- a/www/templates/intro/summary.html +++ /dev/null @@ -1,24 +0,0 @@ - - -
-

{{template_text.deployment_name}}

-
- -
- The {{ui_config.intro.deployment_partner_name}} {{template_text.deployment_name}}: -
-
    -
  1. ✔️ {{template_text.summary_line_1}} -
    -
  2. ✔️ {{template_text.summary_line_2}} -
    -
  3. ✔️ {{template_text.summary_line_3}} -
-
-
- - - - diff --git a/www/templates/intro/survey.html b/www/templates/intro/survey.html deleted file mode 100644 index 14416ee75..000000000 --- a/www/templates/intro/survey.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/www/templates/join/about-app.html b/www/templates/join/about-app.html deleted file mode 100644 index 63ab20ba1..000000000 --- a/www/templates/join/about-app.html +++ /dev/null @@ -1,42 +0,0 @@ - - -
{{'join.about-app-para-1'}}
- -
{{'join.about-app-para-2'}}
- -
{{'join.about-app-para-3'}}
- -
- {{'join.tips-title'}} -
- - - - {{'join.all-green-status'}} - - - - {{'join.dont-force-kill'}} - - -
-
- -
- - {{'join.all-green-status'}} - -
- - {{'join.background-restrictions'}} - - -
-
- - - - - diff --git a/www/templates/join/request_join.html b/www/templates/join/request_join.html deleted file mode 100644 index 6afd6940d..000000000 --- a/www/templates/join/request_join.html +++ /dev/null @@ -1,39 +0,0 @@ - - - -
-
NREL OpenPATH icon
-
-
-
-

{{'join.proceed-further'}}

- -

{{'join.what-is-opcode'}}

- -
- - - -
- - {{'join.scan-details'}} -
-
- -
- {{'join.or'}} -
-
- -
- - {{'join.paste-details'}} -
-
-
-
-
-
-
diff --git a/www/templates/main-metrics.html b/www/templates/main-metrics.html deleted file mode 100644 index d3c8b7ce3..000000000 --- a/www/templates/main-metrics.html +++ /dev/null @@ -1,225 +0,0 @@ - - - - - - - - - - -
-
-
{{'main-metrics.summary'}}
-
{{'main-metrics.chart'}}
-
-
-
-
{{'main-metrics.change-data'}}
-
-
-
-
{{ selectCtrl.fromDateTimestamp.format('ll') }} ➡️ {{ selectCtrl.toDateTimestamp.format('ll') }}
-
-
-
-
- - - -
-
-
-
-
{{'main-metrics.distance'}}
-
{{'main-metrics.trips'}}
-
{{'main-metrics.duration'}}
-
{{'main-metrics.speed'}}
-
-
-
-
- - - - -
-
-
-
-
-
-
-
-

{{'main-metrics.footprint'}}

-
-
-
kg CO₂
-
{{ 'main-metrics.label-to-squish' | i18next }}
- -
-
-
-
{{'main-metrics.how-it-compares'}}
- -
{{'main-metrics.average'}} kg CO₂
-
{{'main-metrics.avoided'}} kg CO₂
-
{{'main-metrics.lastweek'}} kg CO₂
- -
{{'main-metrics.us-2030-goal'}} {{carbonData.us2030 | number}} kg CO₂
-
{{'main-metrics.us-2050-goal'}} {{carbonData.us2050 | number}} kg CO₂
-
-
-
- -
-
-
-
-
- -
-
- -
-
- -
-

{{'main-metrics.calories'}}

-
- -
-
-
kcal
-
{{'main-metrics.equals-cookies' | i18next:{count: numberOfCookies.low} }}
-
{{'main-metrics.equals-icecream' | i18next:{count: numberOfIceCreams.low} }}
-
{{'main-metrics.equals-bananas' | i18next:{count: numberOfBananas.low} }}
- -
-
-
-
{{'main-metrics.average'}} cal
-
{{'main-metrics.lastweek'}} cal
-
-
- -
-
- -
-
- -
-
-
-
- -
-
- - -
-

{{'main-metrics.distance'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.distance[dIndex + i].key }} -
-
- {{ formatDistance(summaryData.defaultSummary.distance[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.trips'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.count[dIndex + i].key }} -
-
- {{ formatCount(summaryData.defaultSummary.count[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.duration'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.duration[dIndex + i].key }} -
-
- {{ formatDuration(summaryData.defaultSummary.duration[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.mean-speed'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.mean_speed[dIndex + i].key }} -
-
- {{ formatMeanSpeed(summaryData.defaultSummary.mean_speed[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
-
-
-
-
-
diff --git a/www/templates/main.html b/www/templates/main.html deleted file mode 100644 index c3de4adcb..000000000 --- a/www/templates/main.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/www/templates/metrics/arrow-greater-lesser.html b/www/templates/metrics/arrow-greater-lesser.html deleted file mode 100644 index bdb0cf940..000000000 --- a/www/templates/metrics/arrow-greater-lesser.html +++ /dev/null @@ -1,38 +0,0 @@ - - -
- -
-
-
{{ change.low | number:0 }}% {{'metrics.greater-than' | i18next }} {{ 'metrics.last-week' | i18next }}
-
-
-
-
{{ (-1) * change.low | number:0 }}% {{'metrics.less-than' | i18next }} {{ 'metrics.last-week' | i18next }}
-
-
-
- -
-
-
-
-
=
-
{{ (-1) * change.low | number:0 }}% {{'metrics.less' | i18next }}
-
{{ change.low | number:0 }}% {{'metrics.greater' | i18next }}
-
-
-
{{'metrics.or' | i18next }}
-
{{'metrics.week-before' | i18next }}
-
-
-
-
-
=
-
{{ (-1) * change.high | number:0 }}% {{'metrics.less' | i18next }}
-
{{ change.high | number:0 }}% {{'metrics.greater' | i18next }}
-
-
-
diff --git a/www/templates/metrics/metrics-control.html b/www/templates/metrics/metrics-control.html deleted file mode 100644 index 6d2be64dc..000000000 --- a/www/templates/metrics/metrics-control.html +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - -
-
-
{{ uictrl.currentString }}
- -
-
-
-
-
-
-
{{'metrics.from'}}
-
-
- -
-
-
- - -
-
-
-
{{'metrics.to'}} -
-
-
-
-
-
-
-
{{'metrics.frequency'}}
-
-
-
{{selectCtrl.pandaFreqString}}
-
-
- - - -
-
diff --git a/www/templates/metrics/range-display.html b/www/templates/metrics/range-display.html deleted file mode 100644 index fa8c3581e..000000000 --- a/www/templates/metrics/range-display.html +++ /dev/null @@ -1,2 +0,0 @@ -{{ lowFmt }} -{{ lowFmt }} - {{ highFmt }} diff --git a/www/templates/recent/log.html b/www/templates/recent/log.html deleted file mode 100644 index 455294705..000000000 --- a/www/templates/recent/log.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - -
- - - -
- - -
{{entry.fmt_time}}
-
{{entry.ID}} | {{entry.level}} | {{entry.message}}
-
- -
-
-
diff --git a/www/templates/recent/sensedData.html b/www/templates/recent/sensedData.html deleted file mode 100644 index 5f631635f..000000000 --- a/www/templates/recent/sensedData.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - -
- - -
- - -
{{entry.metadata.write_fmt_time}}
-
{{entry.data}}
-
-
-
-
diff --git a/www/templates/splash/splash.html b/www/templates/splash/splash.html deleted file mode 100644 index 901f6359c..000000000 --- a/www/templates/splash/splash.html +++ /dev/null @@ -1,7 +0,0 @@ - - -
- -
-
-
diff --git a/www/templates/survey/enketo/demographics-button.html b/www/templates/survey/enketo/demographics-button.html deleted file mode 100644 index 4396410e2..000000000 --- a/www/templates/survey/enketo/demographics-button.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
{{'control.edit-demographics'}}
- -
diff --git a/www/templates/survey/enketo/form-base.html b/www/templates/survey/enketo/form-base.html deleted file mode 100644 index bd933cc4a..000000000 --- a/www/templates/survey/enketo/form-base.html +++ /dev/null @@ -1,43 +0,0 @@ -
- -
diff --git a/www/templates/survey/enketo/inline.html b/www/templates/survey/enketo/inline.html deleted file mode 100644 index 34b61e282..000000000 --- a/www/templates/survey/enketo/inline.html +++ /dev/null @@ -1,40 +0,0 @@ - - -
{{'survey.loading-prior-survey'}}
-
-
- -
- -
{{'survey.prev-survey-found'}}
-
-
- -
-
- -
-
- -
-
- - -
-
- -
- -
-
-
-
- - -
-
-
diff --git a/www/templates/survey/enketo/modal.html b/www/templates/survey/enketo/modal.html deleted file mode 100644 index cc9c6c02d..000000000 --- a/www/templates/survey/enketo/modal.html +++ /dev/null @@ -1,13 +0,0 @@ - - -

{{'survey.survey'}}

- -
- - - -
diff --git a/www/templates/survey/enketo/preview.html b/www/templates/survey/enketo/preview.html deleted file mode 100644 index 0a51a4141..000000000 --- a/www/templates/survey/enketo/preview.html +++ /dev/null @@ -1,6 +0,0 @@ - From 4f3d9fb1a1ecc2982ce0b81f2f80d80cc262d502 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 11:52:34 -0600 Subject: [PATCH 055/135] fix indentations now consistent 2-space indentation --- www/__mocks__/fileSystemMocks.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index fb1258692..a7610cba7 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,18 +1,17 @@ export const mockFileSystem = () => { - window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { - const fs = {"filesystem": - {"root": - {"getFile": (path, options, onSuccess) => { - let fileEntry = {"file": (handleFile) => { - let file = new File(["this is a mock"], "loggerDB"); - handleFile(file); - }} - onSuccess(fileEntry); - } - } - } - } - console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); - handleFS(fs); + window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { + const fs = {"filesystem": + {"root": + {"getFile": (path, options, onSuccess) => { + let fileEntry = {"file": (handleFile) => { + let file = new File(["this is a mock"], "loggerDB"); + handleFile(file); + }} + onSuccess(fileEntry); + }} + } } - } \ No newline at end of file + console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); + handleFS(fs); + } +} \ No newline at end of file From 185813f17ba6e030616b08da58d0bbf3c3ebc775 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 11:59:12 -0600 Subject: [PATCH 056/135] better object declaration https://github.com/e-mission/e-mission-phone/pull/1053#discussion_r1361009142 --- www/__mocks__/fileSystemMocks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index a7610cba7..4b0e8cd3f 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,9 +1,9 @@ export const mockFileSystem = () => { window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { - const fs = {"filesystem": - {"root": - {"getFile": (path, options, onSuccess) => { - let fileEntry = {"file": (handleFile) => { + const fs = {filesystem: + {root: + {getFile: (path, options, onSuccess) => { + let fileEntry = {file: (handleFile) => { let file = new File(["this is a mock"], "loggerDB"); handleFile(file); }} From 655ddda715cff56b491a12b4cd68946ac9e427cc Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 12:01:15 -0600 Subject: [PATCH 057/135] additional indentation improvement --- www/__mocks__/fileSystemMocks.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index 4b0e8cd3f..d7c2743ac 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,17 +1,23 @@ export const mockFileSystem = () => { window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { - const fs = {filesystem: - {root: - {getFile: (path, options, onSuccess) => { - let fileEntry = {file: (handleFile) => { - let file = new File(["this is a mock"], "loggerDB"); - handleFile(file); - }} - onSuccess(fileEntry); - }} + const fs = { + filesystem: + { + root: + { + getFile: (path, options, onSuccess) => { + let fileEntry = { + file: (handleFile) => { + let file = new File(["this is a mock"], "loggerDB"); + handleFile(file); + } + } + onSuccess(fileEntry); + } + } } } - console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); - handleFS(fs); + console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); + handleFS(fs); } } \ No newline at end of file From 4026423b36f5098f3f2d1121742177cd74e36174 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 12:16:08 -0600 Subject: [PATCH 058/135] remove logError https://github.com/e-mission/e-mission-phone/pull/1053#discussion_r1361017447 --- www/js/control/uploadService.ts | 4 ++-- www/js/plugin/logger.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index 93557bdfe..c3a7a520e 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,4 +1,4 @@ -import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; +import { logDebug, logInfo, displayError } from "../plugin/logger"; import i18next from "i18next"; /** @@ -23,7 +23,7 @@ async function getUploadConfig() { url.push(uploadConfig["url"]); resolve(url); } catch (err) { - logError("Error while reading default upload config" + err); + displayError(err, "Error while reading default upload config"); reject(err); } } diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index a8a461bb6..d127f5549 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -33,9 +33,6 @@ export const logInfo = (message: string) => export const logWarn = (message: string) => window['Logger'].log(window['Logger'].LEVEL_WARN, message); -export const logError = (message: string) => - window['Logger'].log(window['Logger'].LEVEL_ERROR, message); - export function displayError(error: Error, title?: string) { const errorMsg = error.message ? error.message + '\n' + error.stack : JSON.stringify(error); displayErrorMsg(errorMsg, title); From 0c1bac8c9198c415673887a86d2918a6d59cfa59 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 13:52:28 -0600 Subject: [PATCH 059/135] remove uneeded code --- www/js/control/ProfileSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 2552b37f0..99c7c4a8a 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -222,7 +222,7 @@ const ProfileSettings = () => { //methods that control the settings const uploadLog = function () { if(uploadReason != "") { - let reason = uploadReason.split('').join(''); + let reason = uploadReason; uploadFile("loggerDB", reason); setUploadVis(false); } From b6e3c65f74fa31f6dc7e0c2d9044875da82db8e7 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:42:13 -0600 Subject: [PATCH 060/135] Update README.md Adding suggested changes Co-authored-by: shankari --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7fca214aa..6fdf01891 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This is the phone component of the e-mission system. :sparkles: This has been upgraded to the latest **Android**, **iOS**, **cordova-lib**, **node** and **npm** versions. __This is ready to build out of the box.__ -For the latest versions, refer [`package.cordovabuild.json`](package.cordovabuild.json) +The currently supported versions are in [`package.cordovabuild.json`](package.cordovabuild.json) Additional Documentation --- From 2b5735e411a514e0e5aa1aa74dadf2902ee6cc38 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:43:10 -0600 Subject: [PATCH 061/135] Update README.md Committing suggested changes Co-authored-by: Jack Greenlee --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6fdf01891..9db9d3798 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ AND/OR ``` npm run ``` -for builds, refer [`package.cordovabuild.json`](package.cordovabuild.json) +For other options of build scripts, refer to [`package.cordovabuild.json`](package.cordovabuild.json)
Expected output From cb954d1604a34cbc897992835539ebaaecc2bdd2 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:54:17 -0600 Subject: [PATCH 062/135] Update README.md Committing changes based on suggestions - Wording - Listing common build types --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 9db9d3798..59f725934 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ AND/OR npm run ``` For other options of build scripts, refer to [`package.cordovabuild.json`](package.cordovabuild.json) +Common ones listed below (both, Android, iOS) +``` +npm run build +npm run build-prod-android +npm run build-prod-ios +```
Expected output From 36b56daad2be26a6ce0ed1241797f6048707a65e Mon Sep 17 00:00:00 2001 From: Sebastian Barry <61334340+sebastianbarry@users.noreply.github.com> Date: Tue, 17 Oct 2023 11:07:17 -0600 Subject: [PATCH 063/135] Correct text in comment > JGreenlee > It's the other way around. labelOptions first, then i18next. > The code is correct but the comment is not --- www/js/survey/multilabel/confirmHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index c7cf74c26..cb94c1f2e 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -50,7 +50,7 @@ export async function getLabelOptions(appConfigParam?) { for (const opt in labelOptions) { labelOptions[opt]?.forEach?.((o, i) => { const translationKey = o.value; - // If translation exists in i18next, use that. Otherwise, use the one in the labelOptions. + // If translation exists in labelOptions, use that. Otherwise, use the one in the i18next. const translation = labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`); labelOptions[opt][i].text = translation; }); From 202f6dbbe71db89cce6e9e83a9a4a1dc5362d082 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 17 Oct 2023 11:11:45 -0600 Subject: [PATCH 064/135] Adding spaces between slashes in text > JGreenlee > While we are rewriting and have this opportunity, can we make all the text with slashes look "like / this" instead of "like/ this"? > I believe they were made like that a while ago to force a line break. But we don't do that anymore in the UI so the slashes end up looking weird. --- www/i18n/en.json | 12 ++++++------ www/json/label-options.json.sample | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 07d31c2b3..9217339f7 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -157,7 +157,7 @@ "e_car_drove_alone": "E-Car Drove Alone", "e_car_shared_ride": "E-Car Shared Ride", "moped": "Moped", - "taxi": "Taxi/Uber/Lyft", + "taxi": "Taxi / Uber / Lyft", "bus": "Bus", "train": "Train", "free_shuttle": "Free Shuttle", @@ -171,12 +171,12 @@ "transit_transfer": "Transit transfer", "shopping": "Shopping", "meal": "Meal", - "pick_drop_person": "Pick-up/ Drop off Person", - "pick_drop_item": "Pick-up/ Drop off Item", - "personal_med": "Personal/ Medical", + "pick_drop_person": "Pick-up / Drop off Person", + "pick_drop_item": "Pick-up / Drop off Item", + "personal_med": "Personal / Medical", "access_recreation": "Access Recreation", - "exercise": "Recreation/ Exercise", - "entertainment": "Entertainment/ Social", + "exercise": "Recreation / Exercise", + "entertainment": "Entertainment / Social", "religious": "Religious", "other": "Other" }, diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index 9d3447bda..0f4292c7f 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -63,7 +63,7 @@ "e_car_drove_alone": "E-Car Drove Alone", "e_car_shared_ride": "E-Car Shared Ride", "moped": "Moped", - "taxi": "Taxi/Uber/Lyft", + "taxi": "Taxi / Uber / Lyft", "bus": "Bus", "train": "Train", "free_shuttle": "Free Shuttle", @@ -77,12 +77,12 @@ "transit_transfer": "Transit transfer", "shopping": "Shopping", "meal": "Meal", - "pick_drop_person": "Pick-up/ Drop off Person", - "pick_drop_item": "Pick-up/ Drop off Item", - "personal_med": "Personal/ Medical", + "pick_drop_person": "Pick-up / Drop off Person", + "pick_drop_item": "Pick-up / Drop off Item", + "personal_med": "Personal / Medical", "access_recreation": "Access Recreation", - "exercise": "Recreation/ Exercise", - "entertainment": "Entertainment/ Social", + "exercise": "Recreation / Exercise", + "entertainment": "Entertainment / Social", "religious": "Religious", "other": "Other" }, @@ -97,7 +97,7 @@ "e_car_drove_alone": "e-coche, Condujo solo", "e_car_shared_ride": "e-coche, Condujo con ontras", "moped": "Ciclomotor", - "taxi": "Taxi/Uber/Lyft", + "taxi": "Taxi / Uber / Lyft", "bus": "Autobús", "train": "Tren", "free_shuttle": "Colectivo gratuito", @@ -111,12 +111,12 @@ "transit_transfer": "Transbordo", "shopping": "Compras", "meal": "Comida", - "pick_drop_person": "Recoger/ Entregar Individuo", - "pick_drop_item": "Recoger/ Entregar Objeto", - "personal_med": "Personal/ Médico", + "pick_drop_person": "Recoger / Entregar Individuo", + "pick_drop_item": "Recoger / Entregar Objeto", + "personal_med": "Personal / Médico", "access_recreation": "Acceder a Recreación", - "exercise": "Recreación/ Ejercicio", - "entertainment": "Entretenimiento/ Social", + "exercise": "Recreación / Ejercicio", + "entertainment": "Entretenimiento / Social", "religious": "Religioso", "other": "Otros" } From 9be110d47e85b9d3aeecad5ee68502eabae535f6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 17 Oct 2023 15:45:54 -0400 Subject: [PATCH 065/135] i18n: fix merge translations if structure differs If the structure is different between lang and fallbackLang such that a key is an object in fallbackLang and a string in lang, we experience an error when we attempt to assign fill in the property to the string. This change will skip filling in if the key is not an object in *both* lang and fallbackLang, thus preventing the error. --- www/js/i18nextInit.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/i18nextInit.ts b/www/js/i18nextInit.ts index 48177caf5..a2688d66e 100644 --- a/www/js/i18nextInit.ts +++ b/www/js/i18nextInit.ts @@ -22,14 +22,14 @@ const mergeInTranslations = (lang, fallbackLang) => { if (__DEV__) { if (typeof value === 'string') { lang[key] = `🌐${value}` - } else if (typeof value === 'object') { + } else if (typeof value === 'object' && typeof lang[key] === 'object') { lang[key] = {}; mergeInTranslations(lang[key], value); } } else { lang[key] = value; } - } else if (typeof value === 'object') { + } else if (typeof value === 'object' && typeof lang[key] === 'object') { mergeInTranslations(lang[key], fallbackLang[key]) } }); From 0e31d1549159114e542d70a6f16f6bdbe1b1b232 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 17 Oct 2023 16:18:26 -0600 Subject: [PATCH 066/135] remove unused imports from startprefts.js --- www/js/splash/startprefs.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js index 92a07e624..1bc01af3d 100644 --- a/www/js/splash/startprefs.js +++ b/www/js/splash/startprefs.js @@ -2,11 +2,10 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; import { storageGet, storageSet } from '../plugin/storage'; -angular.module('emission.splash.startprefs', ['emission.plugin.logger', - 'emission.splash.referral']) +angular.module('emission.splash.startprefs', ['emission.plugin.logger']) -.factory('StartPrefs', function($window, $state, $interval, $rootScope, $ionicPlatform, - $ionicPopup, $http, Logger, ReferralHandler) { +.factory('StartPrefs', function($window, $state, $rootScope, + $ionicPopup, $http, Logger) { var logger = Logger; var startprefs = {}; // Boolean: represents that the "intro" - the one page summary From 4fc39827f8260b32abaaa66f7dc203f44ab1fb87 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 17 Oct 2023 17:53:33 -0600 Subject: [PATCH 067/135] convert startprefs from .js to .ts first attempt at converting to a ts file, not fully functional, but did find several functions that were no longer used and so have been removed --- www/js/splash/startprefs.js | 170 ------------------------------------ www/js/splash/startprefs.ts | 119 +++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 170 deletions(-) delete mode 100644 www/js/splash/startprefs.js create mode 100644 www/js/splash/startprefs.ts diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js deleted file mode 100644 index 1bc01af3d..000000000 --- a/www/js/splash/startprefs.js +++ /dev/null @@ -1,170 +0,0 @@ -import angular from 'angular'; -import { getConfig } from '../config/dynamicConfig'; -import { storageGet, storageSet } from '../plugin/storage'; - -angular.module('emission.splash.startprefs', ['emission.plugin.logger']) - -.factory('StartPrefs', function($window, $state, $rootScope, - $ionicPopup, $http, Logger) { - var logger = Logger; - var startprefs = {}; - // Boolean: represents that the "intro" - the one page summary - // and the login are done - var INTRO_DONE_KEY = 'intro_done'; - // data collection consented protocol: string, represents the date on - // which the consented protocol was approved by the IRB - var DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; - - var CONSENTED_KEY = "config/consent"; - - startprefs.CONSENTED_EVENT = "data_collection_consented"; - startprefs.INTRO_DONE_EVENT = "intro_done"; - - var writeConsentToNative = function() { - return $window.cordova.plugins.BEMDataCollection.markConsented($rootScope.req_consent); - }; - - startprefs.markConsented = function() { - logger.log("changing consent from "+ - $rootScope.curr_consented+" -> "+JSON.stringify($rootScope.req_consent)); - // mark in native storage - return startprefs.readConsentState().then(writeConsentToNative).then(function(response) { - // mark in local storage - storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, - $rootScope.req_consent); - // mark in local variable as well - $rootScope.curr_consented = angular.copy($rootScope.req_consent); - $rootScope.$emit(startprefs.CONSENTED_EVENT, $rootScope.req_consent); - }); - }; - - startprefs.markIntroDone = function() { - var currTime = moment().format(); - storageSet(INTRO_DONE_KEY, currTime); - $rootScope.$emit(startprefs.INTRO_DONE_EVENT, currTime); - } - - // returns boolean - startprefs.readIntroDone = function() { - return storageGet(INTRO_DONE_KEY).then(function(read_val) { - logger.log("in readIntroDone, read_val = "+JSON.stringify(read_val)); - $rootScope.intro_done = read_val; - }); - } - - startprefs.isIntroDone = function() { - if ($rootScope.intro_done == null || $rootScope.intro_done == "") { - logger.log("in isIntroDone, returning false"); - $rootScope.is_intro_done = false; - return false; - } else { - logger.log("in isIntroDone, returning true"); - $rootScope.is_intro_done = true; - return true; - } - } - - startprefs.isConsented = function() { - if ($rootScope.curr_consented == null || $rootScope.curr_consented == "" || - $rootScope.curr_consented.approval_date != $rootScope.req_consent.approval_date) { - console.log("Not consented in local storage, need to show consent"); - $rootScope.is_consented = false; - return false; - } else { - console.log("Consented in local storage, no need to show consent"); - $rootScope.is_consented = true; - return true; - } - } - - startprefs.readConsentState = function() { - // read consent state from the file and populate it - return $http.get("json/startupConfig.json") - .then(function(startupConfigResult) { - $rootScope.req_consent = startupConfigResult.data.emSensorDataCollectionProtocol; - logger.log("required consent version = " + JSON.stringify($rootScope.req_consent)); - return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); - }).then(function(kv_store_consent) { - $rootScope.curr_consented = kv_store_consent; - console.assert(angular.isDefined($rootScope.req_consent), "in readConsentState $rootScope.req_consent", JSON.stringify($rootScope.req_consent)); - // we can just launch this, we don't need to wait for it - startprefs.checkNativeConsent(); - }); - } - - startprefs.readConfig = function() { - return getConfig().then((savedConfig) => $rootScope.app_ui_label = savedConfig); - } - - startprefs.hasConfig = function() { - if ($rootScope.app_ui_label == undefined || - $rootScope.app_ui_label == null || - $rootScope.app_ui_label == "") { - logger.log("Config not downloaded, need to show join screen"); - $rootScope.has_config = false; - return false; - } else { - $rootScope.has_config = true; - logger.log("Config downloaded, skipping join screen"); - return true; - } - } - - /* - * Read the intro_done and consent_done variables into the $rootScope so that - * we can use them without making multiple native calls - */ - startprefs.readStartupState = function() { - console.log("STARTPREFS: about to read startup state"); - var readIntroPromise = startprefs.readIntroDone() - .then(startprefs.isIntroDone); - var readConsentPromise = startprefs.readConsentState() - .then(startprefs.isConsented); - var readConfigPromise = startprefs.readConfig() - .then(startprefs.hasConfig); - return Promise.all([readIntroPromise, readConsentPromise, readConfigPromise]); - }; - - startprefs.getConsentDocument = function() { - return $window.cordova.plugins.BEMUserCache.getDocument("config/consent", false) - .then(function(resultDoc) { - if ($window.cordova.plugins.BEMUserCache.isEmptyDoc(resultDoc)) { - return null; - } else { - return resultDoc; - } - }); - }; - - startprefs.checkNativeConsent = function() { - startprefs.getConsentDocument().then(function(resultDoc) { - if (resultDoc == null) { - if(startprefs.isConsented()) { - logger.log("Local consent found, native consent missing, writing consent to native"); - $ionicPopup.alert({template: "Local consent found, native consent missing, writing consent to native"}); - return writeConsentToNative(); - } else { - logger.log("Both local and native consent not found, nothing to sync"); - } - } - }); - } - - var changeState = function(destState) { - logger.log('changing state to '+destState); - console.log("loading "+destState); - // TODO: Fix this the right way when we fix the FSM - // https://github.com/e-mission/e-mission-phone/issues/146#issuecomment-251061736 - var reload = false; - if (($state.$current == destState.state) && ($state.$current.name == 'root.main.goals')) { - reload = true; - } - $state.go(destState.state, destState.params).then(function() { - if (reload) { - $rootScope.$broadcast("RELOAD_GOAL_PAGE_FOR_REFERRAL") - } - }); - }; - - return startprefs; -}); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts new file mode 100644 index 000000000..d8c422568 --- /dev/null +++ b/www/js/splash/startprefs.ts @@ -0,0 +1,119 @@ +import { storageGet, storageSet } from '../plugin/storage'; +import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; + +type StartPrefs = { + CONSENTED_EVENT: string, + INTRO_DONE_EVENT: string, +} + +export const startPrefs: StartPrefs = { + CONSENTED_EVENT: "data_collection_consented", + INTRO_DONE_EVENT: "intro_done", +}; +// Boolean: represents that the "intro" - the one page summary +// and the login are done +const INTRO_DONE_KEY = 'intro_done'; +// data collection consented protocol: string, represents the date on +// which the consented protocol was approved by the IRB +const DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; + +const CONSENTED_KEY = "config/consent"; + +let _req_consent; +let _curr_consented; + +function writeConsentToNative() { + return window['cordiva'].plugins.BEMDataCollection.markConsented(_req_consent); +}; + +//used in ConsentPage +export function markConsented() { + logInfo("changing consent from " + + _curr_consented + " -> " + JSON.stringify(_req_consent)); + // mark in native storage + return readConsentState().then(writeConsentToNative).then(function (response) { + // mark in local storage + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, + _req_consent); + // mark in local variable as well + //TODO - make a copy here + _curr_consented = _req_consent; + //TODO - find out how this is used and how to replace + //emit(startPrefs.CONSENTED_EVENT, _req_consent); + }); +}; + +//used in several places - storedevice, pushnotify -- onboardingHelper has other style... +let _intro_done = null; +let _is_intro_done; +export function isIntroDone() { + if (_intro_done == null || _intro_done == "") { + logDebug("in isIntroDone, returning false"); + _is_intro_done = false; + return false; + } else { + logDebug("in isIntroDone, returning true"); + _is_intro_done = true; + return true; + } +} + +let _is_consented; +//used in onboardingHelper +export function isConsented() { + if (_curr_consented == null || _curr_consented == "" || + _curr_consented.approval_date != _req_consent.approval_date) { + console.log("Not consented in local storage, need to show consent"); + _is_consented = false; + return false; + } else { + console.log("Consented in local storage, no need to show consent"); + _is_consented = true; + return true; + } +} + +//used in onboardingHelper +export function readConsentState() { + // read consent state from the file and populate it + return fetch("json/startupConfig.json") + .then(response => response.json()) + .then(function (startupConfigResult) { + // let startupConfigJson = await startupConfigResult.json(); + console.log(startupConfigResult); + _req_consent = startupConfigResult.emSensorDataCollectionProtocol; + logInfo("required consent version = " + JSON.stringify(_req_consent)); + return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); + }).then(function (kv_store_consent) { + _curr_consented = kv_store_consent; + console.assert(((_req_consent != undefined) && (_req_consent != null)), "in readConsentState $rootScope.req_consent", JSON.stringify(_req_consent)); + // we can just launch this, we don't need to wait for it + checkNativeConsent(); + }); +} + +//used in ProfileSettings +export function getConsentDocument() { + return window['cordova'].plugins.BEMUserCache.getDocument("config/consent", false) + .then(function (resultDoc) { + if (window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc)) { + return null; + } else { + return resultDoc; + } + }); +}; + +function checkNativeConsent() { + getConsentDocument().then(function (resultDoc) { + if (resultDoc == null) { + if (isConsented()) { + logDebug("Local consent found, native consent missing, writing consent to native"); + displayErrorMsg("Local consent found, native consent missing, writing consent to native"); + return writeConsentToNative(); + } else { + logDebug("Both local and native consent not found, nothing to sync"); + } + } + }); +} From 416a111b05d3c749e47dd025d771514ef7bab029 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 17 Oct 2023 17:54:29 -0600 Subject: [PATCH 068/135] remove all references to angular service, replace with imports we are now not using the angular service anymore, so should access everything from the new ts file --- www/index.js | 1 - www/js/App.tsx | 2 -- www/js/control/ProfileSettings.jsx | 4 ++-- www/js/controllers.js | 8 ++++---- www/js/onboarding/ConsentPage.tsx | 5 ++--- www/js/onboarding/onboardingHelper.ts | 5 ++--- www/js/splash/localnotify.js | 1 - www/js/splash/pushnotify.js | 16 ++++++++-------- www/js/splash/remotenotify.js | 3 +-- www/js/splash/storedevicesettings.js | 16 ++++++++-------- 10 files changed, 27 insertions(+), 34 deletions(-) diff --git a/www/index.js b/www/index.js index 89c3a5e26..b4dd4da3d 100644 --- a/www/index.js +++ b/www/index.js @@ -6,7 +6,6 @@ import 'leaflet/dist/leaflet.css'; import './js/ngApp.js'; import './js/splash/referral.js'; import './js/splash/customURL.js'; -import './js/splash/startprefs.js'; import './js/splash/pushnotify.js'; import './js/splash/storedevicesettings.js'; import './js/splash/localnotify.js'; diff --git a/www/js/App.tsx b/www/js/App.tsx index 2187118fa..b3d823b5b 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -31,8 +31,6 @@ const App = () => { const { colors } = useTheme(); const { t } = useTranslation(); - const StartPrefs = getAngularService('StartPrefs'); - const routes = useMemo(() => { const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter(r => r.key != 'metrics'); diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 4a263acc5..ceebe586c 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -24,6 +24,7 @@ import { AppContext } from "../App"; import { shareQR } from "../components/QrCode"; import { storageClear } from "../plugin/storage"; import { getAppVersion } from "../plugin/clientStats"; +import { getConsentDocument } from "../splash/startprefs"; //any pure functions can go outside const ProfileSettings = () => { @@ -39,7 +40,6 @@ const ProfileSettings = () => { const EmailHelper = getAngularService('EmailHelper'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); - const StartPrefs = getAngularService('StartPrefs'); //functions that come directly from an Angular service const editCollectionConfig = () => setEditCollection(true); @@ -301,7 +301,7 @@ const ProfileSettings = () => { //in ProfileSettings in DevZone (above two functions are helpers) async function checkConsent() { - StartPrefs.getConsentDocument().then(function(resultDoc){ + getConsentDocument().then(function(resultDoc){ setConsentDoc(resultDoc); if (resultDoc == null) { setNoConsentVis(true); diff --git a/www/js/controllers.js b/www/js/controllers.js index 75124efce..5a4de0cb4 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -2,9 +2,9 @@ import angular from 'angular'; import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; +import { getPendingOnboardingState } from './onboarding/onboardingHelper'; -angular.module('emission.controllers', ['emission.splash.startprefs', - 'emission.splash.pushnotify', +angular.module('emission.controllers', ['emission.splash.pushnotify', 'emission.splash.storedevicesettings', 'emission.splash.localnotify', 'emission.splash.remotenotify']) @@ -14,7 +14,7 @@ angular.module('emission.controllers', ['emission.splash.startprefs', .controller('DashCtrl', function($scope) {}) .controller('SplashCtrl', function($scope, $state, $interval, $rootScope, - StartPrefs, PushNotify, StoreDeviceSettings, + PushNotify, StoreDeviceSettings, LocalNotify, RemoteNotify) { console.log('SplashCtrl invoked'); // alert("attach debugger!"); @@ -49,7 +49,7 @@ angular.module('emission.controllers', ['emission.splash.startprefs', 'root.main.metrics'] if (isInList(toState.name, personalTabs)) { // toState is in the personalTabs list - StartPrefs.getPendingOnboardingState().then(function(result) { + getPendingOnboardingState().then(function(result) { if (result != null) { event.preventDefault(); $state.go(result); diff --git a/www/js/onboarding/ConsentPage.tsx b/www/js/onboarding/ConsentPage.tsx index 08aa3ab48..2a098b3a9 100644 --- a/www/js/onboarding/ConsentPage.tsx +++ b/www/js/onboarding/ConsentPage.tsx @@ -4,9 +4,9 @@ import { View, ScrollView } from 'react-native'; import { Button, Surface } from 'react-native-paper'; import { resetDataAndRefresh } from '../config/dynamicConfig'; import { AppContext } from '../App'; -import { getAngularService } from '../angular-react-helper'; import PrivacyPolicy from './PrivacyPolicy'; import { onboardingStyles } from './OnboardingStack'; +import { markConsented } from '../splash/startprefs'; const ConsentPage = () => { @@ -20,8 +20,7 @@ const ConsentPage = () => { }; function agree() { - const StartPrefs = getAngularService('StartPrefs'); - StartPrefs.markConsented().then((response) => { + markConsented().then((response) => { refreshOnboardingState(); }); }; diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 40fb15155..78f5aa4d1 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -1,8 +1,8 @@ import { DateTime } from "luxon"; -import { getAngularService } from "../angular-react-helper"; import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; import { storageGet, storageSet } from "../plugin/storage"; import { logDebug } from "../plugin/logger"; +import { readConsentState, isConsented, isIntroDone } from "../splash/startprefs"; export const INTRO_DONE_KEY = 'intro_done'; @@ -58,8 +58,7 @@ export function getPendingOnboardingState(): Promise { }; async function readConsented() { - const StartPrefs = getAngularService('StartPrefs'); - return StartPrefs.readConsentState().then(StartPrefs.isConsented) as Promise; + return readConsentState().then(isConsented) as Promise; } async function readIntroDone() { diff --git a/www/js/splash/localnotify.js b/www/js/splash/localnotify.js index 6a4241f2c..c96ba827d 100644 --- a/www/js/splash/localnotify.js +++ b/www/js/splash/localnotify.js @@ -8,7 +8,6 @@ import angular from 'angular'; angular.module('emission.splash.localnotify', ['emission.plugin.logger', - 'emission.splash.startprefs', 'ionic-toast']) .factory('LocalNotify', function($window, $ionicPlatform, $ionicPopup, $state, $rootScope, ionicToast, Logger) { diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 40d859f09..874a539a5 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -15,12 +15,12 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; +import { readConsentState, isIntroDone, isConsented, startPrefs } from './startprefs'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', - 'emission.services', - 'emission.splash.startprefs']) + 'emission.services']) .factory('PushNotify', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, StartPrefs) { + $ionicPopup, Logger) { var pushnotify = {}; var push = null; @@ -159,8 +159,8 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', $ionicPlatform.ready().then(function() { pushnotify.datacollect = $window.cordova.plugins.BEMDataCollection; - StartPrefs.readConsentState() - .then(StartPrefs.isConsented) + readConsentState() + .then(isConsented) .then(function(consentState) { if (consentState == true) { pushnotify.registerPush(); @@ -172,16 +172,16 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', Logger.log("pushnotify startup done"); }); - $rootScope.$on(StartPrefs.CONSENTED_EVENT, function(event, data) { + $rootScope.$on(startPrefs.CONSENTED_EVENT, function(event, data) { console.log("got consented event "+JSON.stringify(event.name) +" with data "+ JSON.stringify(data)); - if (StartPrefs.isIntroDone()) { + if (isIntroDone()) { console.log("intro is done -> reconsent situation, we already have a token -> register"); pushnotify.registerPush(); } }); - $rootScope.$on(StartPrefs.INTRO_DONE_EVENT, function(event, data) { + $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); pushnotify.registerPush(); }); diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index 3e43b6f9f..f08921fdd 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -15,8 +15,7 @@ import angular from 'angular'; import { addStatEvent, statKeys } from '../plugin/clientStats'; -angular.module('emission.splash.remotenotify', ['emission.plugin.logger', - 'emission.splash.startprefs']) +angular.module('emission.splash.remotenotify', ['emission.plugin.logger']) .factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, Logger) { diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index d307feaa7..2b60fbee4 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,11 +1,11 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; +import { isConsented, readConsentState, startPrefs, isIntroDone } from "./startprefs"; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', - 'emission.services', - 'emission.splash.startprefs']) + 'emission.services']) .factory('StoreDeviceSettings', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, StartPrefs) { + $ionicPopup, Logger ) { var storedevicesettings = {}; @@ -32,8 +32,8 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', $ionicPlatform.ready().then(function() { storedevicesettings.datacollect = $window.cordova.plugins.BEMDataCollection; - StartPrefs.readConsentState() - .then(StartPrefs.isConsented) + readConsentState() + .then(isConsented) .then(function(consentState) { if (consentState == true) { storedevicesettings.storeDeviceSettings(); @@ -44,16 +44,16 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', Logger.log("storedevicesettings startup done"); }); - $rootScope.$on(StartPrefs.CONSENTED_EVENT, function(event, data) { + $rootScope.$on(startPrefs.CONSENTED_EVENT, function(event, data) { console.log("got consented event "+JSON.stringify(event.name) +" with data "+ JSON.stringify(data)); - if (StartPrefs.isIntroDone()) { + if (isIntroDone()) { console.log("intro is done -> reconsent situation, we already have a token -> register"); storedevicesettings.storeDeviceSettings(); } }); - $rootScope.$on(StartPrefs.INTRO_DONE_EVENT, function(event, data) { + $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); storedevicesettings.storeDeviceSettings(); }); From 46439496147ce1dac70a4c82bbe7615f5357fce6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 00:18:41 -0400 Subject: [PATCH 069/135] Apply suggestions from code review --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59f725934..28f6fe615 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ If connecting to a development server over http, make sure to turn on http suppo ``` -### Run in the emulator +### Building the app ``` npm run From 685e1b65b718f04f224945514ade63c43743da0e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 00:33:28 -0400 Subject: [PATCH 070/135] Update README.md Reword the "Building the app" section to make it clearer what scripts are available and what they do --- README.md | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 28f6fe615..9e3b865c0 100644 --- a/README.md +++ b/README.md @@ -205,22 +205,17 @@ If connecting to a development server over http, make sure to turn on http suppo ### Building the app -``` -npm run -``` -AND/OR -``` -npm run -``` -For other options of build scripts, refer to [`package.cordovabuild.json`](package.cordovabuild.json) -Common ones listed below (both, Android, iOS) -``` -npm run build -npm run build-prod-android -npm run build-prod-ios -``` +We offer a set of build scripts you can pick from, each of which i) bundle the JS with Webpack and then ii) proceed with a Cordova build. +You can bundle JS in 'production' or 'dev' mode, and you can build Android or iOS or both. +The common use cases will be: + +- `npm run build` (to build for production on both Android and iOS platforms) +- `npm run build-prod-android` (to build for production on Android platform only) +- `npm run build-prod-ios` (to build for production on iOS platform only) + +Find the full list of these scripts in [`package.cordovabuild.json`](package.cordovabuild.json) -
Expected output +
Expected output (Android build) ``` BUILD SUCCESSFUL in 2m 48s From 047c2b72150bcd373f08100b569376ca82a3efca Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 00:38:52 -0400 Subject: [PATCH 071/135] Update README.md Remove the note about putting your own configuration files in the `/json` directory. We use the dynamic config now and encourage people to use it rather than making local code changes. --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 9e3b865c0..28d2940df 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,6 @@ Run the setup script bash setup/setup_serve.sh ``` -**(optional)** Configure by changing the files in `www/json`. -Defaults are in `www/json/*.sample` - -``` -ls www/json/*.sample -cp www/json/startupConfig.json.sample www/json/startupConfig.json -cp ..... www/json/connectionConfig.json -``` - ### Activation (after install, and in every new shell) ``` From 5f9d189fb6e1723587aa2907dc1f8b9fffd5e51a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 00:59:57 -0400 Subject: [PATCH 072/135] Update README.md update outdated comments about connectionConfig.json to reflect that we don't use connectionConfig.json anymore; this is replaced by the 'server' field of the dynamic config. If not present, it uses localhost. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 28d2940df..f15d413c5 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +The dynamic config (see https://github.com/e-mission/nrel-openpath-deploy-configs) controls the server endpoint that the phone app will connect to. If you are running the app in an emulator on the same machine as your local server (i.e. they share a `localhost`), you can use one of the `dev-emulator-*` configs (these configs have no `server` specified so `localhost` is assumed). -One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. +If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. Updating the e-mission-\* plugins or adding new plugins --- From c6dc2e5ab2bceb4081835383d537861714648860 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 01:04:57 -0400 Subject: [PATCH 073/135] Update README.md reword "Building the app" --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f15d413c5..1282b2c6e 100644 --- a/README.md +++ b/README.md @@ -196,14 +196,14 @@ If connecting to a development server over http, make sure to turn on http suppo ### Building the app -We offer a set of build scripts you can pick from, each of which i) bundle the JS with Webpack and then ii) proceed with a Cordova build. -You can bundle JS in 'production' or 'dev' mode, and you can build Android or iOS or both. +We offer a set of build scripts to pick from, each of which: (i) bundle the JS with Webpack, and then (ii) proceed with a Cordova build. The common use cases will be: - `npm run build` (to build for production on both Android and iOS platforms) - `npm run build-prod-android` (to build for production on Android platform only) - `npm run build-prod-ios` (to build for production on iOS platform only) +There are a variety of options because Webpack can bundle the JS in 'production' or 'dev' mode, and you can build Android or iOS or both. Find the full list of these scripts in [`package.cordovabuild.json`](package.cordovabuild.json)
Expected output (Android build) From 96d29e6c887489381e910cad8441df3791a798b8 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 01:15:50 -0400 Subject: [PATCH 074/135] Update README.md make "Updating the UI only" a bit clearer: - you don't have to manually type the IP address in the devapp if it's on localhost - you do have to type it manually if it's on a different device, and you should make sure that it's on the same network --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1282b2c6e..0f2b4642e 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ source setup/activate_serve.sh .... ``` -1. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" +1. Change the devapp connection URL and press "Connect" + - If you are running the devapp in an emulator on the same machine as the devapp server, you may simply use localhost, which would be `127.0.0.1:3000` on iOS and `10.0.2.2:3000` on Android. + - If you are running the devapp on a different device, you must type the address manually (e.g. `192.168.162.1:3000`). Note that this is a local IP address; the devices must be on the same network 1. The app will now display the version of e-mission app that is in your local directory 1. The console logs will be displayed back in the server window (prefaced by `[console]`) 1. Breakpoints can be added by connecting through the browser @@ -66,7 +68,7 @@ source setup/activate_serve.sh **Ta-da!** :gift: If you change any of the files in the `www` directory, the app will automatically be re-loaded without manually restarting either the server or the app :tada: -**Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. +**Note**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. End to end testing --- From 079f4ea667a876b06a23475f52041d78c58ffe4f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 01:22:00 -0400 Subject: [PATCH 075/135] Update README.md Remove the note about putting your own configuration files in the `/json` directory. We use the dynamic config now and encourage people to use it rather than making local code changes. --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 0f2b4642e..3c5a12e8c 100644 --- a/README.md +++ b/README.md @@ -153,15 +153,6 @@ AND/OR bash setup/setup_ios_native.sh ``` -**(optional)** Configure by changing the files in `www/json`. -Defaults are in `www/json/*.sample` - -``` -ls www/json/*.sample -cp www/json/startupConfig.json.sample www/json/startupConfig.json -cp ..... www/json/connectionConfig.json -``` - ### Activation (after install, and in every new shell) ``` From bbb0f68ff6e83e40b8e563089ceb99afbb712628 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 18 Oct 2023 12:33:44 -0600 Subject: [PATCH 076/135] renameConsentPage to protocolPage --- www/js/onboarding/{ConsentPage.tsx => ProtocolPage.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename www/js/onboarding/{ConsentPage.tsx => ProtocolPage.tsx} (100%) diff --git a/www/js/onboarding/ConsentPage.tsx b/www/js/onboarding/ProtocolPage.tsx similarity index 100% rename from www/js/onboarding/ConsentPage.tsx rename to www/js/onboarding/ProtocolPage.tsx From 18f384f224a377381ce53743050ecc91d4a62044 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 18 Oct 2023 12:37:51 -0600 Subject: [PATCH 077/135] alter timing of markConsented in order to fix the bug, we're changing when we mark the consent in storage. Moving the call to after the user has agreed to both parts -- protocol AND permissions, prevents the early popup. For more discussion see: https://github.com/e-mission/e-mission-docs/issues/1006#issuecomment-1769073063 --- www/js/App.tsx | 6 +++--- www/js/onboarding/OnboardingStack.tsx | 6 +++--- www/js/onboarding/ProtocolPage.tsx | 11 +++++------ www/js/onboarding/SaveQrPage.tsx | 15 +++++++++------ www/js/onboarding/onboardingHelper.ts | 17 ++++++++++------- 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/www/js/App.tsx b/www/js/App.tsx index 2187118fa..3c6c8bec9 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -91,9 +91,9 @@ const App = () => { {appContent} - { /* If we are past the consent page (route > CONSENT), the permissions popup can show if needed. - This also includes if onboarding is DONE altogether (because "DONE" is > "CONSENT") */ } - {(onboardingState && onboardingState.route > OnboardingRoute.CONSENT) && + { /* If we are fully consented, (route > PROTOCOL), the permissions popup can show if needed. + This also includes if onboarding is DONE altogether (because "DONE" is > "PROTOCOL") */ } + {(onboardingState && onboardingState.route > OnboardingRoute.PROTOCOL) && } diff --git a/www/js/onboarding/OnboardingStack.tsx b/www/js/onboarding/OnboardingStack.tsx index a49bde3ab..c547fd074 100644 --- a/www/js/onboarding/OnboardingStack.tsx +++ b/www/js/onboarding/OnboardingStack.tsx @@ -2,7 +2,7 @@ import React, { useContext } from "react"; import { StyleSheet } from "react-native"; import { AppContext } from "../App"; import WelcomePage from "./WelcomePage"; -import ConsentPage from "./ConsentPage"; +import ProtocolPage from "./ProtocolPage"; import SurveyPage from "./SurveyPage"; import SaveQrPage from "./SaveQrPage"; import SummaryPage from "./SummaryPage"; @@ -19,8 +19,8 @@ const OnboardingStack = () => { return ; } else if (onboardingState.route == OnboardingRoute.SUMMARY) { return ; - } else if (onboardingState.route == OnboardingRoute.CONSENT) { - return ; + } else if (onboardingState.route == OnboardingRoute.PROTOCOL) { + return ; } else if (onboardingState.route == OnboardingRoute.SAVE_QR) { return ; } else if (onboardingState.route == OnboardingRoute.SURVEY) { diff --git a/www/js/onboarding/ProtocolPage.tsx b/www/js/onboarding/ProtocolPage.tsx index 08aa3ab48..73961245a 100644 --- a/www/js/onboarding/ProtocolPage.tsx +++ b/www/js/onboarding/ProtocolPage.tsx @@ -7,8 +7,9 @@ import { AppContext } from '../App'; import { getAngularService } from '../angular-react-helper'; import PrivacyPolicy from './PrivacyPolicy'; import { onboardingStyles } from './OnboardingStack'; +import { setProtocolDone } from './onboardingHelper'; -const ConsentPage = () => { +const ProtocolPage = () => { const { t } = useTranslation(); const context = useContext(AppContext); @@ -20,10 +21,8 @@ const ConsentPage = () => { }; function agree() { - const StartPrefs = getAngularService('StartPrefs'); - StartPrefs.markConsented().then((response) => { - refreshOnboardingState(); - }); + setProtocolDone(true); + refreshOnboardingState(); }; // privacy policy and data collection info, followed by accept/reject buttons @@ -40,4 +39,4 @@ const ConsentPage = () => { ); } -export default ConsentPage; +export default ProtocolPage; diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 51f884886..658c66993 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -20,12 +20,15 @@ const SaveQrPage = ({ }) => { useEffect(() => { if (overallStatus == true && !registerUserDone) { - logDebug('permissions done, going to log in'); - login(onboardingState.opcode).then((response) => { - logDebug('login done, refreshing onboarding state'); - setRegisterUserDone(true); - preloadDemoSurveyResponse(); - refreshOnboardingState(); + const StartPrefs = getAngularService('StartPrefs'); + StartPrefs.markConsented().then((response) => { + logDebug('permissions done, going to log in'); + login(onboardingState.opcode).then((response) => { + logDebug('login done, refreshing onboarding state'); + setRegisterUserDone(true); + preloadDemoSurveyResponse(); + refreshOnboardingState(); + }); }); } else { logDebug('permissions not done, waiting'); diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index cfbebb40b..4a6ec202c 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -6,12 +6,12 @@ import { logDebug } from "../plugin/logger"; export const INTRO_DONE_KEY = 'intro_done'; // route = WELCOME if no config present -// route = SUMMARY if config present, but not consented and summary not done -// route = CONSENT if config present, but not consented and summary done -// route = SAVE_QR if config present, consented, but save qr not done +// route = SUMMARY if config present, but protocol not done and summary not done +// route = PROTOCOL if config present, but protocol not done and summary done +// route = SAVE_QR if config present, protocol done, but save qr not done // route = SURVEY if config present, consented and save qr done // route = DONE if onboarding is finished (intro_done marked) -export enum OnboardingRoute { WELCOME, SUMMARY, CONSENT, SAVE_QR, SURVEY, DONE }; +export enum OnboardingRoute { WELCOME, SUMMARY, PROTOCOL, SAVE_QR, SURVEY, DONE }; export type OnboardingState = { opcode: string, route: OnboardingRoute, @@ -20,6 +20,9 @@ export type OnboardingState = { export let summaryDone = false; export const setSummaryDone = (b) => summaryDone = b; +export let protocolDone = false; +export const setProtocolDone = (b) => protocolDone = b; + export let saveQrDone = false; export const setSaveQrDone = (b) => saveQrDone = b; @@ -40,10 +43,10 @@ export function getPendingOnboardingState(): Promise { route = OnboardingRoute.DONE; } else if (!config) { route = OnboardingRoute.WELCOME; - } else if (!isConsented && !summaryDone) { + } else if (!protocolDone && !summaryDone) { route = OnboardingRoute.SUMMARY; - } else if (!isConsented) { - route = OnboardingRoute.CONSENT; + } else if (!protocolDone) { + route = OnboardingRoute.PROTOCOL; } else if (!saveQrDone) { route = OnboardingRoute.SAVE_QR; } else { From 0584278c258326cdadc17199fa40258030a20de8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 18 Oct 2023 16:45:19 -0600 Subject: [PATCH 078/135] add a comment about the notifications we had a bug related to this call, so adding a note to future development to make sure we're aware of the ramifications that this can cause. --- www/js/splash/startprefs.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index d8c422568..a89b9aec8 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -23,6 +23,9 @@ let _req_consent; let _curr_consented; function writeConsentToNative() { + //note that this calls to the notification API, + //so should not be called until we have notification permissions + //see https://github.com/e-mission/e-mission-docs/issues/1006 return window['cordiva'].plugins.BEMDataCollection.markConsented(_req_consent); }; From 1d4752d58d65b189b3c0f944d0fd83736a8cbc51 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 18 Oct 2023 16:58:34 -0600 Subject: [PATCH 079/135] remove extra line --- www/js/splash/startprefs.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index a89b9aec8..3b0455c19 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -82,7 +82,6 @@ export function readConsentState() { return fetch("json/startupConfig.json") .then(response => response.json()) .then(function (startupConfigResult) { - // let startupConfigJson = await startupConfigResult.json(); console.log(startupConfigResult); _req_consent = startupConfigResult.emSensorDataCollectionProtocol; logInfo("required consent version = " + JSON.stringify(_req_consent)); From e0dc3bb0419aa3a8470db597ab444d3ba94f0847 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 18 Oct 2023 16:58:58 -0600 Subject: [PATCH 080/135] correct typo was breaking because I spelled cordova wrong --- www/js/splash/startprefs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 3b0455c19..0b8580e16 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -26,7 +26,7 @@ function writeConsentToNative() { //note that this calls to the notification API, //so should not be called until we have notification permissions //see https://github.com/e-mission/e-mission-docs/issues/1006 - return window['cordiva'].plugins.BEMDataCollection.markConsented(_req_consent); + return window['cordova'].plugins.BEMDataCollection.markConsented(_req_consent); }; //used in ConsentPage From 8a3320d84b59e14174d912b6501282360e5cad9e Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 09:15:10 -0600 Subject: [PATCH 081/135] make a deep copy for storage --- www/js/splash/startprefs.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 0b8580e16..71490f499 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -39,8 +39,7 @@ export function markConsented() { storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, _req_consent); // mark in local variable as well - //TODO - make a copy here - _curr_consented = _req_consent; + _curr_consented = {..._req_consent}; //TODO - find out how this is used and how to replace //emit(startPrefs.CONSENTED_EVENT, _req_consent); }); From 86fe118eb42a0f5db940d8b4fbd4acdf60fcd0ea Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 09:16:48 -0600 Subject: [PATCH 082/135] correct outdated comments https://github.com/e-mission/e-mission-phone/pull/1053#discussion_r1365711315 --- www/js/control/uploadService.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index c3a7a520e..038bd8efc 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -73,9 +73,7 @@ function readDBFile(parentDir, database, callbackFn) { } const sendToServer = function upload(url, binArray, params) { - //this was the best way I could find to contact the database, - //had to modify the way it gets handled on the other side - //the original way it could not find "reason" + //use url encoding to pass additional params in the post const urlParams = "?reason=" + params.reason + "&tz=" + params.tz; return fetch(url+urlParams, { method: 'POST', From cac185f788d580c9f09fa4a7b4b1bf5f07f9ba50 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 09:33:33 -0600 Subject: [PATCH 083/135] add comments and timeout this mock was confusing, so I've added comments to indicate what the two parts do. Perviously, there was no timeout in the if, only in the else, now they are consistent --- www/__tests__/uploadService.test.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index c0c7f9d04..b845d1ba9 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -18,16 +18,22 @@ mockFileSystem(); //comnplex mock, allows the readDBFile to work in testing //use this message to verify that the post went through let message = ""; -global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { - if(options) { - message = "sent " + options.method + options.body + " for " + url;; - rs('sent ' + options.method + options.body + ' to ' + url); - } else { - setTimeout(() => rs({ - json: () => new Promise((rs, rj) => { - setTimeout(() => rs('mock data for ' + url), 100); - }) - })); +//each have a slight delay to mimic a real fetch request +global.fetch = (url: string, options: { method: string, headers: {}, body: string }) => new Promise((rs, rj) => { + //if there's options, that means there is a post request + if (options) { + setTimeout(() => { + message = "sent " + options.method + options.body + " for " + url; + rs('sent ' + options.method + options.body + ' to ' + url); + }, 100); + } + //else it is a fetch request + else { + setTimeout(() => rs({ + json: () => new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); + }) + })); } }) as any; From 4b148c224a61f658f5d26156a0c03d4b426e89a1 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 10:00:54 -0600 Subject: [PATCH 084/135] fix location of timeout we want to set the message right away, then have a delay on the response itself --- www/__tests__/uploadService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index b845d1ba9..6aea5805d 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -22,8 +22,8 @@ let message = ""; global.fetch = (url: string, options: { method: string, headers: {}, body: string }) => new Promise((rs, rj) => { //if there's options, that means there is a post request if (options) { + message = "sent " + options.method + options.body + " for " + url; setTimeout(() => { - message = "sent " + options.method + options.body + " for " + url; rs('sent ' + options.method + options.body + ' to ' + url); }, 100); } From d8a3b33768b06840e9de146fc4bcd90ad7312d29 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 10:24:54 -0600 Subject: [PATCH 085/135] revert uploadSample json file https://github.com/e-mission/e-mission-phone/pull/1053/files/0c1bac8c9198c415673887a86d2918a6d59cfa59#r1364808637 --- www/json/uploadConfig.json.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/json/uploadConfig.json.sample b/www/json/uploadConfig.json.sample index a3c8b7210..53cead55e 100644 --- a/www/json/uploadConfig.json.sample +++ b/www/json/uploadConfig.json.sample @@ -1,3 +1,3 @@ { - "url": "http://localhost:5647/phonelogs" + "url": "http://fill.me.in " } From 58a50a6b941204a93e344305c32cd153d00afe6c Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Thu, 19 Oct 2023 12:05:19 -0600 Subject: [PATCH 086/135] Update README.md Committing suggested change Co-authored-by: K. Shankari --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c5a12e8c..609777cc5 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> pa
-### Activation (after install, and in every new shell) +### Enable HTTP support on android by editing `config.xml` If connecting to a development server over http, make sure to turn on http support on android From 9d3965a1f9607837cc6989cebfdfdad8eaf28743 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Thu, 19 Oct 2023 12:11:56 -0600 Subject: [PATCH 087/135] Update README.md Committing change based on suggestion (adding a link that points to Noel-openpath-deploy-configs) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 609777cc5..111e0d82a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ are available in the [e-mission-server README](https://github.com/e-mission/e-mi The dynamic config (see https://github.com/e-mission/nrel-openpath-deploy-configs) controls the server endpoint that the phone app will connect to. If you are running the app in an emulator on the same machine as your local server (i.e. they share a `localhost`), you can use one of the `dev-emulator-*` configs (these configs have no `server` specified so `localhost` is assumed). -If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. +If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. [deploy-configs](https://github.com/e-mission/nrel-openpath-deploy-configs/#testing-configs) has more information on this. Updating the e-mission-\* plugins or adding new plugins --- From ca0d569db01f2b7fdd825423fe4f212938494384 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Thu, 19 Oct 2023 13:00:14 -0700 Subject: [PATCH 088/135] clarify link to repo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 111e0d82a..121684e0a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ are available in the [e-mission-server README](https://github.com/e-mission/e-mi The dynamic config (see https://github.com/e-mission/nrel-openpath-deploy-configs) controls the server endpoint that the phone app will connect to. If you are running the app in an emulator on the same machine as your local server (i.e. they share a `localhost`), you can use one of the `dev-emulator-*` configs (these configs have no `server` specified so `localhost` is assumed). -If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. [deploy-configs](https://github.com/e-mission/nrel-openpath-deploy-configs/#testing-configs) has more information on this. +If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. The [deploy-configs](https://github.com/e-mission/nrel-openpath-deploy-configs/#testing-configs) repo has more information on this. Updating the e-mission-\* plugins or adding new plugins --- From a26536c0d422d229e871da91bae3222220a9a9c5 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 14:39:47 -0600 Subject: [PATCH 089/135] updating "keys" remove unused key, update to single source of truth for INTRO_DONE_KEY --- www/js/splash/startprefs.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 71490f499..a69a970ec 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,5 +1,6 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; +import { INTRO_DONE_KEY } from '../onboarding/onboardingHelper'; type StartPrefs = { CONSENTED_EVENT: string, @@ -10,15 +11,11 @@ export const startPrefs: StartPrefs = { CONSENTED_EVENT: "data_collection_consented", INTRO_DONE_EVENT: "intro_done", }; -// Boolean: represents that the "intro" - the one page summary -// and the login are done -const INTRO_DONE_KEY = 'intro_done'; + // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB const DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; -const CONSENTED_KEY = "config/consent"; - let _req_consent; let _curr_consented; From d8d1a3779a2fcf56c99a4d0c112a93a269948a93 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 14:43:05 -0600 Subject: [PATCH 090/135] update event protocol Pre-migration, there was a "consent done" event broadcasted from startprefs whenever the user marked consent. However, Reach and typescript does not handle events like this. Instead, we are extracting the code into a function, retrieving it from the plugin (for now), and then calling those functions when consent is marked. As a part of this, unify the reading of "isIntroDone" to the method in onboardingHelper, rather than storing a variable in startPrefs --- www/js/onboarding/onboardingHelper.ts | 4 ++-- www/js/splash/pushnotify.js | 17 ++++++++++------- www/js/splash/startprefs.ts | 24 +++++++----------------- www/js/splash/storedevicesettings.js | 14 ++++++++------ 4 files changed, 27 insertions(+), 32 deletions(-) diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 78f5aa4d1..daec90cb1 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -2,7 +2,7 @@ import { DateTime } from "luxon"; import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; import { storageGet, storageSet } from "../plugin/storage"; import { logDebug } from "../plugin/logger"; -import { readConsentState, isConsented, isIntroDone } from "../splash/startprefs"; +import { readConsentState, isConsented } from "../splash/startprefs"; export const INTRO_DONE_KEY = 'intro_done'; @@ -61,7 +61,7 @@ async function readConsented() { return readConsentState().then(isConsented) as Promise; } -async function readIntroDone() { +export async function readIntroDone() { return storageGet(INTRO_DONE_KEY).then((read_val) => !!read_val) as Promise; } diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 874a539a5..0a5f9f18e 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -15,7 +15,8 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { readConsentState, isIntroDone, isConsented, startPrefs } from './startprefs'; +import { readConsentState, isConsented, startPrefs } from './startprefs'; +import { readIntroDone } from '../onboarding/onboardingHelper'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services']) @@ -172,14 +173,16 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', Logger.log("pushnotify startup done"); }); - $rootScope.$on(startPrefs.CONSENTED_EVENT, function(event, data) { - console.log("got consented event "+JSON.stringify(event.name) - +" with data "+ JSON.stringify(data)); - if (isIntroDone()) { + //new way of handling this, called in startprefs by markConsent + pushnotify.afterConsent = function () { + console.log("in pushnotify, executing after consent is received"); + readIntroDone().then((intro_done) => { + if (intro_done) { console.log("intro is done -> reconsent situation, we already have a token -> register"); pushnotify.registerPush(); - } - }); + } + }) + } $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index a69a970ec..5bcfac39a 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,6 +1,7 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; import { INTRO_DONE_KEY } from '../onboarding/onboardingHelper'; +import { getAngularService } from "../angular-react-helper"; type StartPrefs = { CONSENTED_EVENT: string, @@ -37,26 +38,15 @@ export function markConsented() { _req_consent); // mark in local variable as well _curr_consented = {..._req_consent}; - //TODO - find out how this is used and how to replace - //emit(startPrefs.CONSENTED_EVENT, _req_consent); + + //handle consent in other plugins - previously used $emit + const PushNotify = getAngularService("PushNotify"); + PushNotify.afterConsent(); + const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + StoreSeviceSettings.afterConsent(); }); }; -//used in several places - storedevice, pushnotify -- onboardingHelper has other style... -let _intro_done = null; -let _is_intro_done; -export function isIntroDone() { - if (_intro_done == null || _intro_done == "") { - logDebug("in isIntroDone, returning false"); - _is_intro_done = false; - return false; - } else { - logDebug("in isIntroDone, returning true"); - _is_intro_done = true; - return true; - } -} - let _is_consented; //used in onboardingHelper export function isConsented() { diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index 2b60fbee4..f9ff0e00c 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,6 +1,7 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { isConsented, readConsentState, startPrefs, isIntroDone } from "./startprefs"; +import { isConsented, readConsentState, startPrefs } from "./startprefs"; +import { readIntroDone } from '../onboarding/onboardingHelper'; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services']) @@ -44,14 +45,15 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', Logger.log("storedevicesettings startup done"); }); - $rootScope.$on(startPrefs.CONSENTED_EVENT, function(event, data) { - console.log("got consented event "+JSON.stringify(event.name) - +" with data "+ JSON.stringify(data)); - if (isIntroDone()) { + storedevicesettings.afterConsent = function() { + console.log("in storedevicesettings, executing after consent is received"); + readIntroDone().then((intro_done) => { + if (intro_done) { console.log("intro is done -> reconsent situation, we already have a token -> register"); storedevicesettings.storeDeviceSettings(); } - }); + }) + } $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); From 8a99ba93f74345b97942983a4dd27b178fa7ee9c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 15:23:18 -0600 Subject: [PATCH 091/135] move plugin calls we actually need to wait until fully consented, and the user registered before we can take the actions in the plugins I noticed this because of an error message that appeared when logging in, indicating that the user was not registered, so the device settings could not be stored moving these calls to after the user has FULLY consented, resolved that issue --- www/js/onboarding/SaveQrPage.tsx | 7 +++++++ www/js/splash/startprefs.ts | 6 ------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 406376cfa..387ee4dc3 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -28,6 +28,13 @@ const SaveQrPage = ({ }) => { setRegisterUserDone(true); preloadDemoSurveyResponse(); refreshOnboardingState(); + + //fully consented, so can handle other aspects + //other plugins - previously used $emit + const PushNotify = getAngularService("PushNotify"); + PushNotify.afterConsent(); + const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + StoreSeviceSettings.afterConsent(); }); } else { logDebug('permissions not done, waiting'); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 5bcfac39a..cb48b9769 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -38,12 +38,6 @@ export function markConsented() { _req_consent); // mark in local variable as well _curr_consented = {..._req_consent}; - - //handle consent in other plugins - previously used $emit - const PushNotify = getAngularService("PushNotify"); - PushNotify.afterConsent(); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - StoreSeviceSettings.afterConsent(); }); }; From 1edfc8c70c058a2e6ca64a367681ddfb2dc33115 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 15:56:32 -0600 Subject: [PATCH 092/135] add docstrings for functions adding descriptions to the functions, and removed some unused imports --- www/js/splash/startprefs.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index cb48b9769..f8626b9e8 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,7 +1,5 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; -import { INTRO_DONE_KEY } from '../onboarding/onboardingHelper'; -import { getAngularService } from "../angular-react-helper"; type StartPrefs = { CONSENTED_EVENT: string, @@ -20,6 +18,10 @@ const DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; let _req_consent; let _curr_consented; +/** + * @function writes the consent document to native storage + * @returns Promise to execute the write to storage +*/ function writeConsentToNative() { //note that this calls to the notification API, //so should not be called until we have notification permissions @@ -27,7 +29,10 @@ function writeConsentToNative() { return window['cordova'].plugins.BEMDataCollection.markConsented(_req_consent); }; -//used in ConsentPage +/** + * @function marks consent in native storage, local storage, and local var + * @returns Promise for marking the consent in native and local storage + */ export function markConsented() { logInfo("changing consent from " + _curr_consented + " -> " + JSON.stringify(_req_consent)); @@ -42,7 +47,11 @@ export function markConsented() { }; let _is_consented; -//used in onboardingHelper + +/** + * @function checking for consent locally + * @returns {boolean} if the consent is marked in the local var + */ export function isConsented() { if (_curr_consented == null || _curr_consented == "" || _curr_consented.approval_date != _req_consent.approval_date) { @@ -56,9 +65,11 @@ export function isConsented() { } } -//used in onboardingHelper +/** + * @function reads the consent state from the file and populates it + * @returns Promise for the stored consent file + */ export function readConsentState() { - // read consent state from the file and populate it return fetch("json/startupConfig.json") .then(response => response.json()) .then(function (startupConfigResult) { @@ -74,6 +85,10 @@ export function readConsentState() { }); } +/** + * @function gets the consent document from storage + * @returns Promise for the consent document or null if the doc is empty + */ //used in ProfileSettings export function getConsentDocument() { return window['cordova'].plugins.BEMUserCache.getDocument("config/consent", false) @@ -86,6 +101,10 @@ export function getConsentDocument() { }); }; +/** + * @function checks the consent doc in native storage + * @returns if doc not stored in native, a promise to write it there + */ function checkNativeConsent() { getConsentDocument().then(function (resultDoc) { if (resultDoc == null) { From d4a29b1f1e8b23d67b00af39eb2708867c8d661a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 20 Oct 2023 10:16:02 -0600 Subject: [PATCH 093/135] first test for startprefs after adding more mocked functions, the startprefs now have one test, for getting the consent doc from storage --- www/__mocks__/cordovaMocks.ts | 20 ++++++++++++++++ www/__tests__/startprefs.test.ts | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 www/__tests__/startprefs.test.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 4a9189ecd..424d4f424 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -87,6 +87,26 @@ export const mockBEMUserCache = () => { rs(messages.filter(m => m.key == key).map(m => m.value)); }, 100) ); + }, + getDocument: (key: string, withMetadata?: boolean) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(_cache[key]); + }, 100) + ); + }, + markConsented: (consentDoc) => { + _cache['config/consent'] = consentDoc; + }, + isEmptyDoc: (doc) => { + console.log(doc); + if (doc == undefined) { return true } + let string = doc.toString(); + if (string.length == 0) { + return true; + } else { + return false; + } } } window['cordova'] ||= {}; diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts new file mode 100644 index 000000000..765637dc2 --- /dev/null +++ b/www/__tests__/startprefs.test.ts @@ -0,0 +1,39 @@ +import { markConsented, isConsented, readConsentState, getConsentDocument } from '../js/splash/startprefs'; + +import { mockBEMUserCache } from "../__mocks__/cordovaMocks"; +import { mockLogger } from "../__mocks__/globalMocks"; + +mockBEMUserCache(); +mockLogger(); + +global.fetch = (url: string) => new Promise((rs, rj) => { + setTimeout(() => rs({ + json: () => new Promise((rs, rj) => { + let jsonString = '{ "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }'; + setTimeout(() => rs(jsonString), 100); + }) + })); +}) as any; + +it('marks consent in local and native storage', async () => { + +}); + +it('checks local vars for consent, returns boolean', async () => { + +}); + +it('reads the required and current consent', async () => { + //gets the info from the json file + //then sets the local req_consent variable + //then storageGets the data_collection_consented_protocol + //then uses that to store in local curr_consent var + //and launches checkNativeConsent + //let consentFile = await readConsentState(); + //expect(consentFile).toBeUndefined(); +}); + +it('gets the consent document from storage', async () => { + let consentDoc = await getConsentDocument(); + expect(consentDoc).toBeNull(); +}); \ No newline at end of file From 40f6b93a7ca29b99383dcde84f977120f1a9e714 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 20 Oct 2023 10:49:23 -0600 Subject: [PATCH 094/135] test markConsented this test needed a mock of markConsented in the BEMDataCollection plugin, I have added _storage so that when the document is written in, it can also be retrieved -- even though that happens in two different plugins --- www/__mocks__/cordovaMocks.ts | 19 ++++++++++++++----- www/__tests__/startprefs.test.ts | 16 +++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 424d4f424..3e66cb163 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -24,6 +24,9 @@ export const mockGetAppVersion = () => { window['cordova'].getAppVersion = mockGetAppVersion; } +//for consent document +const _storage = {}; + export const mockBEMUserCache = () => { const _cache = {}; const messages = []; @@ -91,15 +94,11 @@ export const mockBEMUserCache = () => { getDocument: (key: string, withMetadata?: boolean) => { return new Promise((rs, rj) => setTimeout(() => { - rs(_cache[key]); + rs(_storage[key]); }, 100) ); }, - markConsented: (consentDoc) => { - _cache['config/consent'] = consentDoc; - }, isEmptyDoc: (doc) => { - console.log(doc); if (doc == undefined) { return true } let string = doc.toString(); if (string.length == 0) { @@ -113,3 +112,13 @@ export const mockBEMUserCache = () => { window['cordova'].plugins ||= {}; window['cordova'].plugins.BEMUserCache = mockBEMUserCache; } + +export const mockBEMDataCollection = () => { + const mockBEMDataCollection = { + markConsented: (consentDoc) => { + _storage['config/consent'] = consentDoc; + } + } + window['cordova'] ||= {}; + window['cordova'].plugins.BEMDataCollection = mockBEMDataCollection; +} diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts index 765637dc2..994cfb284 100644 --- a/www/__tests__/startprefs.test.ts +++ b/www/__tests__/startprefs.test.ts @@ -1,9 +1,10 @@ import { markConsented, isConsented, readConsentState, getConsentDocument } from '../js/splash/startprefs'; -import { mockBEMUserCache } from "../__mocks__/cordovaMocks"; +import { mockBEMUserCache, mockBEMDataCollection } from "../__mocks__/cordovaMocks"; import { mockLogger } from "../__mocks__/globalMocks"; mockBEMUserCache(); +mockBEMDataCollection(); mockLogger(); global.fetch = (url: string) => new Promise((rs, rj) => { @@ -16,11 +17,12 @@ global.fetch = (url: string) => new Promise((rs, rj) => { }) as any; it('marks consent in local and native storage', async () => { - + let marked = markConsented(); + console.log("marked is", marked); }); it('checks local vars for consent, returns boolean', async () => { - + expect(isConsented()).toBeFalsy(); }); it('reads the required and current consent', async () => { @@ -29,11 +31,11 @@ it('reads the required and current consent', async () => { //then storageGets the data_collection_consented_protocol //then uses that to store in local curr_consent var //and launches checkNativeConsent - //let consentFile = await readConsentState(); - //expect(consentFile).toBeUndefined(); + // let consentFile = await readConsentState(); + expect(await readConsentState()).toBeUndefined(); }); it('gets the consent document from storage', async () => { - let consentDoc = await getConsentDocument(); - expect(consentDoc).toBeNull(); +// let consentDoc = await getConsentDocument(); + expect(await getConsentDocument()).toBeNull(); }); \ No newline at end of file From 9849fa21530c5f13ce6042e1776f7b27c23daaca Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 20 Oct 2023 12:09:16 -0600 Subject: [PATCH 095/135] test all functions in sequence since testing separately affects the local storage, testing in sequence allows for easier testing. return json from fetch rather than string to make it parseable in storage, had to check for the string undefined, since that was returned in one of the tests --- www/__tests__/startprefs.test.ts | 31 +++++++------------------------ www/js/plugin/storage.ts | 2 +- www/js/splash/startprefs.ts | 3 ++- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts index 994cfb284..1e62e7b5e 100644 --- a/www/__tests__/startprefs.test.ts +++ b/www/__tests__/startprefs.test.ts @@ -10,32 +10,15 @@ mockLogger(); global.fetch = (url: string) => new Promise((rs, rj) => { setTimeout(() => rs({ json: () => new Promise((rs, rj) => { - let jsonString = '{ "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }'; - setTimeout(() => rs(jsonString), 100); + let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }; + setTimeout(() => rs(myJSON), 100); }) })); }) as any; -it('marks consent in local and native storage', async () => { - let marked = markConsented(); - console.log("marked is", marked); -}); - -it('checks local vars for consent, returns boolean', async () => { - expect(isConsented()).toBeFalsy(); -}); - -it('reads the required and current consent', async () => { - //gets the info from the json file - //then sets the local req_consent variable - //then storageGets the data_collection_consented_protocol - //then uses that to store in local curr_consent var - //and launches checkNativeConsent - // let consentFile = await readConsentState(); - expect(await readConsentState()).toBeUndefined(); -}); - -it('gets the consent document from storage', async () => { -// let consentDoc = await getConsentDocument(); - expect(await getConsentDocument()).toBeNull(); +it('checks state of consent before and after marking consent', async () => { + expect(await readConsentState().then(isConsented)).toBeFalsy(); + let marked = await markConsented(); + expect(await readConsentState().then(isConsented)).toBeTruthy(); + expect(await getConsentDocument()).toEqual({"approval_date": "2016-07-14", "protocol_id": "2014-04-6267"}); }); \ No newline at end of file diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 59e535b6e..e649d504d 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -30,7 +30,7 @@ const localStorageSet = (key: string, value: {[k: string]: any}) => { const localStorageGet = (key: string) => { const value = localStorage.getItem(key); - if (value) { + if (value && value != "undefined") { return JSON.parse(value); } else { return null; diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index f8626b9e8..2acda39b1 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -53,6 +53,7 @@ let _is_consented; * @returns {boolean} if the consent is marked in the local var */ export function isConsented() { + console.log("curr consented is", _curr_consented); if (_curr_consented == null || _curr_consented == "" || _curr_consented.approval_date != _req_consent.approval_date) { console.log("Not consented in local storage, need to show consent"); @@ -67,7 +68,7 @@ export function isConsented() { /** * @function reads the consent state from the file and populates it - * @returns Promise for the stored consent file + * @returns nothing, just reads into local variables */ export function readConsentState() { return fetch("json/startupConfig.json") From 2c6739a185c109fb24d70ea20ff57236dfa80c4d Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Fri, 20 Oct 2023 12:41:24 -0600 Subject: [PATCH 096/135] Remove translations from label-options.json.sample Since the default translations are in 18next files, we don't need a duplicate of the default label options translations in this file Also changed the logic because I didn't have it right when labelOptions.translations is undefined --- www/js/survey/multilabel/confirmHelper.ts | 4 +- www/json/label-options.json.sample | 72 +---------------------- 2 files changed, 3 insertions(+), 73 deletions(-) diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index cb94c1f2e..b668669bf 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -50,8 +50,8 @@ export async function getLabelOptions(appConfigParam?) { for (const opt in labelOptions) { labelOptions[opt]?.forEach?.((o, i) => { const translationKey = o.value; - // If translation exists in labelOptions, use that. Otherwise, use the one in the i18next. - const translation = labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`); + // If translation exists in labelOptions, use that. Otherwise, use the one in the i18next. If there is not "translations" field in labelOptions, defaultly use the one in the i18next. + const translation = labelOptions.translations ? labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`) : i18next.t(`multilabel.${translationKey}`); labelOptions[opt][i].text = translation; }); } diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index 0f4292c7f..9870b744f 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -50,75 +50,5 @@ {"value":"train"}, {"value":"free_shuttle"}, {"value":"other"} - ], - "translations": { - "en": { - "walk": "Walk", - "e-bike": "E-bike", - "bike": "Regular Bike", - "bikeshare": "Bikeshare", - "scootershare": "Scooter share", - "drove_alone": "Gas Car Drove Alone", - "shared_ride": "Gas Car Shared Ride", - "e_car_drove_alone": "E-Car Drove Alone", - "e_car_shared_ride": "E-Car Shared Ride", - "moped": "Moped", - "taxi": "Taxi / Uber / Lyft", - "bus": "Bus", - "train": "Train", - "free_shuttle": "Free Shuttle", - "air": "Air", - "not_a_trip": "Not a trip", - "no_travel": "No travel", - "home": "Home", - "work": "To Work", - "at_work": "At Work", - "school": "School", - "transit_transfer": "Transit transfer", - "shopping": "Shopping", - "meal": "Meal", - "pick_drop_person": "Pick-up / Drop off Person", - "pick_drop_item": "Pick-up / Drop off Item", - "personal_med": "Personal / Medical", - "access_recreation": "Access Recreation", - "exercise": "Recreation / Exercise", - "entertainment": "Entertainment / Social", - "religious": "Religious", - "other": "Other" - }, - "es": { - "walk": "Caminando", - "e-bike": "e-bicicleta", - "bike": "Bicicleta", - "bikeshare": "Bicicleta compartida", - "scootershare": "Motoneta compartida", - "drove_alone": "Coche de Gas, Condujo solo", - "shared_ride": "Coche de Gas, Condujo con otros", - "e_car_drove_alone": "e-coche, Condujo solo", - "e_car_shared_ride": "e-coche, Condujo con ontras", - "moped": "Ciclomotor", - "taxi": "Taxi / Uber / Lyft", - "bus": "Autobús", - "train": "Tren", - "free_shuttle": "Colectivo gratuito", - "air": "Avión", - "not_a_trip": "No es un viaje", - "no_travel": "No viajar", - "home": "Inicio", - "work": "Trabajo", - "at_work": "En el trabajo", - "school": "Escuela", - "transit_transfer": "Transbordo", - "shopping": "Compras", - "meal": "Comida", - "pick_drop_person": "Recoger / Entregar Individuo", - "pick_drop_item": "Recoger / Entregar Objeto", - "personal_med": "Personal / Médico", - "access_recreation": "Acceder a Recreación", - "exercise": "Recreación / Ejercicio", - "entertainment": "Entretenimiento / Social", - "religious": "Religioso", - "other": "Otros" - } - } + ] } \ No newline at end of file From 8ab5bf1099c40a3fd6d09715350dc595bac3c782 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 20 Oct 2023 13:56:58 -0600 Subject: [PATCH 097/135] add timeout for consistency, since this is also a function that is async --- www/__mocks__/cordovaMocks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 3e66cb163..2ac50e229 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -116,7 +116,9 @@ export const mockBEMUserCache = () => { export const mockBEMDataCollection = () => { const mockBEMDataCollection = { markConsented: (consentDoc) => { - _storage['config/consent'] = consentDoc; + setTimeout(() => { + _storage['config/consent'] = consentDoc; + }, 100) } } window['cordova'] ||= {}; From f2f0cfb94dd8675635b622627aec73ca31941b47 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Fri, 20 Oct 2023 16:17:45 -0700 Subject: [PATCH 098/135] FETCH -> GET to match HTTP request types --- www/__tests__/uploadService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index 6aea5805d..5c64fae0e 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -27,7 +27,7 @@ global.fetch = (url: string, options: { method: string, headers: {}, body: strin rs('sent ' + options.method + options.body + ' to ' + url); }, 100); } - //else it is a fetch request + //else it is a get request else { setTimeout(() => rs({ json: () => new Promise((rs, rj) => { From aebb1c33aec2ac370507bf496dd3c71d2f51e5a2 Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:23:26 -0700 Subject: [PATCH 099/135] Build the release version of android when creating the build to upload --- bin/sign_and_align_keys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/sign_and_align_keys.sh b/bin/sign_and_align_keys.sh index 261058bd5..74c04c020 100644 --- a/bin/sign_and_align_keys.sh +++ b/bin/sign_and_align_keys.sh @@ -10,7 +10,7 @@ fi # Sign and release the L+ version # Make sure the highest supported version has the biggest version code -npm run build-prod-android +npm run build-prod-android-release # cp platforms/android/app/build/outputs/apk/release/app-release-unsigned.aab platforms/android/app/build/outputs/apk/app-release-signed-unaligned.apk jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore ../config_files/production.keystore ./platforms/android/app/build/outputs/bundle/release/app-release.aab androidproductionkey cp platforms/android/app/build/outputs/bundle/release/app-release.aab $1-build-$2.aab From 284c47c4dfd0e1a2fe7a97d0b15175fedfc2397b Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:24:12 -0700 Subject: [PATCH 100/135] Improve error message --- bin/sign_and_align_keys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/sign_and_align_keys.sh b/bin/sign_and_align_keys.sh index 74c04c020..9b60c3ade 100644 --- a/bin/sign_and_align_keys.sh +++ b/bin/sign_and_align_keys.sh @@ -4,7 +4,7 @@ PROJECT=$1 VERSION=$2 if [[ $# -eq 0 ]]; then - echo "No arguments supplied" + echo "sign_and_align_keys " exit 1 fi From a26698fb044c839ab9d83541609780535fe9d7d7 Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:33:30 -0700 Subject: [PATCH 101/135] Run the CI on UI feature branches as well So that we can check that the tests are passing before we merge --- .github/workflows/serve-install.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/serve-install.yml b/.github/workflows/serve-install.yml index a5e634821..d180a9384 100644 --- a/.github/workflows/serve-install.yml +++ b/.github/workflows/serve-install.yml @@ -9,10 +9,12 @@ on: branches: - master - maint_upgrade_** + - ui_feature_** pull_request: branches: - master - maint_upgrade_** + - ui_feature_** schedule: # * is a special character in YAML so you have to quote this string - cron: '5 4 * * 0' From 34cd182fefa5e89d0596d01a1ed7109bf4e5f9db Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:53:52 -0700 Subject: [PATCH 102/135] Temporarily add `service_rewrite_2023` to the list of branches that we run the CI on To avoid having to make a new branch and making everybody switch --- .github/workflows/serve-install.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/serve-install.yml b/.github/workflows/serve-install.yml index d180a9384..51852ed71 100644 --- a/.github/workflows/serve-install.yml +++ b/.github/workflows/serve-install.yml @@ -15,6 +15,7 @@ on: - master - maint_upgrade_** - ui_feature_** + - service_rewrite_2023 schedule: # * is a special character in YAML so you have to quote this string - cron: '5 4 * * 0' From 07b7d20ba7bbd385cc0c74600b72928234158146 Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:57:21 -0700 Subject: [PATCH 103/135] Replace tabs with spaces --- .github/workflows/serve-install.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/serve-install.yml b/.github/workflows/serve-install.yml index 51852ed71..c78ce1f86 100644 --- a/.github/workflows/serve-install.yml +++ b/.github/workflows/serve-install.yml @@ -9,13 +9,13 @@ on: branches: - master - maint_upgrade_** - - ui_feature_** + - ui_feature_** pull_request: branches: - master - maint_upgrade_** - - ui_feature_** - - service_rewrite_2023 + - ui_feature_** + - service_rewrite_2023 schedule: # * is a special character in YAML so you have to quote this string - cron: '5 4 * * 0' From d54731fdef2ed1c691eaaf71bd0e713f3e363dc2 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Sat, 21 Oct 2023 16:22:41 -0600 Subject: [PATCH 104/135] add hybrid car added hybrid car to label-options file and to en i18n --- www/i18n/en.json | 2 ++ www/json/label-options.json.sample | 2 ++ 2 files changed, 4 insertions(+) diff --git a/www/i18n/en.json b/www/i18n/en.json index 9217339f7..a3f4642f3 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -154,6 +154,8 @@ "scootershare": "Scooter share", "drove_alone": "Gas Car Drove Alone", "shared_ride": "Gas Car Shared Ride", + "hybrid_drove_alone": "Hybrid Drove Alone", + "hybrid_shared_ride": "Hybrid Shared Ride", "e_car_drove_alone": "E-Car Drove Alone", "e_car_shared_ride": "E-Car Shared Ride", "moped": "Moped", diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index 9870b744f..bd428628c 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -7,6 +7,8 @@ {"value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.00894}, {"value":"drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.22031}, {"value":"shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.11015}, + {"value":"hybrid_drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.138}, + {"value":"hybrid_shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.069}, {"value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.08216}, {"value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.04108}, {"value":"moped", "baseMode":"MOPED", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.05555}, From 6da202ce5e13b186c24cd128a80532478a0f566b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Sat, 21 Oct 2023 17:29:58 -0600 Subject: [PATCH 105/135] add timeout to med accuracy medium accuracy sometimes alters the tracking settings as well, this timeout resolves that --- www/js/control/ProfileSettings.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 0b00efc75..993c43814 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -261,9 +261,11 @@ const ProfileSettings = () => { refreshCollectSettings(); } - async function toggleLowAccuracy() { + async function toggleLowAccuracy() { let toggle = await helperToggleLowAccuracy(); - refreshCollectSettings(); + setTimeout(function() { + refreshCollectSettings(); + }, 1500); } const viewQRCode = function(e) { From 5386a2dc651079f4e0e1139a57531d7b4cb76341 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Sat, 21 Oct 2023 20:50:02 -0600 Subject: [PATCH 106/135] update variable name the prior variable name was inconsistent with the rest of the file, and therefore created confusion --- www/js/control/ProfileSettings.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 993c43814..7cf22a154 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -60,7 +60,7 @@ const ProfileSettings = () => { const [showingSensed, setShowingSensed] = useState(false); const [showingLog, setShowingLog] = useState(false); const [editSync, setEditSync] = useState(false); - const [editCollection, setEditCollection] = useState(false); + const [editCollectionVis, setEditCollectionVis] = useState(false); // const [collectConfig, setCollectConfig] = useState({}); const [collectSettings, setCollectSettings] = useState({}); @@ -156,13 +156,13 @@ const ProfileSettings = () => { //ensure ui table updated when editor closes useEffect(() => { - if(editCollection == false) { + if(editCollectionVis == false) { setTimeout(function() { console.log("closed editor, time to refresh collect"); refreshCollectSettings(); }, 1000); } - }, [editCollection]) + }, [editCollectionVis]) async function refreshNotificationSettings() { console.debug('about to refreshNotificationSettings, notificationSettings = ', notificationSettings); @@ -496,7 +496,7 @@ const ProfileSettings = () => { - + ); From a9d98ed20e3ecda235e81b2f80f81e91826c9b6d Mon Sep 17 00:00:00 2001 From: louisg1337 Date: Sun, 22 Oct 2023 22:03:50 -0400 Subject: [PATCH 107/135] Bumped up version for new location permissions release --- package.cordovabuild.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 943f06520..25145b5ed 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -126,7 +126,7 @@ "cordova-plugin-app-version": "0.1.14", "cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-device": "2.1.0", - "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.7.9", + "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.8.1", "cordova-plugin-em-opcodeauth": "git+https://github.com/e-mission/cordova-jwt-auth.git#v1.7.2", "cordova-plugin-em-server-communication": "git+https://github.com/e-mission/cordova-server-communication.git#v1.2.6", "cordova-plugin-em-serversync": "git+https://github.com/e-mission/cordova-server-sync.git#v1.3.2", From 60caee5ab2a7a598110e79ef2f294d7e66868b8c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 23 Oct 2023 09:06:47 -0600 Subject: [PATCH 108/135] remove "protocol id" since we no longer work with the IRB protocol within the app, we don't need to present it to the user --- www/i18n/en.json | 2 +- www/js/control/ProfileSettings.jsx | 2 +- www/json/startupConfig.json | 1 - www/json/startupConfig.json.sample | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 9217339f7..ed4ea2d2e 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -66,7 +66,7 @@ "consent-not-found": "Consent for data collection not found, consent now?", "no-consent-message": "OK! Note that you won't get any personalized stats until you do!", "consent-found": "Consent found!", - "consented-to": "Consented to protocol {{protocol_id}}, {{approval_date}}", + "consented-to": "Consented on {{approval_date}}", "consented-ok": "OK", "qrcode": "My OPcode", "qrcode-share-title": "You can save your OPcode to login easily in the future!" diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index de5469519..32e4c2a8d 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -496,7 +496,7 @@ const ProfileSettings = () => { setConsentVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.consented-to', {protocol_id: consentDoc.protocol_id, approval_date: consentDoc.approval_date})} + {t('general-settings.consented-to', {approval_date: consentDoc.approval_date})} - From 6dc7e4f674e7f55fdceb0f32f676201ad62fbb30 Mon Sep 17 00:00:00 2001 From: Abby Wheelis <54848919+Abby-Wheelis@users.noreply.github.com> Date: Mon, 23 Oct 2023 10:52:06 -0600 Subject: [PATCH 110/135] update hybrid kgCo2PerKm https://github.com/e-mission/e-mission-docs/issues/1013#issuecomment-1773461173 --- www/json/label-options.json.sample | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index bd428628c..a2b49258c 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -7,8 +7,8 @@ {"value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.00894}, {"value":"drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.22031}, {"value":"shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.11015}, - {"value":"hybrid_drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.138}, - {"value":"hybrid_shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.069}, + {"value":"hybrid_drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.127}, + {"value":"hybrid_shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.0635}, {"value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.08216}, {"value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.04108}, {"value":"moped", "baseMode":"MOPED", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.05555}, @@ -53,4 +53,4 @@ {"value":"free_shuttle"}, {"value":"other"} ] -} \ No newline at end of file +} From ea49921bb3e473efdc5a62fb25d3f7a21890d056 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 24 Oct 2023 14:10:51 -0600 Subject: [PATCH 111/135] add more url checks we need to make sure that nobody scans a random qr code and gets into the app with an invalid token, we can help prevent that by checking that the qr code contains the right elements --- www/js/onboarding/WelcomePage.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index 3589923c8..a6df7f2ee 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -20,12 +20,25 @@ const WelcomePage = () => { const [infoPopupVis, setInfoPopupVis] = useState(false); const [existingToken, setExistingToken] = useState(''); + const checkURL = function (result) { + let notCancelled = result.cancelled == false; + let isQR = result.format == "QR_CODE"; + let hasPrefix = false; + if (__DEV__) { + hasPrefix = result.text.startsWith("emission"); + } else { + hasPrefix = result.text.startsWith("nrelopenpath"); + } + let hasToken = result.text.includes("login_token?token"); + + return notCancelled && isQR && hasPrefix && hasToken; + } + const scanCode = function() { - window.cordova.plugins.barcodeScanner.scan( + window['cordova'].plugins.barcodeScanner.scan( function (result) { console.debug("scanned code", result); - if (result.format == "QR_CODE" && - result.cancelled == false) { + if (checkURL(result)) { let text = result.text.split("=")[1]; console.log("found code", text); loginWithToken(text); From 5da390968328cd493c46ba10c67b30fb703f7ff4 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 24 Oct 2023 17:21:03 -0600 Subject: [PATCH 112/135] updates to check code added a log statement, and verifying that the first part of the opcode is "nrelopenpath" or "emission" -- the staging opcodes start with "emission", but sometimes we use production opcodes to test things in develpoment --- www/js/onboarding/WelcomePage.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index a6df7f2ee..cd3734059 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -5,7 +5,7 @@ import { Button, Dialog, Divider, IconButton, Surface, Text, TextInput, Touchabl import color from 'color'; import { initByUser } from '../config/dynamicConfig'; import { AppContext } from '../App'; -import { displayError } from "../plugin/logger"; +import { displayError, logDebug } from "../plugin/logger"; import { onboardingStyles } from './OnboardingStack'; import { Icon } from '../components/Icon'; @@ -23,14 +23,11 @@ const WelcomePage = () => { const checkURL = function (result) { let notCancelled = result.cancelled == false; let isQR = result.format == "QR_CODE"; - let hasPrefix = false; - if (__DEV__) { - hasPrefix = result.text.startsWith("emission"); - } else { - hasPrefix = result.text.startsWith("nrelopenpath"); - } + let hasPrefix = result.text.split(":")[0] == "nrelopenpath" || result.text.split(":")[0] == "emission"; let hasToken = result.text.includes("login_token?token"); + logDebug("QR code " + result.text + " checks: cancel, format, prefix, params " + notCancelled + isQR + hasPrefix + hasToken); + return notCancelled && isQR && hasPrefix && hasToken; } From 636051936971075d7b5179e5919ad7b234866b72 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 24 Oct 2023 17:51:28 -0600 Subject: [PATCH 113/135] Update QrCode.tsx make sure qr code is made with the whole url link --- www/js/components/QrCode.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/www/js/components/QrCode.tsx b/www/js/components/QrCode.tsx index edd120c22..0499b4c46 100644 --- a/www/js/components/QrCode.tsx +++ b/www/js/components/QrCode.tsx @@ -35,6 +35,11 @@ export function shareQR(message) { } const QrCode = ({ value, ...rest }) => { + let hasLink = value.toString().includes("//"); + if(!hasLink) { + value = "nrelopenpath://login_token?token=" + value; + } + return ; }; From 2d930f881041211c59c34011b815b58b03370f3e Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Tue, 24 Oct 2023 20:00:06 -0700 Subject: [PATCH 114/135] Use only `emission` externally Long term, this should be part of the app config https://github.com/e-mission/e-mission-docs/issues/985#issuecomment-1769790309 --- www/js/components/QrCode.tsx | 2 +- www/js/onboarding/WelcomePage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/components/QrCode.tsx b/www/js/components/QrCode.tsx index 0499b4c46..74c66863f 100644 --- a/www/js/components/QrCode.tsx +++ b/www/js/components/QrCode.tsx @@ -37,7 +37,7 @@ export function shareQR(message) { const QrCode = ({ value, ...rest }) => { let hasLink = value.toString().includes("//"); if(!hasLink) { - value = "nrelopenpath://login_token?token=" + value; + value = "emission://login_token?token=" + value; } return ; diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index cd3734059..5653218d7 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -23,7 +23,7 @@ const WelcomePage = () => { const checkURL = function (result) { let notCancelled = result.cancelled == false; let isQR = result.format == "QR_CODE"; - let hasPrefix = result.text.split(":")[0] == "nrelopenpath" || result.text.split(":")[0] == "emission"; + let hasPrefix = result.text.split(":")[0] == "emission"; let hasToken = result.text.includes("login_token?token"); logDebug("QR code " + result.text + " checks: cancel, format, prefix, params " + notCancelled + isQR + hasPrefix + hasToken); From 29fac8a5182286b25d0d95dfcb27c8ba22007e9e Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 09:04:54 -0600 Subject: [PATCH 115/135] fix naming error when I updated the name of this visibility state, I did not update it's setter. This caused the popup to fail to launch --- www/js/control/ProfileSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 7cf22a154..e79a95d8d 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -42,7 +42,7 @@ const ProfileSettings = () => { const StartPrefs = getAngularService('StartPrefs'); //functions that come directly from an Angular service - const editCollectionConfig = () => setEditCollection(true); + const editCollectionConfig = () => setEditCollectionVis(true); const editSyncConfig = () => setEditSync(true); //states and variables used to control/create the settings From 4f58aa8f9efeb6fab4a4435a5d980fc3a46d7b0a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 14:12:32 -0600 Subject: [PATCH 116/135] re-work methods for checking code In a cleanup pass, we're working towards using URL methods rather than the "hacky" parts that I had before https://developer.mozilla.org/en-US/docs/Web/API/URL Also re-work the method to pull out the token from the url components and return the code if it's good, or false if the url is bad --- www/js/onboarding/WelcomePage.tsx | 34 ++++++++++++++++++------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index 5653218d7..cb317c5bc 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -20,28 +20,34 @@ const WelcomePage = () => { const [infoPopupVis, setInfoPopupVis] = useState(false); const [existingToken, setExistingToken] = useState(''); - const checkURL = function (result) { + const getCode = function (result) { + let url = new window.URL(result.text); let notCancelled = result.cancelled == false; let isQR = result.format == "QR_CODE"; - let hasPrefix = result.text.split(":")[0] == "emission"; - let hasToken = result.text.includes("login_token?token"); + let hasPrefix = url.protocol == "emission:"; + let hasToken = url.searchParams.has("token"); + let code = url.searchParams.get("token"); - logDebug("QR code " + result.text + " checks: cancel, format, prefix, params " + notCancelled + isQR + hasPrefix + hasToken); + logDebug("QR code " + result.text + " checks: cancel, format, prefix, params, code " + notCancelled + isQR + hasPrefix + hasToken + code); - return notCancelled && isQR && hasPrefix && hasToken; - } + if (notCancelled && isQR && hasPrefix && hasToken) { + return code; + } else { + return false; + } + }; - const scanCode = function() { + const scanCode = function () { window['cordova'].plugins.barcodeScanner.scan( function (result) { console.debug("scanned code", result); - if (checkURL(result)) { - let text = result.text.split("=")[1]; - console.log("found code", text); - loginWithToken(text); - } else { - displayError(result.text, "invalid study reference") ; - } + let code = getCode(result); + if (code != false) { + console.log("found code", code); + loginWithToken(code); + } else { + displayError(result.text, "invalid study reference"); + } }, function (error) { displayError(error, "Scanning failed: "); From 313074123bf6c9e5b0470ef210eece70ebd97c41 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 15:42:06 -0600 Subject: [PATCH 117/135] remove unneeded type declaration https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1372324523 --- www/js/splash/startprefs.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 2acda39b1..d085289ff 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,12 +1,7 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; -type StartPrefs = { - CONSENTED_EVENT: string, - INTRO_DONE_EVENT: string, -} - -export const startPrefs: StartPrefs = { +export const startPrefs = { CONSENTED_EVENT: "data_collection_consented", INTRO_DONE_EVENT: "intro_done", }; From 39aa2f254c7d3e6b928887ee9ed9e47e5aea88ff Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 16:32:57 -0600 Subject: [PATCH 118/135] move "after consent" saveQR was a bad place to handle this, moving to markConsented As Jack pointed out, on a reconsent (mark consent after intro done) we want to call registerPush and storeDeviceSettings - that is what is done here centralizing the logic to markConsented eliminated the need for the functions in the other files, since we check for consent done just once --- www/js/onboarding/SaveQrPage.tsx | 7 ------- www/js/splash/pushnotify.js | 11 ----------- www/js/splash/startprefs.ts | 29 +++++++++++++++++++++------- www/js/splash/storedevicesettings.js | 10 ---------- 4 files changed, 22 insertions(+), 35 deletions(-) diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 3c5c0b954..3bfc93bb4 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -31,13 +31,6 @@ const SaveQrPage = ({ }) => { setRegisterUserDone(true); preloadDemoSurveyResponse(); refreshOnboardingState(); - - //fully consented, so can handle other aspects - //other plugins - previously used $emit - const PushNotify = getAngularService("PushNotify"); - PushNotify.afterConsent(); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - StoreSeviceSettings.afterConsent(); }) ); } else { diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 0a5f9f18e..06bf433f8 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -173,17 +173,6 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', Logger.log("pushnotify startup done"); }); - //new way of handling this, called in startprefs by markConsent - pushnotify.afterConsent = function () { - console.log("in pushnotify, executing after consent is received"); - readIntroDone().then((intro_done) => { - if (intro_done) { - console.log("intro is done -> reconsent situation, we already have a token -> register"); - pushnotify.registerPush(); - } - }) - } - $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); pushnotify.registerPush(); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index d085289ff..ec3bd9a14 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,5 +1,7 @@ +import { getAngularService } from "../angular-react-helper"; import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; +import { readIntroDone } from "../onboarding/onboardingHelper"; export const startPrefs = { CONSENTED_EVENT: "data_collection_consented", @@ -32,13 +34,26 @@ export function markConsented() { logInfo("changing consent from " + _curr_consented + " -> " + JSON.stringify(_req_consent)); // mark in native storage - return readConsentState().then(writeConsentToNative).then(function (response) { - // mark in local storage - storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, - _req_consent); - // mark in local variable as well - _curr_consented = {..._req_consent}; - }); + return readConsentState() + .then(writeConsentToNative) + .then(function (response) { + // mark in local storage + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, + _req_consent); + // mark in local variable as well + _curr_consented = { ..._req_consent }; + }) + //check for reconsent + .then(readIntroDone) + .then((isIntroDone) => { + if(isIntroDone) { + console.debug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") + const PushNotify = getAngularService("PushNotify"); + const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + PushNotify.registerPush(); + StoreSeviceSettings.storeDeviceSettings(); + } + }); }; let _is_consented; diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index f9ff0e00c..a53f7b76e 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -45,16 +45,6 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', Logger.log("storedevicesettings startup done"); }); - storedevicesettings.afterConsent = function() { - console.log("in storedevicesettings, executing after consent is received"); - readIntroDone().then((intro_done) => { - if (intro_done) { - console.log("intro is done -> reconsent situation, we already have a token -> register"); - storedevicesettings.storeDeviceSettings(); - } - }) - } - $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); storedevicesettings.storeDeviceSettings(); From 657ae34b1c65cb7ae204b81038bb11ea2056cfe0 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 16:37:40 -0600 Subject: [PATCH 119/135] rework "intro done event" We no longer emit this event, but still need to handle the logic On marking intro done, we will registerPush and storeDeviceSettings https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1372339180 --- www/js/onboarding/onboardingHelper.ts | 11 ++++++++++- www/js/splash/pushnotify.js | 5 ----- www/js/splash/storedevicesettings.js | 5 ----- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 9bca0fdd5..b73810ee5 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -3,6 +3,7 @@ import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; import { storageGet, storageSet } from "../plugin/storage"; import { logDebug } from "../plugin/logger"; import { readConsentState, isConsented } from "../splash/startprefs"; +import { getAngularService } from "../angular-react-helper"; export const INTRO_DONE_KEY = 'intro_done'; @@ -70,5 +71,13 @@ export async function readIntroDone() { export async function markIntroDone() { const currDateTime = DateTime.now().toISO(); - return storageSet(INTRO_DONE_KEY, currDateTime); + return storageSet(INTRO_DONE_KEY, currDateTime) + .then(() => { + //handle "on intro" events + console.log("intro done, calling registerPush and storeDeviceSettings"); + const PushNotify = getAngularService("PushNotify"); + const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + PushNotify.registerPush(); + StoreSeviceSettings.storeDeviceSettings(); + }); } diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 06bf433f8..dd31811ab 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -173,10 +173,5 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', Logger.log("pushnotify startup done"); }); - $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { - console.log("intro is done -> original consent situation, we should have a token by now -> register"); - pushnotify.registerPush(); - }); - return pushnotify; }); diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index a53f7b76e..c32083295 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -45,10 +45,5 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', Logger.log("storedevicesettings startup done"); }); - $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { - console.log("intro is done -> original consent situation, we should have a token by now -> register"); - storedevicesettings.storeDeviceSettings(); - }); - return storedevicesettings; }); From a7a75d1dde7f5621074a514b9ec9c0a71235e732 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 16:38:06 -0600 Subject: [PATCH 120/135] remove unneeded object --- www/js/splash/startprefs.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index ec3bd9a14..277946629 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -3,11 +3,6 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; import { readIntroDone } from "../onboarding/onboardingHelper"; -export const startPrefs = { - CONSENTED_EVENT: "data_collection_consented", - INTRO_DONE_EVENT: "intro_done", -}; - // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB const DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; From c54f939f467ca43c3a2fc45796dd3bf5e2cc9f4b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 27 Oct 2023 08:51:40 -0600 Subject: [PATCH 121/135] console.log -> logDebug There were several places where I had other log statements, but logDebug was more appropriate, see review for details https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373749123 --- www/js/control/ProfileSettings.jsx | 3 ++- www/js/onboarding/onboardingHelper.ts | 2 +- www/js/splash/startprefs.ts | 10 +++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 8c7510618..cecc4f84c 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -26,6 +26,7 @@ import { shareQR } from "../components/QrCode"; import { storageClear } from "../plugin/storage"; import { getAppVersion } from "../plugin/clientStats"; import { getConsentDocument } from "../splash/startprefs"; +import { logDebug } from "../plugin/logger"; //any pure functions can go outside const ProfileSettings = () => { @@ -309,7 +310,7 @@ const ProfileSettings = () => { async function checkConsent() { getConsentDocument().then(function(resultDoc){ setConsentDoc(resultDoc); - console.debug("In profile settings, consent doc found", resultDoc); + logDebug("In profile settings, consent doc found", resultDoc); if (resultDoc == null) { setNoConsentVis(true); } else { diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index b73810ee5..4110c2394 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -74,7 +74,7 @@ export async function markIntroDone() { return storageSet(INTRO_DONE_KEY, currDateTime) .then(() => { //handle "on intro" events - console.log("intro done, calling registerPush and storeDeviceSettings"); + logDebug("intro done, calling registerPush and storeDeviceSettings"); const PushNotify = getAngularService("PushNotify"); const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); PushNotify.registerPush(); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 277946629..8a0b28415 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -42,7 +42,7 @@ export function markConsented() { .then(readIntroDone) .then((isIntroDone) => { if(isIntroDone) { - console.debug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") + logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") const PushNotify = getAngularService("PushNotify"); const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); PushNotify.registerPush(); @@ -58,14 +58,14 @@ let _is_consented; * @returns {boolean} if the consent is marked in the local var */ export function isConsented() { - console.log("curr consented is", _curr_consented); + logDebug("curr consented is" + JSON.stringify(_curr_consented)); if (_curr_consented == null || _curr_consented == "" || _curr_consented.approval_date != _req_consent.approval_date) { - console.log("Not consented in local storage, need to show consent"); + logDebug("Not consented in local storage, need to show consent"); _is_consented = false; return false; } else { - console.log("Consented in local storage, no need to show consent"); + logDebug("Consented in local storage, no need to show consent"); _is_consented = true; return true; } @@ -81,7 +81,7 @@ export function readConsentState() { .then(function (startupConfigResult) { console.log(startupConfigResult); _req_consent = startupConfigResult.emSensorDataCollectionProtocol; - logInfo("required consent version = " + JSON.stringify(_req_consent)); + logDebug("required consent version = " + JSON.stringify(_req_consent)); return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); }).then(function (kv_store_consent) { _curr_consented = kv_store_consent; From 2516b2dfea0acb264f1946b3251ef4198a4282e8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 27 Oct 2023 08:55:53 -0600 Subject: [PATCH 122/135] _is_consented assigned but never used https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373756101 --- www/js/splash/startprefs.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 8a0b28415..4feccdb4a 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -51,8 +51,6 @@ export function markConsented() { }); }; -let _is_consented; - /** * @function checking for consent locally * @returns {boolean} if the consent is marked in the local var @@ -62,11 +60,9 @@ export function isConsented() { if (_curr_consented == null || _curr_consented == "" || _curr_consented.approval_date != _req_consent.approval_date) { logDebug("Not consented in local storage, need to show consent"); - _is_consented = false; return false; } else { logDebug("Consented in local storage, no need to show consent"); - _is_consented = true; return true; } } From 8bbfb82bad6d1aee88a046e7f7c89c8c73a73af8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 27 Oct 2023 09:53:36 -0600 Subject: [PATCH 123/135] add a catch block https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373739450 --- www/js/splash/startprefs.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 4feccdb4a..43f29c692 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -41,13 +41,16 @@ export function markConsented() { //check for reconsent .then(readIntroDone) .then((isIntroDone) => { - if(isIntroDone) { + if (isIntroDone) { logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") const PushNotify = getAngularService("PushNotify"); const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); PushNotify.registerPush(); StoreSeviceSettings.storeDeviceSettings(); } + }) + .catch((error) => { + displayErrorMsg(error, "Error while while wrting consent to storage"); }); }; From e19a346457af3d97c926b308ecc9133e7a289843 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 27 Oct 2023 12:28:08 -0600 Subject: [PATCH 124/135] less hacky undefined catch --- www/js/plugin/storage.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index e649d504d..a8c503f0e 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -25,12 +25,14 @@ const unmungeValue = (key, retData) => { } const localStorageSet = (key: string, value: {[k: string]: any}) => { - localStorage.setItem(key, JSON.stringify(value)); + if (value) { + localStorage.setItem(key, JSON.stringify(value)); + } } const localStorageGet = (key: string) => { const value = localStorage.getItem(key); - if (value && value != "undefined") { + if (value) { return JSON.parse(value); } else { return null; From 9f5c02e92be6af2fab9774cdd7bc60a449e13d87 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Sat, 28 Oct 2023 16:18:33 -0700 Subject: [PATCH 125/135] Change the path where we expect gems to be installed This fixes https://github.com/e-mission/e-mission-docs/issues/1022 --- setup/export_shared_dep_versions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/export_shared_dep_versions.sh b/setup/export_shared_dep_versions.sh index 930216b6e..2ac27d61b 100644 --- a/setup/export_shared_dep_versions.sh +++ b/setup/export_shared_dep_versions.sh @@ -11,4 +11,4 @@ export GRADLE_VERSION=7.6 export OSX_EXP_VERSION=12 export NVM_DIR="$HOME/.nvm" -export RUBY_PATH=$HOME/.gem/ruby/$RUBY_VERSION.0/bin +export RUBY_PATH=$HOME/.local/share/gem/ruby/$RUBY_VERSION.0/bin From b2d6aa912f152515d791f136654b696acff125d1 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 30 Oct 2023 09:42:37 -0600 Subject: [PATCH 126/135] add comment and discussion link --- www/js/plugin/storage.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index a8c503f0e..643e985e1 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -25,6 +25,9 @@ const unmungeValue = (key, retData) => { } const localStorageSet = (key: string, value: {[k: string]: any}) => { + //checking for a value to prevent storing undefined + //case where local was null and native was undefined stored "undefined" + //see discussion: https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373753945 if (value) { localStorage.setItem(key, JSON.stringify(value)); } From fcbefb1b4a97605f62a602c309a6c3cea13328d4 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 30 Oct 2023 10:16:19 -0600 Subject: [PATCH 127/135] add comments about naming --- www/js/splash/pushnotify.js | 4 ++++ www/js/splash/remotenotify.js | 3 +++ 2 files changed, 7 insertions(+) diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index dd31811ab..b107349fc 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -1,3 +1,7 @@ +//naming of this file can be a little confusing - "pushnotifysettings" for rewritten file +//https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1375360832 + + /* * This module deals with the interaction with the push plugin, the redirection * of silent push notifications and the re-parsing of iOS pushes. It then diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index f08921fdd..f67cb9d87 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -1,3 +1,6 @@ +//naming of this module can be confusing "remotenotifyhandler" for rewritten file +//https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1375360832 + /* * This module deals with handling specific push messages that open web pages * or popups. It does not interface with the push plugin directly. Instead, it From 3c23808bc45a836e056c30998e10fa08dbdbcf1d Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 30 Oct 2023 11:30:56 -0600 Subject: [PATCH 128/135] remove old imports --- www/js/splash/pushnotify.js | 3 +-- www/js/splash/storedevicesettings.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index b107349fc..775ddc4bd 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -19,8 +19,7 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { readConsentState, isConsented, startPrefs } from './startprefs'; -import { readIntroDone } from '../onboarding/onboardingHelper'; +import { readConsentState, isConsented } from './startprefs'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services']) diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index c32083295..31543bc6c 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,7 +1,6 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { isConsented, readConsentState, startPrefs } from "./startprefs"; -import { readIntroDone } from '../onboarding/onboardingHelper'; +import { isConsented, readConsentState } from "./startprefs"; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services']) From b29dc133224b39ee072e62d0fed84d22b7cb8839 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 31 Oct 2023 13:27:23 -0600 Subject: [PATCH 129/135] Remove default translation for moped in en.json Via https://github.com/e-mission/e-mission-docs/issues/1013#issuecomment-1787627109 --- www/i18n/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index a3f4642f3..6ec8ddf35 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -158,7 +158,6 @@ "hybrid_shared_ride": "Hybrid Shared Ride", "e_car_drove_alone": "E-Car Drove Alone", "e_car_shared_ride": "E-Car Shared Ride", - "moped": "Moped", "taxi": "Taxi / Uber / Lyft", "bus": "Bus", "train": "Train", From 60abf8442a2e5d0451869304aa176911a2e09da2 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 31 Oct 2023 13:28:01 -0600 Subject: [PATCH 130/135] Remove moped as a default mode from the default label-options.json.sample file Via https://github.com/e-mission/e-mission-docs/issues/1013#issuecomment-1787627109 --- www/json/label-options.json.sample | 1 - 1 file changed, 1 deletion(-) diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index a2b49258c..7947e2149 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -11,7 +11,6 @@ {"value":"hybrid_shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.0635}, {"value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.08216}, {"value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.04108}, - {"value":"moped", "baseMode":"MOPED", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.05555}, {"value":"taxi", "baseMode":"TAXI", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.30741}, {"value":"bus", "baseMode":"BUS", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.20727}, {"value":"train", "baseMode":"TRAIN", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.12256}, From 61ab8817b9d50daba54008a5f935cc2a86fa997d Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:00:42 -0700 Subject: [PATCH 131/135] we no longer need this file with prettier --- .editorconfig | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index aca25232d..000000000 --- a/.editorconfig +++ /dev/null @@ -1,14 +0,0 @@ -# http://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.md] -insert_final_newline = false -trim_trailing_whitespace = false \ No newline at end of file From 23cba1cd2a1913cd5ae00932d753113d3c3127b6 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:01:16 -0700 Subject: [PATCH 132/135] only check 'www' directory except dist, manual_lib, and json --- .prettierignore | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..be7b1726d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +# Ignore www/dist, manual_lib, json +www/dist +www/manual_lib +www/json + +# This is the pattern to check only www directory +# Ignore all +/* +# but don't ignore all the files in www directory +!/www From b92f993adf50080181feaf49e14b2f5904c8429f Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:01:38 -0700 Subject: [PATCH 133/135] update prettier config --- .prettierrc | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..5875d605a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true, + "bracketSameLine": true, + "endOfLine": "lf", + "semi": true +} From 4707d827f7fe0fe188514c5da022e3b809d55bde Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:02:42 -0700 Subject: [PATCH 134/135] add prettier package --- package.serve.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.serve.json b/package.serve.json index 8da81f941..6315b6a46 100644 --- a/package.serve.json +++ b/package.serve.json @@ -49,7 +49,8 @@ "typescript": "^5.0.3", "url-loader": "^4.1.1", "webpack": "^5.0.1", - "webpack-cli": "^5.0.1" + "webpack-cli": "^5.0.1", + "prettier": "3.0.3" }, "dependencies": { "@react-navigation/native": "^6.1.7", From bb73676c7245b68b9a8784204b68d71ba0af5267 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:05:36 -0700 Subject: [PATCH 135/135] format the files with prettier --- www/__mocks__/cordovaMocks.ts | 54 +- www/__mocks__/fileSystemMocks.ts | 26 +- www/__mocks__/globalMocks.ts | 2 +- www/__tests__/LoadMoreButton.test.tsx | 27 +- www/__tests__/clientStats.test.ts | 16 +- www/__tests__/commHelper.test.ts | 26 +- www/__tests__/customURL.test.ts | 60 +- www/__tests__/diaryHelper.test.ts | 101 +- www/__tests__/startprefs.test.ts | 43 +- www/__tests__/storage.test.ts | 6 +- www/__tests__/uploadService.test.ts | 64 +- www/__tests__/useImperialConfig.test.ts | 7 +- www/build/app.css | 3147 +++++++++++++---- www/build/static.css | 70 +- www/css/main.diary.css | 169 +- www/css/style.css | 497 +-- www/i18n/en.json | 876 ++--- www/index.html | 14 +- www/js/App.tsx | 76 +- www/js/angular-react-helper.tsx | 4 +- www/js/appTheme.ts | 28 +- www/js/appstatus/ExplainPermissions.tsx | 63 +- www/js/appstatus/PermissionItem.tsx | 30 +- www/js/appstatus/PermissionsControls.tsx | 110 +- www/js/commHelper.ts | 171 +- www/js/components/ActionMenu.tsx | 79 +- www/js/components/BarChart.tsx | 21 +- www/js/components/Carousel.tsx | 23 +- www/js/components/Chart.tsx | 290 +- www/js/components/DiaryButton.tsx | 21 +- www/js/components/Icon.tsx | 15 +- www/js/components/LeafletView.tsx | 51 +- www/js/components/LineChart.tsx | 12 +- www/js/components/NavBarButton.tsx | 56 +- www/js/components/QrCode.tsx | 47 +- www/js/components/ToggleSwitch.tsx | 21 +- www/js/components/charting.ts | 90 +- www/js/config/dynamicConfig.ts | 231 +- www/js/config/enketo-config.js | 10 +- www/js/config/serverConn.ts | 9 +- www/js/config/useImperialConfig.ts | 43 +- www/js/control/AlertBar.jsx | 65 +- www/js/control/AppStatusModal.tsx | 66 +- www/js/control/ControlCollectionHelper.tsx | 532 +-- www/js/control/ControlDataTable.jsx | 14 +- www/js/control/ControlSyncHelper.tsx | 533 +-- www/js/control/DataDatePicker.tsx | 40 +- www/js/control/DemographicsSettingRow.jsx | 45 +- www/js/control/ExpandMenu.jsx | 24 +- www/js/control/LogPage.tsx | 322 +- www/js/control/PopOpCode.jsx | 151 +- www/js/control/PrivacyPolicyModal.tsx | 57 +- www/js/control/ProfileSettings.jsx | 1194 ++++--- www/js/control/ReminderTime.tsx | 99 +- www/js/control/SensedPage.tsx | 164 +- www/js/control/SettingRow.jsx | 93 +- www/js/control/emailService.js | 179 +- www/js/control/uploadService.ts | 202 +- www/js/controllers.js | 171 +- www/js/diary.js | 28 +- www/js/diary/LabelTab.tsx | 164 +- www/js/diary/addressNamesHelper.ts | 58 +- www/js/diary/cards/DiaryCard.tsx | 46 +- www/js/diary/cards/ModesIndicator.tsx | 72 +- www/js/diary/cards/PlaceCard.tsx | 51 +- www/js/diary/cards/TimestampBadge.tsx | 32 +- www/js/diary/cards/TripCard.tsx | 142 +- www/js/diary/cards/UntrackedTimeCard.tsx | 53 +- www/js/diary/components/StartEndLocations.tsx | 103 +- www/js/diary/details/LabelDetailsScreen.tsx | 142 +- .../diary/details/OverallTripDescriptives.tsx | 31 +- .../details/TripSectionsDescriptives.tsx | 73 +- www/js/diary/diaryHelper.ts | 136 +- www/js/diary/diaryTypes.ts | 102 +- www/js/diary/list/DateSelect.tsx | 75 +- www/js/diary/list/FilterSelect.tsx | 88 +- www/js/diary/list/LabelListScreen.tsx | 87 +- www/js/diary/list/LoadMoreButton.tsx | 22 +- www/js/diary/list/TimelineScrollList.tsx | 79 +- www/js/diary/services.js | 574 +-- www/js/diary/timelineHelper.ts | 73 +- www/js/diary/useDerivedProperties.tsx | 21 +- www/js/i18n-utils.js | 47 +- www/js/i18nextInit.ts | 26 +- www/js/main.js | 59 +- www/js/metrics-factory.js | 409 ++- www/js/metrics-mappings.js | 722 ++-- www/js/metrics/ActiveMinutesTableCard.tsx | 100 +- www/js/metrics/CarbonFootprintCard.tsx | 382 +- www/js/metrics/CarbonTextCard.tsx | 246 +- www/js/metrics/ChangeIndicator.tsx | 129 +- www/js/metrics/DailyActiveMinutesCard.tsx | 46 +- www/js/metrics/MetricsCard.tsx | 146 +- www/js/metrics/MetricsDateSelect.tsx | 94 +- www/js/metrics/MetricsTab.tsx | 153 +- www/js/metrics/WeeklyActiveMinutesCard.tsx | 71 +- www/js/metrics/metricsHelper.ts | 163 +- www/js/metrics/metricsTypes.ts | 20 +- www/js/ngApp.js | 86 +- www/js/onboarding/OnboardingStack.tsx | 25 +- www/js/onboarding/PrivacyPolicy.tsx | 239 +- www/js/onboarding/ProtocolPage.tsx | 41 +- www/js/onboarding/SaveQrPage.tsx | 102 +- www/js/onboarding/StudySummary.tsx | 41 +- www/js/onboarding/SummaryPage.tsx | 34 +- www/js/onboarding/SurveyPage.tsx | 107 +- www/js/onboarding/WelcomePage.tsx | 241 +- www/js/onboarding/onboardingHelper.ts | 112 +- www/js/plugin/clientStats.ts | 52 +- www/js/plugin/logger.ts | 51 +- www/js/plugin/storage.ts | 173 +- www/js/services.js | 518 +-- www/js/splash/customURL.ts | 36 +- www/js/splash/localnotify.js | 212 +- www/js/splash/notifScheduler.js | 374 +- www/js/splash/pushnotify.js | 285 +- www/js/splash/referral.js | 60 +- www/js/splash/remotenotify.js | 84 +- www/js/splash/startprefs.ts | 107 +- www/js/splash/storedevicesettings.js | 87 +- www/js/survey/enketo/AddNoteButton.tsx | 75 +- www/js/survey/enketo/AddedNotesList.tsx | 182 +- www/js/survey/enketo/EnketoModal.tsx | 98 +- www/js/survey/enketo/UserInputButton.tsx | 65 +- www/js/survey/enketo/answer.js | 357 +- .../survey/enketo/enketo-add-note-button.js | 206 +- www/js/survey/enketo/enketo-trip-button.js | 184 +- www/js/survey/enketo/enketoHelper.ts | 99 +- .../survey/enketo/infinite_scroll_filters.ts | 42 +- www/js/survey/input-matcher.js | 366 +- .../multilabel/MultiLabelButtonGroup.tsx | 198 +- www/js/survey/multilabel/confirmHelper.ts | 104 +- .../multilabel/infinite_scroll_filters.ts | 75 +- www/js/survey/multilabel/multi-label-ui.js | 415 ++- www/js/survey/survey.ts | 20 +- www/js/useAppConfig.ts | 11 +- www/js/useAppStateChange.ts | 35 +- www/js/usePermissionStatus.ts | 708 ++-- 138 files changed, 12778 insertions(+), 9251 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index c00377120..62aa9be1a 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -5,13 +5,13 @@ export const mockCordova = () => { window['cordova'].platformId ||= 'ios'; window['cordova'].platformVersion ||= packageJsonBuild.dependencies['cordova-ios']; window['cordova'].plugins ||= {}; -} +}; export const mockDevice = () => { window['device'] ||= {}; window['device'].platform ||= 'ios'; window['device'].version ||= '14.0.0'; -} +}; export const mockGetAppVersion = () => { const mockGetAppVersion = { @@ -19,15 +19,17 @@ export const mockGetAppVersion = () => { getPackageName: () => new Promise((rs, rj) => setTimeout(() => rs('com.example.mockapp'), 10)), getVersionCode: () => new Promise((rs, rj) => setTimeout(() => rs('123'), 10)), getVersionNumber: () => new Promise((rs, rj) => setTimeout(() => rs('1.2.3'), 10)), - } + }; window['cordova'] ||= {}; window['cordova'].getAppVersion = mockGetAppVersion; -} +}; export const mockFile = () => { - window['cordova'].file = { "dataDirectory" : "../path/to/data/directory", - "applicationStorageDirectory" : "../path/to/app/storage/directory"}; -} + window['cordova'].file = { + dataDirectory: '../path/to/data/directory', + applicationStorageDirectory: '../path/to/app/storage/directory', + }; +}; //for consent document const _storage = {}; @@ -40,7 +42,7 @@ export const mockBEMUserCache = () => { return new Promise((rs, rj) => setTimeout(() => { rs(_cache[key]); - }, 100) + }, 100), ); }, putLocalStorage: (key: string, value: any) => { @@ -48,7 +50,7 @@ export const mockBEMUserCache = () => { setTimeout(() => { _cache[key] = value; rs(); - }, 100) + }, 100), ); }, removeLocalStorage: (key: string) => { @@ -56,7 +58,7 @@ export const mockBEMUserCache = () => { setTimeout(() => { delete _cache[key]; rs(); - }, 100) + }, 100), ); }, clearAll: () => { @@ -64,21 +66,21 @@ export const mockBEMUserCache = () => { setTimeout(() => { for (let p in _cache) delete _cache[p]; rs(); - }, 100) + }, 100), ); }, listAllLocalStorageKeys: () => { return new Promise((rs, rj) => setTimeout(() => { rs(Object.keys(_cache)); - }, 100) + }, 100), ); }, listAllUniqueKeys: () => { return new Promise((rs, rj) => setTimeout(() => { rs(Object.keys(_cache)); - }, 100) + }, 100), ); }, putMessage: (key: string, value: any) => { @@ -86,46 +88,48 @@ export const mockBEMUserCache = () => { setTimeout(() => { messages.push({ key, value }); rs(); - }, 100) + }, 100), ); }, getAllMessages: (key: string, withMetadata?: boolean) => { return new Promise((rs, rj) => setTimeout(() => { - rs(messages.filter(m => m.key == key).map(m => m.value)); - }, 100) + rs(messages.filter((m) => m.key == key).map((m) => m.value)); + }, 100), ); }, getDocument: (key: string, withMetadata?: boolean) => { return new Promise((rs, rj) => setTimeout(() => { rs(_storage[key]); - }, 100) + }, 100), ); }, isEmptyDoc: (doc) => { - if (doc == undefined) { return true } + if (doc == undefined) { + return true; + } let string = doc.toString(); if (string.length == 0) { return true; } else { return false; } - } - } + }, + }; window['cordova'] ||= {}; window['cordova'].plugins ||= {}; window['cordova'].plugins.BEMUserCache = mockBEMUserCache; -} +}; export const mockBEMDataCollection = () => { const mockBEMDataCollection = { markConsented: (consentDoc) => { setTimeout(() => { _storage['config/consent'] = consentDoc; - }, 100) - } - } + }, 100); + }, + }; window['cordova'] ||= {}; window['cordova'].plugins.BEMDataCollection = mockBEMDataCollection; -} +}; diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index d7c2743ac..70b532507 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,23 +1,21 @@ export const mockFileSystem = () => { window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { const fs = { - filesystem: - { - root: - { + filesystem: { + root: { getFile: (path, options, onSuccess) => { let fileEntry = { file: (handleFile) => { - let file = new File(["this is a mock"], "loggerDB"); + let file = new File(['this is a mock'], 'loggerDB'); handleFile(file); - } - } + }, + }; onSuccess(fileEntry); - } - } - } - } - console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); + }, + }, + }, + }; + console.log('in mock, fs is ', fs, ' get File is ', fs.filesystem.root.getFile); handleFS(fs); - } -} \ No newline at end of file + }; +}; diff --git a/www/__mocks__/globalMocks.ts b/www/__mocks__/globalMocks.ts index 3d9b71507..f13cb274b 100644 --- a/www/__mocks__/globalMocks.ts +++ b/www/__mocks__/globalMocks.ts @@ -1,3 +1,3 @@ export const mockLogger = () => { window['Logger'] = { log: console.log }; -} +}; diff --git a/www/__tests__/LoadMoreButton.test.tsx b/www/__tests__/LoadMoreButton.test.tsx index 5acb4a700..100cf19fc 100644 --- a/www/__tests__/LoadMoreButton.test.tsx +++ b/www/__tests__/LoadMoreButton.test.tsx @@ -1,30 +1,23 @@ /** * @jest-environment jsdom */ -import React from 'react' -import {render, fireEvent, waitFor, screen} from '@testing-library/react-native' -import LoadMoreButton from '../js/diary/list/LoadMoreButton' +import React from 'react'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react-native'; +import LoadMoreButton from '../js/diary/list/LoadMoreButton'; - -describe("LoadMoreButton", () => { - it("renders correctly", async () => { - render( - {}}>{} - ); +describe('LoadMoreButton', () => { + it('renders correctly', async () => { + render( {}}>{}); await waitFor(() => { - expect(screen.getByTestId("load-button")).toBeTruthy(); + expect(screen.getByTestId('load-button')).toBeTruthy(); }); }); - it("calls onPressFn when clicked", () => { + it('calls onPressFn when clicked', () => { const mockFn = jest.fn(); - const { getByTestId } = render( - {} - ); - const loadButton = getByTestId("load-button"); + const { getByTestId } = render({}); + const loadButton = getByTestId('load-button'); fireEvent.press(loadButton); expect(mockFn).toHaveBeenCalled(); }); }); - - diff --git a/www/__tests__/clientStats.test.ts b/www/__tests__/clientStats.test.ts index d1a054195..a3a953582 100644 --- a/www/__tests__/clientStats.test.ts +++ b/www/__tests__/clientStats.test.ts @@ -1,5 +1,11 @@ -import { mockBEMUserCache, mockDevice, mockGetAppVersion } from "../__mocks__/cordovaMocks"; -import { addStatError, addStatEvent, addStatReading, getAppVersion, statKeys } from "../js/plugin/clientStats"; +import { mockBEMUserCache, mockDevice, mockGetAppVersion } from '../__mocks__/cordovaMocks'; +import { + addStatError, + addStatEvent, + addStatReading, + getAppVersion, + statKeys, +} from '../js/plugin/clientStats'; mockDevice(); // this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" @@ -22,7 +28,7 @@ it('stores a client stats reading', async () => { ts: expect.any(Number), reading, client_app_version: '1.2.3', - client_os_version: '14.0.0' + client_os_version: '14.0.0', }); }); @@ -34,7 +40,7 @@ it('stores a client stats event', async () => { ts: expect.any(Number), reading: null, client_app_version: '1.2.3', - client_os_version: '14.0.0' + client_os_version: '14.0.0', }); }); @@ -47,6 +53,6 @@ it('stores a client stats error', async () => { ts: expect.any(Number), reading: errorStr, client_app_version: '1.2.3', - client_os_version: '14.0.0' + client_os_version: '14.0.0', }); }); diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts index 2e2dfc6af..8bc52a408 100644 --- a/www/__tests__/commHelper.test.ts +++ b/www/__tests__/commHelper.test.ts @@ -5,19 +5,27 @@ mockLogger(); // mock for JavaScript 'fetch' // we emulate a 100ms delay when i) fetching data and ii) parsing it as text -global.fetch = (url: string) => new Promise((rs, rj) => { - setTimeout(() => rs({ - text: () => new Promise((rs, rj) => { - setTimeout(() => rs('mock data for ' + url), 100); - }) - })); -}) as any; +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); + }), + }), + ); + }) as any; it('fetches text from a URL and caches it so the next call is faster', async () => { const tsBeforeCalls = Date.now(); - const text1 = await fetchUrlCached('https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md'); + const text1 = await fetchUrlCached( + 'https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md', + ); const tsBetweenCalls = Date.now(); - const text2 = await fetchUrlCached('https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md'); + const text2 = await fetchUrlCached( + 'https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md', + ); const tsAfterCalls = Date.now(); expect(text1).toEqual(expect.stringContaining('mock data')); expect(text2).toEqual(expect.stringContaining('mock data')); diff --git a/www/__tests__/customURL.test.ts b/www/__tests__/customURL.test.ts index 68ce3c47d..c06345679 100644 --- a/www/__tests__/customURL.test.ts +++ b/www/__tests__/customURL.test.ts @@ -1,38 +1,38 @@ import { onLaunchCustomURL } from '../js/splash/customURL'; describe('onLaunchCustomURL', () => { - let mockHandler; + let mockHandler; - beforeEach(() => { - // create a new mock handler before each test case. - mockHandler = jest.fn(); - }); + beforeEach(() => { + // create a new mock handler before each test case. + mockHandler = jest.fn(); + }); - it('tests valid url 1 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { - const validURL = 'emission://login_token?token=nrelop_dev-emulator-program'; - const expectedURL = 'login_token?token=nrelop_dev-emulator-program'; - const expectedComponents = { route: 'login_token', token: 'nrelop_dev-emulator-program' }; - onLaunchCustomURL(validURL, mockHandler); - expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); - }); + it('tests valid url 1 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { + const validURL = 'emission://login_token?token=nrelop_dev-emulator-program'; + const expectedURL = 'login_token?token=nrelop_dev-emulator-program'; + const expectedComponents = { route: 'login_token', token: 'nrelop_dev-emulator-program' }; + onLaunchCustomURL(validURL, mockHandler); + expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); + }); - it('tests valid url 2 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { - const validURL = 'emission://test?param1=first¶m2=second'; - const expectedURL = 'test?param1=first¶m2=second'; - const expectedComponents = { route: 'test', param1: 'first', param2: 'second' }; - onLaunchCustomURL(validURL, mockHandler); - expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); - }); + it('tests valid url 2 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { + const validURL = 'emission://test?param1=first¶m2=second'; + const expectedURL = 'test?param1=first¶m2=second'; + const expectedComponents = { route: 'test', param1: 'first', param2: 'second' }; + onLaunchCustomURL(validURL, mockHandler); + expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); + }); - it('test invalid url 1 - should not call handler callback with invalid URL', () => { - const invalidURL = 'invalid_url'; - onLaunchCustomURL(invalidURL, mockHandler); - expect(mockHandler).not.toHaveBeenCalled(); - }); + it('test invalid url 1 - should not call handler callback with invalid URL', () => { + const invalidURL = 'invalid_url'; + onLaunchCustomURL(invalidURL, mockHandler); + expect(mockHandler).not.toHaveBeenCalled(); + }); - it('tests invalid url 2 - should not call handler callback with invalid URL', () => { - const invalidURL = ''; - onLaunchCustomURL(invalidURL, mockHandler); - expect(mockHandler).not.toHaveBeenCalled(); - }); -}) \ No newline at end of file + it('tests invalid url 2 - should not call handler callback with invalid URL', () => { + const invalidURL = ''; + onLaunchCustomURL(invalidURL, mockHandler); + expect(mockHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 822b19bba..1ac143334 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -1,63 +1,86 @@ -import { getFormattedDate, isMultiDay, getFormattedDateAbbr, getFormattedTimeRange, getDetectedModes, getBaseModeByKey, modeColors } from "../js/diary/diaryHelper"; +import { + getFormattedDate, + isMultiDay, + getFormattedDateAbbr, + getFormattedTimeRange, + getDetectedModes, + getBaseModeByKey, + modeColors, +} from '../js/diary/diaryHelper'; it('returns a formatted date', () => { - expect(getFormattedDate("2023-09-18T00:00:00-07:00")).toBe("Mon September 18, 2023"); - expect(getFormattedDate("")).toBeUndefined(); - expect(getFormattedDate("2023-09-18T00:00:00-07:00", "2023-09-21T00:00:00-07:00")).toBe("Mon September 18, 2023 - Thu September 21, 2023"); + expect(getFormattedDate('2023-09-18T00:00:00-07:00')).toBe('Mon September 18, 2023'); + expect(getFormattedDate('')).toBeUndefined(); + expect(getFormattedDate('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe( + 'Mon September 18, 2023 - Thu September 21, 2023', + ); }); it('returns an abbreviated formatted date', () => { - expect(getFormattedDateAbbr("2023-09-18T00:00:00-07:00")).toBe("Mon, Sep 18"); - expect(getFormattedDateAbbr("")).toBeUndefined(); - expect(getFormattedDateAbbr("2023-09-18T00:00:00-07:00", "2023-09-21T00:00:00-07:00")).toBe("Mon, Sep 18 - Thu, Sep 21"); + expect(getFormattedDateAbbr('2023-09-18T00:00:00-07:00')).toBe('Mon, Sep 18'); + expect(getFormattedDateAbbr('')).toBeUndefined(); + expect(getFormattedDateAbbr('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe( + 'Mon, Sep 18 - Thu, Sep 21', + ); }); it('returns a human readable time range', () => { - expect(getFormattedTimeRange("2023-09-18T00:00:00-07:00", "2023-09-18T00:00:00-09:20")).toBe("2 hours"); - expect(getFormattedTimeRange("2023-09-18T00:00:00-07:00", "2023-09-18T00:00:00-09:30")).toBe("3 hours"); - expect(getFormattedTimeRange("", "2023-09-18T00:00:00-09:30")).toBeFalsy(); + expect(getFormattedTimeRange('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:20')).toBe( + '2 hours', + ); + expect(getFormattedTimeRange('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:30')).toBe( + '3 hours', + ); + expect(getFormattedTimeRange('', '2023-09-18T00:00:00-09:30')).toBeFalsy(); }); -it("returns a Base Mode for a given key", () => { - expect(getBaseModeByKey("WALKING")).toEqual({ name: "WALKING", icon: "walk", color: modeColors.blue }); - expect(getBaseModeByKey("MotionTypes.WALKING")).toEqual({ name: "WALKING", icon: "walk", color: modeColors.blue }); - expect(getBaseModeByKey("I made this type up")).toEqual({ name: "UNKNOWN", icon: "help", color: modeColors.grey }); +it('returns a Base Mode for a given key', () => { + expect(getBaseModeByKey('WALKING')).toEqual({ + name: 'WALKING', + icon: 'walk', + color: modeColors.blue, + }); + expect(getBaseModeByKey('MotionTypes.WALKING')).toEqual({ + name: 'WALKING', + icon: 'walk', + color: modeColors.blue, + }); + expect(getBaseModeByKey('I made this type up')).toEqual({ + name: 'UNKNOWN', + icon: 'help', + color: modeColors.grey, + }); }); it('returns true/false is multi day', () => { - expect(isMultiDay("2023-09-18T00:00:00-07:00", "2023-09-19T00:00:00-07:00")).toBeTruthy(); - expect(isMultiDay("2023-09-18T00:00:00-07:00", "2023-09-18T00:00:00-09:00")).toBeFalsy(); - expect(isMultiDay("", "2023-09-18T00:00:00-09:00")).toBeFalsy(); + expect(isMultiDay('2023-09-18T00:00:00-07:00', '2023-09-19T00:00:00-07:00')).toBeTruthy(); + expect(isMultiDay('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:00')).toBeFalsy(); + expect(isMultiDay('', '2023-09-18T00:00:00-09:00')).toBeFalsy(); }); //created a fake trip with relevant sections by examining log statements -let myFakeTrip = {sections: [ - { "sensed_mode_str": "BICYCLING", "distance": 6013.73657416706 }, - { "sensed_mode_str": "WALKING", "distance": 715.3078629361006 } -]}; -let myFakeTrip2 = {sections: [ - { "sensed_mode_str": "BICYCLING", "distance": 6013.73657416706 }, - { "sensed_mode_str": "BICYCLING", "distance": 715.3078629361006 } -]}; +let myFakeTrip = { + sections: [ + { sensed_mode_str: 'BICYCLING', distance: 6013.73657416706 }, + { sensed_mode_str: 'WALKING', distance: 715.3078629361006 }, + ], +}; +let myFakeTrip2 = { + sections: [ + { sensed_mode_str: 'BICYCLING', distance: 6013.73657416706 }, + { sensed_mode_str: 'BICYCLING', distance: 715.3078629361006 }, + ], +}; let myFakeDetectedModes = [ - { mode: "BICYCLING", - icon: "bike", - color: modeColors.green, - pct: 89 }, - { mode: "WALKING", - icon: "walk", - color: modeColors.blue, - pct: 11 }]; + { mode: 'BICYCLING', icon: 'bike', color: modeColors.green, pct: 89 }, + { mode: 'WALKING', icon: 'walk', color: modeColors.blue, pct: 11 }, +]; -let myFakeDetectedModes2 = [ - { mode: "BICYCLING", - icon: "bike", - color: modeColors.green, - pct: 100 }]; +let myFakeDetectedModes2 = [{ mode: 'BICYCLING', icon: 'bike', color: modeColors.green, pct: 100 }]; it('returns the detected modes, with percentages, for a trip', () => { expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); expect(getDetectedModes({})).toEqual([]); // empty trip, no sections, no modes -}) +}); diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts index 1e62e7b5e..75ed707dc 100644 --- a/www/__tests__/startprefs.test.ts +++ b/www/__tests__/startprefs.test.ts @@ -1,24 +1,41 @@ -import { markConsented, isConsented, readConsentState, getConsentDocument } from '../js/splash/startprefs'; +import { + markConsented, + isConsented, + readConsentState, + getConsentDocument, +} from '../js/splash/startprefs'; -import { mockBEMUserCache, mockBEMDataCollection } from "../__mocks__/cordovaMocks"; -import { mockLogger } from "../__mocks__/globalMocks"; +import { mockBEMUserCache, mockBEMDataCollection } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; mockBEMUserCache(); mockBEMDataCollection(); mockLogger(); -global.fetch = (url: string) => new Promise((rs, rj) => { - setTimeout(() => rs({ - json: () => new Promise((rs, rj) => { - let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }; - setTimeout(() => rs(myJSON), 100); - }) - })); -}) as any; +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + json: () => + new Promise((rs, rj) => { + let myJSON = { + emSensorDataCollectionProtocol: { + protocol_id: '2014-04-6267', + approval_date: '2016-07-14', + }, + }; + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; it('checks state of consent before and after marking consent', async () => { expect(await readConsentState().then(isConsented)).toBeFalsy(); let marked = await markConsented(); expect(await readConsentState().then(isConsented)).toBeTruthy(); - expect(await getConsentDocument()).toEqual({"approval_date": "2016-07-14", "protocol_id": "2014-04-6267"}); -}); \ No newline at end of file + expect(await getConsentDocument()).toEqual({ + approval_date: '2016-07-14', + protocol_id: '2014-04-6267', + }); +}); diff --git a/www/__tests__/storage.test.ts b/www/__tests__/storage.test.ts index 6fea4f8b9..ca6d71dec 100644 --- a/www/__tests__/storage.test.ts +++ b/www/__tests__/storage.test.ts @@ -1,6 +1,6 @@ -import { mockBEMUserCache } from "../__mocks__/cordovaMocks"; -import { mockLogger } from "../__mocks__/globalMocks"; -import { storageClear, storageGet, storageRemove, storageSet } from "../js/plugin/storage"; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { storageClear, storageGet, storageRemove, storageSet } from '../js/plugin/storage'; // mocks used - storage.ts uses BEMUserCache and logging. // localStorage is already mocked for us by Jest :) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index 5c64fae0e..b9bede9fd 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -2,51 +2,55 @@ //however, tests are still important to make sure the code works //at some point we hope to restore this functionality -import {uploadFile} from "../js/control/uploadService"; +import { uploadFile } from '../js/control/uploadService'; import { mockLogger } from '../__mocks__/globalMocks'; -import { mockDevice, mockGetAppVersion, mockCordova, mockFile } from "../__mocks__/cordovaMocks"; -import { mockFileSystem } from "../__mocks__/fileSystemMocks"; +import { mockDevice, mockGetAppVersion, mockCordova, mockFile } from '../__mocks__/cordovaMocks'; +import { mockFileSystem } from '../__mocks__/fileSystemMocks'; mockDevice(); mockGetAppVersion(); mockCordova(); mockLogger(); -mockFile(); //mocks the base directory +mockFile(); //mocks the base directory mockFileSystem(); //comnplex mock, allows the readDBFile to work in testing //use this message to verify that the post went through -let message = ""; +let message = ''; //each have a slight delay to mimic a real fetch request -global.fetch = (url: string, options: { method: string, headers: {}, body: string }) => new Promise((rs, rj) => { - //if there's options, that means there is a post request - if (options) { - message = "sent " + options.method + options.body + " for " + url; - setTimeout(() => { - rs('sent ' + options.method + options.body + ' to ' + url); - }, 100); - } - //else it is a get request - else { - setTimeout(() => rs({ - json: () => new Promise((rs, rj) => { - setTimeout(() => rs('mock data for ' + url), 100); - }) - })); - } -}) as any; +global.fetch = (url: string, options: { method: string; headers: {}; body: string }) => + new Promise((rs, rj) => { + //if there's options, that means there is a post request + if (options) { + message = 'sent ' + options.method + options.body + ' for ' + url; + setTimeout(() => { + rs('sent ' + options.method + options.body + ' to ' + url); + }, 100); + } + //else it is a get request + else { + setTimeout(() => + rs({ + json: () => + new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); + }), + }), + ); + } + }) as any; window.alert = (message) => { console.log(message); -} +}; //very basic tests - difficult to do too much since there's a lot of mocking involved it('posts the logs to the configured database', async () => { - let posted = await uploadFile("loggerDB", "HelloWorld"); - expect(message).toEqual(expect.stringContaining("HelloWorld")); - expect(message).toEqual(expect.stringContaining("POST")); - posted = await uploadFile("loggerDB", "second test"); - expect(message).toEqual(expect.stringContaining("second test")); - expect(message).toEqual(expect.stringContaining("POST")); -}, 10000); \ No newline at end of file + let posted = await uploadFile('loggerDB', 'HelloWorld'); + expect(message).toEqual(expect.stringContaining('HelloWorld')); + expect(message).toEqual(expect.stringContaining('POST')); + posted = await uploadFile('loggerDB', 'second test'); + expect(message).toEqual(expect.stringContaining('second test')); + expect(message).toEqual(expect.stringContaining('POST')); +}, 10000); diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index cab1b5a11..593498aae 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -1,16 +1,15 @@ import { convertDistance, convertSpeed, formatForDisplay } from '../js/config/useImperialConfig'; - // This mock is required, or else the test will dive into the import chain of useAppConfig.ts and fail when it gets to the root jest.mock('../js/useAppConfig', () => { return jest.fn(() => ({ appConfig: { - use_imperial: false + use_imperial: false, }, - loading: false + loading: false, })); }); - + describe('formatForDisplay', () => { it('should round to the nearest integer when value is >= 100', () => { expect(formatForDisplay(105)).toBe('105'); diff --git a/www/build/app.css b/www/build/app.css index d7ec98c10..97de0161f 100644 --- a/www/build/app.css +++ b/www/build/app.css @@ -19,7 +19,58 @@ * ======================================================================== */ -.tour-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1100;background-color:#000;opacity:.8;filter:alpha(opacity=80)}.tour-step-backdrop{position:relative;z-index:1101;background:inherit}.tour-step-backdrop>td{position:relative;z-index:1101}.tour-step-background{position:absolute!important;z-index:1100;background:inherit;border-radius:6px}.popover[class*=tour-]{z-index:1100}.popover[class*=tour-] .popover-navigation{padding:9px 14px}.popover[class*=tour-] .popover-navigation [data-role=end]{float:right}.popover[class*=tour-] .popover-navigation [data-role=prev],.popover[class*=tour-] .popover-navigation [data-role=next],.popover[class*=tour-] .popover-navigation [data-role=end]{cursor:pointer}.popover[class*=tour-] .popover-navigation [data-role=prev].disabled,.popover[class*=tour-] .popover-navigation [data-role=next].disabled,.popover[class*=tour-] .popover-navigation [data-role=end].disabled{cursor:default}.popover[class*=tour-].orphan{position:fixed;margin-top:0}.popover[class*=tour-].orphan .arrow{display:none} +.tour-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1100; + background-color: #000; + opacity: 0.8; + filter: alpha(opacity=80); +} +.tour-step-backdrop { + position: relative; + z-index: 1101; + background: inherit; +} +.tour-step-backdrop > td { + position: relative; + z-index: 1101; +} +.tour-step-background { + position: absolute !important; + z-index: 1100; + background: inherit; + border-radius: 6px; +} +.popover[class*='tour-'] { + z-index: 1100; +} +.popover[class*='tour-'] .popover-navigation { + padding: 9px 14px; +} +.popover[class*='tour-'] .popover-navigation [data-role='end'] { + float: right; +} +.popover[class*='tour-'] .popover-navigation [data-role='prev'], +.popover[class*='tour-'] .popover-navigation [data-role='next'], +.popover[class*='tour-'] .popover-navigation [data-role='end'] { + cursor: pointer; +} +.popover[class*='tour-'] .popover-navigation [data-role='prev'].disabled, +.popover[class*='tour-'] .popover-navigation [data-role='next'].disabled, +.popover[class*='tour-'] .popover-navigation [data-role='end'].disabled { + cursor: default; +} +.popover[class*='tour-'].orphan { + position: fixed; + margin-top: 0; +} +.popover[class*='tour-'].orphan .arrow { + display: none; +} /*! * angular-loading-bar v0.6.0 * https://chieffancypants.github.io/angular-loading-bar @@ -76,7 +127,7 @@ right: 0; top: 0; height: 2px; - opacity: .45; + opacity: 0.45; -moz-box-shadow: #29d 1px 0 6px 1px; -ms-box-shadow: #29d 1px 0 6px 1px; -webkit-box-shadow: #29d 1px 0 6px 1px; @@ -98,50 +149,80 @@ width: 14px; height: 14px; - border: solid 2px transparent; - border-top-color: #29d; + border: solid 2px transparent; + border-top-color: #29d; border-left-color: #29d; border-radius: 10px; -webkit-animation: loading-bar-spinner 400ms linear infinite; - -moz-animation: loading-bar-spinner 400ms linear infinite; - -ms-animation: loading-bar-spinner 400ms linear infinite; - -o-animation: loading-bar-spinner 400ms linear infinite; - animation: loading-bar-spinner 400ms linear infinite; + -moz-animation: loading-bar-spinner 400ms linear infinite; + -ms-animation: loading-bar-spinner 400ms linear infinite; + -o-animation: loading-bar-spinner 400ms linear infinite; + animation: loading-bar-spinner 400ms linear infinite; } @-webkit-keyframes loading-bar-spinner { - 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } } @-moz-keyframes loading-bar-spinner { - 0% { -moz-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -moz-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -moz-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } } @-o-keyframes loading-bar-spinner { - 0% { -o-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -o-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } } @-ms-keyframes loading-bar-spinner { - 0% { -ms-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -ms-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -ms-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } } @keyframes loading-bar-spinner { - 0% { transform: rotate(0deg); transform: rotate(0deg); } - 100% { transform: rotate(360deg); transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + transform: rotate(360deg); + } } /* Version: 3.4.8 Timestamp: Thu May 1 09:50:32 EDT 2014 */ .select2-container { - margin: 0; - position: relative; - display: inline-block; - /* inline-block for ie7 */ - zoom: 1; - *display: inline; - vertical-align: middle; + margin: 0; + position: relative; + display: inline-block; + /* inline-block for ie7 */ + zoom: 1; + *display: inline; + vertical-align: middle; } .select2-container, @@ -154,379 +235,438 @@ Version: 3.4.8 Timestamp: Thu May 1 09:50:32 EDT 2014 More Info : http://www.quirksmode.org/css/box.html */ -webkit-box-sizing: border-box; /* webkit */ - -moz-box-sizing: border-box; /* firefox */ - box-sizing: border-box; /* css3 */ + -moz-box-sizing: border-box; /* firefox */ + box-sizing: border-box; /* css3 */ } .select2-container .select2-choice { - display: block; - height: 26px; - padding: 0 0 0 8px; - overflow: hidden; - position: relative; + display: block; + height: 26px; + padding: 0 0 0 8px; + overflow: hidden; + position: relative; - border: 1px solid #aaa; - white-space: nowrap; - line-height: 26px; - color: #444; - text-decoration: none; + border: 1px solid #aaa; + white-space: nowrap; + line-height: 26px; + color: #444; + text-decoration: none; - border-radius: 4px; + border-radius: 4px; - background-clip: padding-box; + background-clip: padding-box; - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; - background-color: #fff; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eee), color-stop(0.5, #fff)); - background-image: -webkit-linear-gradient(center bottom, #eee 0%, #fff 50%); - background-image: -moz-linear-gradient(center bottom, #eee 0%, #fff 50%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#ffffff', endColorstr = '#eeeeee', GradientType = 0); - background-image: linear-gradient(to top, #eee 0%, #fff 50%); + background-color: #fff; + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #eee), + color-stop(0.5, #fff) + ); + background-image: -webkit-linear-gradient(center bottom, #eee 0%, #fff 50%); + background-image: -moz-linear-gradient(center bottom, #eee 0%, #fff 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#ffffff', endColorstr = '#eeeeee', GradientType = 0); + background-image: linear-gradient(to top, #eee 0%, #fff 50%); } .select2-container.select2-drop-above .select2-choice { - border-bottom-color: #aaa; + border-bottom-color: #aaa; - border-radius: 0 0 4px 4px; + border-radius: 0 0 4px 4px; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eee), color-stop(0.9, #fff)); - background-image: -webkit-linear-gradient(center bottom, #eee 0%, #fff 90%); - background-image: -moz-linear-gradient(center bottom, #eee 0%, #fff 90%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#eeeeee', GradientType=0); - background-image: linear-gradient(to bottom, #eee 0%, #fff 90%); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #eee), + color-stop(0.9, #fff) + ); + background-image: -webkit-linear-gradient(center bottom, #eee 0%, #fff 90%); + background-image: -moz-linear-gradient(center bottom, #eee 0%, #fff 90%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#eeeeee', GradientType=0); + background-image: linear-gradient(to bottom, #eee 0%, #fff 90%); } .select2-container.select2-allowclear .select2-choice .select2-chosen { - margin-right: 42px; + margin-right: 42px; } .select2-container .select2-choice > .select2-chosen { - margin-right: 26px; - display: block; - overflow: hidden; + margin-right: 26px; + display: block; + overflow: hidden; - white-space: nowrap; + white-space: nowrap; - text-overflow: ellipsis; - float: none; - width: auto; + text-overflow: ellipsis; + float: none; + width: auto; } .select2-container .select2-choice abbr { - display: none; - width: 12px; - height: 12px; - position: absolute; - right: 24px; - top: 8px; + display: none; + width: 12px; + height: 12px; + position: absolute; + right: 24px; + top: 8px; - font-size: 1px; - text-decoration: none; + font-size: 1px; + text-decoration: none; - border: 0; - background: url('select2.png') right top no-repeat; - cursor: pointer; - outline: 0; + border: 0; + background: url('select2.png') right top no-repeat; + cursor: pointer; + outline: 0; } .select2-container.select2-allowclear .select2-choice abbr { - display: inline-block; + display: inline-block; } .select2-container .select2-choice abbr:hover { - background-position: right -11px; - cursor: pointer; + background-position: right -11px; + cursor: pointer; } .select2-drop-mask { - border: 0; - margin: 0; - padding: 0; - position: fixed; - left: 0; - top: 0; - min-height: 100%; - min-width: 100%; - height: auto; - width: auto; - opacity: 0; - z-index: 9998; - /* styles required for IE to work */ - background-color: #fff; - filter: alpha(opacity=0); + border: 0; + margin: 0; + padding: 0; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 9998; + /* styles required for IE to work */ + background-color: #fff; + filter: alpha(opacity=0); } .select2-drop { - width: 100%; - margin-top: -1px; - position: absolute; - z-index: 9999; - top: 100%; + width: 100%; + margin-top: -1px; + position: absolute; + z-index: 9999; + top: 100%; - background: #fff; - color: #000; - border: 1px solid #aaa; - border-top: 0; + background: #fff; + color: #000; + border: 1px solid #aaa; + border-top: 0; - border-radius: 0 0 4px 4px; + border-radius: 0 0 4px 4px; - -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); - box-shadow: 0 4px 5px rgba(0, 0, 0, .15); + -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15); + box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15); } .select2-drop.select2-drop-above { - margin-top: 1px; - border-top: 1px solid #aaa; - border-bottom: 0; + margin-top: 1px; + border-top: 1px solid #aaa; + border-bottom: 0; - border-radius: 4px 4px 0 0; + border-radius: 4px 4px 0 0; - -webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); - box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); + -webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, 0.15); + box-shadow: 0 -4px 5px rgba(0, 0, 0, 0.15); } .select2-drop-active { - border: 1px solid #5897fb; - border-top: none; + border: 1px solid #5897fb; + border-top: none; } .select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid #5897fb; + border-top: 1px solid #5897fb; } .select2-drop-auto-width { - border-top: 1px solid #aaa; - width: auto; + border-top: 1px solid #aaa; + width: auto; } .select2-drop-auto-width .select2-search { - padding-top: 4px; + padding-top: 4px; } .select2-container .select2-choice .select2-arrow { - display: inline-block; - width: 18px; - height: 100%; - position: absolute; - right: 0; - top: 0; + display: inline-block; + width: 18px; + height: 100%; + position: absolute; + right: 0; + top: 0; - border-left: 1px solid #aaa; - border-radius: 0 4px 4px 0; + border-left: 1px solid #aaa; + border-radius: 0 4px 4px 0; - background-clip: padding-box; + background-clip: padding-box; - background: #ccc; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #ccc), color-stop(0.6, #eee)); - background-image: -webkit-linear-gradient(center bottom, #ccc 0%, #eee 60%); - background-image: -moz-linear-gradient(center bottom, #ccc 0%, #eee 60%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#eeeeee', endColorstr = '#cccccc', GradientType = 0); - background-image: linear-gradient(to top, #ccc 0%, #eee 60%); + background: #ccc; + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #ccc), + color-stop(0.6, #eee) + ); + background-image: -webkit-linear-gradient(center bottom, #ccc 0%, #eee 60%); + background-image: -moz-linear-gradient(center bottom, #ccc 0%, #eee 60%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#eeeeee', endColorstr = '#cccccc', GradientType = 0); + background-image: linear-gradient(to top, #ccc 0%, #eee 60%); } .select2-container .select2-choice .select2-arrow b { - display: block; - width: 100%; - height: 100%; - background: url('select2.png') no-repeat 0 1px; + display: block; + width: 100%; + height: 100%; + background: url('select2.png') no-repeat 0 1px; } .select2-search { - display: inline-block; - width: 100%; - min-height: 26px; - margin: 0; - padding-left: 4px; - padding-right: 4px; + display: inline-block; + width: 100%; + min-height: 26px; + margin: 0; + padding-left: 4px; + padding-right: 4px; - position: relative; - z-index: 10000; + position: relative; + z-index: 10000; - white-space: nowrap; + white-space: nowrap; } .select2-search input { - width: 100%; - height: auto !important; - min-height: 26px; - padding: 4px 20px 4px 5px; - margin: 0; + width: 100%; + height: auto !important; + min-height: 26px; + padding: 4px 20px 4px 5px; + margin: 0; - outline: 0; - font-family: sans-serif; - font-size: 1em; + outline: 0; + font-family: sans-serif; + font-size: 1em; - border: 1px solid #aaa; - border-radius: 0; + border: 1px solid #aaa; + border-radius: 0; - -webkit-box-shadow: none; - box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; - background: #fff url('select2.png') no-repeat 100% -22px; - background: url('select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); - background: url('select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: url('select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: url('select2.png') no-repeat 100% -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; + background: #fff url('select2.png') no-repeat 100% -22px; + background: + url('select2.png') no-repeat 100% -22px, + -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); + background: + url('select2.png') no-repeat 100% -22px, + -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); + background: + url('select2.png') no-repeat 100% -22px, + -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); + background: + url('select2.png') no-repeat 100% -22px, + linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; } .select2-drop.select2-drop-above .select2-search input { - margin-top: 4px; + margin-top: 4px; } .select2-search input.select2-active { - background: #fff url('select2-spinner.gif') no-repeat 100%; - background: url('select2-spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); - background: url('select2-spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: url('select2-spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: url('select2-spinner.gif') no-repeat 100%, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; + background: #fff url('select2-spinner.gif') no-repeat 100%; + background: + url('select2-spinner.gif') no-repeat 100%, + -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); + background: + url('select2-spinner.gif') no-repeat 100%, + -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); + background: + url('select2-spinner.gif') no-repeat 100%, + -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); + background: + url('select2-spinner.gif') no-repeat 100%, + linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; } .select2-container-active .select2-choice, .select2-container-active .select2-choices { - border: 1px solid #5897fb; - outline: none; + border: 1px solid #5897fb; + outline: none; - -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, .3); - box-shadow: 0 0 5px rgba(0, 0, 0, .3); + -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); } .select2-dropdown-open .select2-choice { - border-bottom-color: transparent; - -webkit-box-shadow: 0 1px 0 #fff inset; - box-shadow: 0 1px 0 #fff inset; + border-bottom-color: transparent; + -webkit-box-shadow: 0 1px 0 #fff inset; + box-shadow: 0 1px 0 #fff inset; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; - background-color: #eee; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #fff), color-stop(0.5, #eee)); - background-image: -webkit-linear-gradient(center bottom, #fff 0%, #eee 50%); - background-image: -moz-linear-gradient(center bottom, #fff 0%, #eee 50%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0); - background-image: linear-gradient(to top, #fff 0%, #eee 50%); + background-color: #eee; + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #fff), + color-stop(0.5, #eee) + ); + background-image: -webkit-linear-gradient(center bottom, #fff 0%, #eee 50%); + background-image: -moz-linear-gradient(center bottom, #fff 0%, #eee 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0); + background-image: linear-gradient(to top, #fff 0%, #eee 50%); } .select2-dropdown-open.select2-drop-above .select2-choice, .select2-dropdown-open.select2-drop-above .select2-choices { - border: 1px solid #5897fb; - border-top-color: transparent; + border: 1px solid #5897fb; + border-top-color: transparent; - background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #fff), color-stop(0.5, #eee)); - background-image: -webkit-linear-gradient(center top, #fff 0%, #eee 50%); - background-image: -moz-linear-gradient(center top, #fff 0%, #eee 50%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0); - background-image: linear-gradient(to bottom, #fff 0%, #eee 50%); + background-image: -webkit-gradient( + linear, + left top, + left bottom, + color-stop(0, #fff), + color-stop(0.5, #eee) + ); + background-image: -webkit-linear-gradient(center top, #fff 0%, #eee 50%); + background-image: -moz-linear-gradient(center top, #fff 0%, #eee 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0); + background-image: linear-gradient(to bottom, #fff 0%, #eee 50%); } .select2-dropdown-open .select2-choice .select2-arrow { - background: transparent; - border-left: none; - filter: none; + background: transparent; + border-left: none; + filter: none; } .select2-dropdown-open .select2-choice .select2-arrow b { - background-position: -18px 1px; + background-position: -18px 1px; } .select2-hidden-accessible { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; } /* results */ .select2-results { - max-height: 200px; - padding: 0 0 0 4px; - margin: 4px 4px 4px 0; - position: relative; - overflow-x: hidden; - overflow-y: auto; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + max-height: 200px; + padding: 0 0 0 4px; + margin: 4px 4px 4px 0; + position: relative; + overflow-x: hidden; + overflow-y: auto; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } .select2-results ul.select2-result-sub { - margin: 0; - padding-left: 0; + margin: 0; + padding-left: 0; } .select2-results li { - list-style: none; - display: list-item; - background-image: none; + list-style: none; + display: list-item; + background-image: none; } .select2-results li.select2-result-with-children > .select2-result-label { - font-weight: bold; + font-weight: bold; } .select2-results .select2-result-label { - padding: 3px 7px 4px; - margin: 0; - cursor: pointer; + padding: 3px 7px 4px; + margin: 0; + cursor: pointer; - min-height: 1em; + min-height: 1em; - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } -.select2-results-dept-1 .select2-result-label { padding-left: 20px } -.select2-results-dept-2 .select2-result-label { padding-left: 40px } -.select2-results-dept-3 .select2-result-label { padding-left: 60px } -.select2-results-dept-4 .select2-result-label { padding-left: 80px } -.select2-results-dept-5 .select2-result-label { padding-left: 100px } -.select2-results-dept-6 .select2-result-label { padding-left: 110px } -.select2-results-dept-7 .select2-result-label { padding-left: 120px } +.select2-results-dept-1 .select2-result-label { + padding-left: 20px; +} +.select2-results-dept-2 .select2-result-label { + padding-left: 40px; +} +.select2-results-dept-3 .select2-result-label { + padding-left: 60px; +} +.select2-results-dept-4 .select2-result-label { + padding-left: 80px; +} +.select2-results-dept-5 .select2-result-label { + padding-left: 100px; +} +.select2-results-dept-6 .select2-result-label { + padding-left: 110px; +} +.select2-results-dept-7 .select2-result-label { + padding-left: 120px; +} .select2-results .select2-highlighted { - background: #3875d7; - color: #fff; + background: #3875d7; + color: #fff; } .select2-results li em { - background: #feffde; - font-style: normal; + background: #feffde; + font-style: normal; } .select2-results .select2-highlighted em { - background: transparent; + background: transparent; } .select2-results .select2-highlighted ul { - background: #fff; - color: #000; + background: #fff; + color: #000; } - .select2-results .select2-no-results, .select2-results .select2-searching, .select2-results .select2-selection-limit { - background: #f4f4f4; - display: list-item; - padding-left: 5px; + background: #f4f4f4; + display: list-item; + padding-left: 5px; } /* disabled look for disabled choices in the results dropdown */ .select2-results .select2-disabled.select2-highlighted { - color: #666; - background: #f4f4f4; - display: list-item; - cursor: default; + color: #666; + background: #f4f4f4; + display: list-item; + cursor: default; } .select2-results .select2-disabled { background: #f4f4f4; @@ -535,56 +675,61 @@ disabled look for disabled choices in the results dropdown } .select2-results .select2-selected { - display: none; + display: none; } .select2-more-results.select2-active { - background: #f4f4f4 url('select2-spinner.gif') no-repeat 100%; + background: #f4f4f4 url('select2-spinner.gif') no-repeat 100%; } .select2-more-results { - background: #f4f4f4; - display: list-item; + background: #f4f4f4; + display: list-item; } /* disabled styles */ .select2-container.select2-container-disabled .select2-choice { - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; + background-color: #f4f4f4; + background-image: none; + border: 1px solid #ddd; + cursor: default; } .select2-container.select2-container-disabled .select2-choice .select2-arrow { - background-color: #f4f4f4; - background-image: none; - border-left: 0; + background-color: #f4f4f4; + background-image: none; + border-left: 0; } .select2-container.select2-container-disabled .select2-choice abbr { - display: none; + display: none; } - /* multiselect */ .select2-container-multi .select2-choices { - height: auto !important; - height: 1%; - margin: 0; - padding: 0; - position: relative; + height: auto !important; + height: 1%; + margin: 0; + padding: 0; + position: relative; - border: 1px solid #aaa; - cursor: text; - overflow: hidden; + border: 1px solid #aaa; + cursor: text; + overflow: hidden; - background-color: #fff; - background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eee), color-stop(15%, #fff)); - background-image: -webkit-linear-gradient(top, #eee 1%, #fff 15%); - background-image: -moz-linear-gradient(top, #eee 1%, #fff 15%); - background-image: linear-gradient(to bottom, #eee 1%, #fff 15%); + background-color: #fff; + background-image: -webkit-gradient( + linear, + 0% 0%, + 0% 100%, + color-stop(1%, #eee), + color-stop(15%, #fff) + ); + background-image: -webkit-linear-gradient(top, #eee 1%, #fff 15%); + background-image: -moz-linear-gradient(top, #eee 1%, #fff 15%); + background-image: linear-gradient(to bottom, #eee 1%, #fff 15%); } .select2-locked { @@ -592,197 +737,218 @@ disabled look for disabled choices in the results dropdown } .select2-container-multi .select2-choices { - min-height: 26px; + min-height: 26px; } .select2-container-multi.select2-container-active .select2-choices { - border: 1px solid #5897fb; - outline: none; + border: 1px solid #5897fb; + outline: none; - -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, .3); - box-shadow: 0 0 5px rgba(0, 0, 0, .3); + -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); } .select2-container-multi .select2-choices li { - float: left; - list-style: none; + float: left; + list-style: none; } -html[dir="rtl"] .select2-container-multi .select2-choices li -{ - float: right; +html[dir='rtl'] .select2-container-multi .select2-choices li { + float: right; } .select2-container-multi .select2-choices .select2-search-field { - margin: 0; - padding: 0; - white-space: nowrap; + margin: 0; + padding: 0; + white-space: nowrap; } .select2-container-multi .select2-choices .select2-search-field input { - padding: 5px; - margin: 1px 0; + padding: 5px; + margin: 1px 0; - font-family: sans-serif; - font-size: 100%; - color: #666; - outline: 0; - border: 0; - -webkit-box-shadow: none; - box-shadow: none; - background: transparent !important; + font-family: sans-serif; + font-size: 100%; + color: #666; + outline: 0; + border: 0; + -webkit-box-shadow: none; + box-shadow: none; + background: transparent !important; } .select2-container-multi .select2-choices .select2-search-field input.select2-active { - background: #fff url('select2-spinner.gif') no-repeat 100% !important; + background: #fff url('select2-spinner.gif') no-repeat 100% !important; } .select2-default { - color: #999 !important; + color: #999 !important; } .select2-container-multi .select2-choices .select2-search-choice { - padding: 3px 5px 3px 18px; - margin: 3px 0 3px 5px; - position: relative; + padding: 3px 5px 3px 18px; + margin: 3px 0 3px 5px; + position: relative; - line-height: 13px; - color: #333; - cursor: default; - border: 1px solid #aaaaaa; + line-height: 13px; + color: #333; + cursor: default; + border: 1px solid #aaaaaa; - border-radius: 3px; + border-radius: 3px; - -webkit-box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05); - box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05); + -webkit-box-shadow: + 0 0 2px #fff inset, + 0 1px 0 rgba(0, 0, 0, 0.05); + box-shadow: + 0 0 2px #fff inset, + 0 1px 0 rgba(0, 0, 0, 0.05); - background-clip: padding-box; + background-clip: padding-box; - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; - background-color: #e4e4e4; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#f4f4f4', GradientType=0); - background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eee)); - background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); - background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); - background-image: linear-gradient(to top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); -} -html[dir="rtl"] .select2-container-multi .select2-choices .select2-search-choice -{ - margin-left: 0; - margin-right: 5px; + background-color: #e4e4e4; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#f4f4f4', GradientType=0); + background-image: -webkit-gradient( + linear, + 0% 0%, + 0% 100%, + color-stop(20%, #f4f4f4), + color-stop(50%, #f0f0f0), + color-stop(52%, #e8e8e8), + color-stop(100%, #eee) + ); + background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); + background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); + background-image: linear-gradient(to top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); +} +html[dir='rtl'] .select2-container-multi .select2-choices .select2-search-choice { + margin-left: 0; + margin-right: 5px; } .select2-container-multi .select2-choices .select2-search-choice .select2-chosen { - cursor: default; + cursor: default; } .select2-container-multi .select2-choices .select2-search-choice-focus { - background: #d4d4d4; + background: #d4d4d4; } .select2-search-choice-close { - display: block; - width: 12px; - height: 13px; - position: absolute; - right: 3px; - top: 4px; + display: block; + width: 12px; + height: 13px; + position: absolute; + right: 3px; + top: 4px; - font-size: 1px; - outline: none; - background: url('select2.png') right top no-repeat; + font-size: 1px; + outline: none; + background: url('select2.png') right top no-repeat; } -html[dir="rtl"] .select2-search-choice-close { - right: auto; - left: 3px; +html[dir='rtl'] .select2-search-choice-close { + right: auto; + left: 3px; } .select2-container-multi .select2-search-choice-close { - left: 3px; + left: 3px; } -.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover { +.select2-container-multi + .select2-choices + .select2-search-choice + .select2-search-choice-close:hover { background-position: right -11px; } -.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close { - background-position: right -11px; +.select2-container-multi + .select2-choices + .select2-search-choice-focus + .select2-search-choice-close { + background-position: right -11px; } /* disabled styles */ .select2-container-multi.select2-container-disabled .select2-choices { - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; + background-color: #f4f4f4; + background-image: none; + border: 1px solid #ddd; + cursor: default; } .select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice { - padding: 3px 5px 3px 5px; - border: 1px solid #ddd; - background-image: none; - background-color: #f4f4f4; + padding: 3px 5px 3px 5px; + border: 1px solid #ddd; + background-image: none; + background-color: #f4f4f4; } -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { display: none; - background: none; +.select2-container-multi.select2-container-disabled + .select2-choices + .select2-search-choice + .select2-search-choice-close { + display: none; + background: none; } /* end multiselect */ - .select2-result-selectable .select2-match, .select2-result-unselectable .select2-match { - text-decoration: underline; + text-decoration: underline; } -.select2-offscreen, .select2-offscreen:focus { - clip: rect(0 0 0 0) !important; - width: 1px !important; - height: 1px !important; - border: 0 !important; - margin: 0 !important; - padding: 0 !important; - overflow: hidden !important; - position: absolute !important; - outline: 0 !important; - left: 0px !important; - top: 0px !important; +.select2-offscreen, +.select2-offscreen:focus { + clip: rect(0 0 0 0) !important; + width: 1px !important; + height: 1px !important; + border: 0 !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + position: absolute !important; + outline: 0 !important; + left: 0px !important; + top: 0px !important; } .select2-display-none { - display: none; + display: none; } .select2-measure-scrollbar { - position: absolute; - top: -10000px; - left: -10000px; - width: 100px; - height: 100px; - overflow: scroll; + position: absolute; + top: -10000px; + left: -10000px; + width: 100px; + height: 100px; + overflow: scroll; } /* Retina-ize icons */ -@media only screen and (-webkit-min-device-pixel-ratio: 1.5), only screen and (min-resolution: 2dppx) { - .select2-search input, - .select2-search-choice-close, - .select2-container .select2-choice abbr, - .select2-container .select2-choice .select2-arrow b { - background-image: url('select2x2.png') !important; - background-repeat: no-repeat !important; - background-size: 60px 40px !important; - } +@media only screen and (-webkit-min-device-pixel-ratio: 1.5), + only screen and (min-resolution: 2dppx) { + .select2-search input, + .select2-search-choice-close, + .select2-container .select2-choice abbr, + .select2-container .select2-choice .select2-arrow b { + background-image: url('select2x2.png') !important; + background-repeat: no-repeat !important; + background-size: 60px 40px !important; + } - .select2-search input { - background-position: 100% -21px !important; - } + .select2-search input { + background-position: 100% -21px !important; + } } .task-checklist-edit > .checklist-form li:after, .task-filter:after, .filters:after, .filters .filters-controls:after { - content: ""; + content: ''; display: table; clear: both; } @@ -2232,7 +2398,11 @@ html[dir="rtl"] .select2-search-choice-close { font-weight: bold; letter-spacing: 0.0618em; color: #fff; - text-shadow: -1px -1px 1px #333, 1px -1px 1px #333, -1px 1px 1px #333, 1px 1px 1px #333; + text-shadow: + -1px -1px 1px #333, + 1px -1px 1px #333, + -1px 1px 1px #333, + 1px 1px 1px #333; } .herobox .avatar-level > a, .herobox .avatar-name > a, @@ -2270,8 +2440,8 @@ html[dir="rtl"] .select2-search-choice-close { left: 2%; width: 96%; height: 96%; - -webkit-box-shadow: 0 0 0 30px rgba(0,0,0,0.63); - box-shadow: 0 0 0 30px rgba(0,0,0,0.63); + -webkit-box-shadow: 0 0 0 30px rgba(0, 0, 0, 0.63); + box-shadow: 0 0 0 30px rgba(0, 0, 0, 0.63); } .toolbar-mobile > div h4:before, .toolbar-nav .toolbar-button-dropdown > div h4:before, @@ -2439,7 +2609,7 @@ html[dir="rtl"] .select2-search-choice-close { } @media screen and (max-width: 767px) { .toolbar-controls, -.toolbar-controls { + .toolbar-controls { width: 96%; position: fixed; bottom: 2%; @@ -2449,7 +2619,7 @@ html[dir="rtl"] .select2-search-choice-close { } @media screen and (min-width: 768px) { .toolbar-controls, -.toolbar-controls { + .toolbar-controls { display: none; } } @@ -2460,8 +2630,8 @@ html[dir="rtl"] .select2-search-choice-close { @media screen and (min-width: 768px) { .options-menu, .options-submenu, -.options-menu, -.options-submenu { + .options-menu, + .options-submenu { padding: 1em 1em 0em 1em; } } @@ -2550,26 +2720,66 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #763225 !important; outline: none; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #c65e4a !important; background-color: #e0a79c !important; outline: none; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > input + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li textarea + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li textarea + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + > input + + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + textarea + + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li + textarea + + button:active, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #d68c7d !important; } .task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -2594,14 +2804,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #e6b8af; border-color: #d68c7d; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #d68c7d; } @@ -2655,26 +2905,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #e2aea3 !important; border-color: #9a4230 !important; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #c65e4a !important; border-color: #e6b8af !important; color: #fff !important; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li button.active, @@ -2695,7 +2996,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-worst:not(.completed) .plusminus .task-checker label:after { border: 1px solid #c96652 !important; } -.task-column:not(.rewards) .color-worst:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-worst:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #b94f3a !important; box-shadow: inset 0 0 0 1px #b94f3a !important; background-color: #e1aaa0 !important; @@ -2745,17 +3051,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #c35440 !important; background-color: #db9a8e !important; outline: none; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-worst:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #d28071 !important; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close > a:nth-of-type(2), @@ -2775,9 +3101,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #d28071; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-worst:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #d28071; } @@ -2814,17 +3150,35 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #923e2e !important; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-worst:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #c35440 !important; border-color: #e1aaa0 !important; color: #fff !important; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-worst:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close button:focus, @@ -2848,7 +3202,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-worst:not(.completed) .task-actions a:focus { background-color: #b94f3a; } -.task-column:not(.rewards) .color-worst:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-worst:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-worst:not(.completed) input.habit:focus + a { background-color: #b94f3a; } @@ -2947,26 +3304,66 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #8d1e1e !important; outline: none; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #da5353 !important; background-color: #efb5b5 !important; outline: none; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > input + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li textarea + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li textarea + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + > input + + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + textarea + + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li + textarea + + button:active, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #e79090 !important; } .task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -2991,14 +3388,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #f4cccc; border-color: #e79090; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #e79090; } @@ -3052,26 +3489,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #f1bebe !important; border-color: #b82828 !important; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #da5353 !important; border-color: #f4cccc !important; color: #fff !important; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li button.active, @@ -3092,7 +3580,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-worse:not(.completed) .plusminus .task-checker label:after { border: 1px solid #dc5d5d !important; } -.task-column:not(.rewards) .color-worse:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-worse:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #d43939 !important; box-shadow: inset 0 0 0 1px #d43939 !important; background-color: #f0baba !important; @@ -3142,17 +3635,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #d74747 !important; background-color: #eba4a4 !important; outline: none; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-worse:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #e48181 !important; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close > a:nth-of-type(2), @@ -3172,9 +3685,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #e48181; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-worse:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #e48181; } @@ -3211,17 +3734,35 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #af2626 !important; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-worse:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #d74747 !important; border-color: #f0baba !important; color: #fff !important; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-worse:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close button:focus, @@ -3245,7 +3786,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-worse:not(.completed) .task-actions a:focus { background-color: #d43939; } -.task-column:not(.rewards) .color-worse:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-worse:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-worse:not(.completed) input.habit:focus + a { background-color: #d43939; } @@ -3344,11 +3888,21 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #a5590a !important; outline: none; } -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li textarea + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li textarea + button:focus { @@ -3356,14 +3910,29 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #fad7b2 !important; outline: none; } -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > input + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li textarea + button:active, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + textarea + + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li textarea + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #f8c187 !important; } .task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -3388,14 +3957,49 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #fce5cd; border-color: #f8c187; } -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #f8c187; } @@ -3453,22 +4057,69 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #f49b40 !important; border-color: #fce5cd !important; color: #fff !important; } -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li button.active, @@ -3489,7 +4140,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-bad:not(.completed) .plusminus .task-checker label:after { border: 1px solid #f4a24c !important; } -.task-column:not(.rewards) .color-bad:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-bad:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #f28b21 !important; box-shadow: inset 0 0 0 1px #f28b21 !important; background-color: #fbdab7 !important; @@ -3539,17 +4195,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #f49530 !important; background-color: #facd9e !important; outline: none; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #f7b874 !important; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close > a:nth-of-type(2), @@ -3569,9 +4245,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #f7b874; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-bad:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #f7b874; } @@ -3610,15 +4296,29 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-bad:not(.completed) .save-close.active.filters-tags a, .task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active.filters-tags a, .task-column:not(.rewards) .color-bad:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #f49530 !important; border-color: #fbdab7 !important; color: #fff !important; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-bad:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close button:focus, @@ -3642,7 +4342,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-bad:not(.completed) .task-actions a:focus { background-color: #f28b21; } -.task-column:not(.rewards) .color-bad:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-bad:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-bad:not(.completed) input.habit:focus + a { background-color: #f28b21; } @@ -3741,29 +4444,93 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #af8300 !important; outline: none; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > input + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > input + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > input + + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + > input + + button:focus, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > input + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li textarea + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li textarea + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + > input + + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + textarea + + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + textarea + + button:focus, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #ffcc35 !important; background-color: #ffebb0 !important; outline: none; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > input + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > input + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > input + + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + > input + + button:active, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > input + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li textarea + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li textarea + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + > input + + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + textarea + + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + textarea + + button:active, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #ffdf82 !important; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > a:nth-of-type(2), +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > a:nth-of-type(2), .task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > a:nth-of-type(2), .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > a:nth-of-type(2), .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > a:nth-of-type(2) { @@ -3785,14 +4552,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #fff2cc; border-color: #ffdf82; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #ffdf82; } @@ -3846,26 +4653,90 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #ffeeba !important; border-color: #e6ab00 !important; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active.filters-tags a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active.filters-tags button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li.active.filters-tags + a, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #ffcc35 !important; border-color: #fff2cc !important; color: #fff !important; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li button.active, @@ -3886,7 +4757,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-neutral:not(.completed) .plusminus .task-checker label:after { border: 1px solid #ffcf42 !important; } -.task-column:not(.rewards) .color-neutral:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #ffc314 !important; box-shadow: inset 0 0 0 1px #ffc314 !important; background-color: #ffecb5 !important; @@ -3936,21 +4812,45 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-neutral:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #ffc726 !important; background-color: #ffe59a !important; outline: none; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-neutral:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #ffda6e !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > a:nth-of-type(2), -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > a:nth-of-type(2) { border-left: 1px solid #ffe8a4 !important; } @media screen and (min-width: 768px) { @@ -3966,9 +4866,23 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #ffda6e; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .save-close + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #ffda6e; } @@ -4005,17 +4919,39 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #daa200 !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #ffc726 !important; border-color: #ffecb5 !important; color: #fff !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .save-close.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close button:focus, @@ -4039,7 +4975,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-neutral:not(.completed) .task-actions a:focus { background-color: #ffc314; } -.task-column:not(.rewards) .color-neutral:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-neutral:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-neutral:not(.completed) input.habit:focus + a { background-color: #ffc314; } @@ -4138,26 +5077,56 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #477337 !important; outline: none; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #84bb70 !important; background-color: #c9e1c0 !important; outline: none; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > input + button:active, .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li textarea + button:active, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + textarea + + button:active, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li textarea + button:active, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #afd3a2 !important; } .task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -4182,14 +5151,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #d9ead3; border-color: #afd3a2; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #afd3a2; } @@ -4243,26 +5252,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #cfe5c7 !important; border-color: #5c9748 !important; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #84bb70 !important; border-color: #d9ead3 !important; color: #fff !important; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li button.active, @@ -4283,7 +5343,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-good:not(.completed) .plusminus .task-checker label:after { border: 1px solid #8bbf79 !important; } -.task-column:not(.rewards) .color-good:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-good:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #71b05b !important; box-shadow: inset 0 0 0 1px #71b05b !important; background-color: #cce3c4 !important; @@ -4333,17 +5398,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-good:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #7bb666 !important; background-color: #bddbb2 !important; outline: none; } .task-column:not(.rewards) .color-good:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-good:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #a4cd96 !important; } .task-column:not(.rewards) .color-good:not(.completed) .save-close > a:nth-of-type(2), @@ -4363,9 +5448,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #a4cd96; } .task-column:not(.rewards) .color-good:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-good:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #a4cd96; } @@ -4402,17 +5497,35 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #588f44 !important; } .task-column:not(.rewards) .color-good:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-good:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #7bb666 !important; border-color: #cce3c4 !important; color: #fff !important; } .task-column:not(.rewards) .color-good:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-good:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-good:not(.completed) .save-close button:focus, @@ -4436,7 +5549,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-good:not(.completed) .task-actions a:focus { background-color: #71b05b; } -.task-column:not(.rewards) .color-good:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-good:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-good:not(.completed) input.habit:focus + a { background-color: #71b05b; } @@ -4535,26 +5651,81 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #3e6168 !important; outline: none; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > input + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li textarea + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li textarea + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + > input + + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + textarea + + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + textarea + + button:focus, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #77a5ae !important; background-color: #bfd5d9 !important; outline: none; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > input + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > input + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + > input + + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + > input + + button:active, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > input + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li textarea + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li textarea + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + > input + + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + textarea + + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + textarea + + button:active, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #a4c3c9 !important; } .task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -4579,14 +5750,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #d0e0e3; border-color: #a4c3c9; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #a4c3c9; } @@ -4640,26 +5851,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #c6d9dd !important; border-color: #518088 !important; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #77a5ae !important; border-color: #d0e0e3 !important; color: #fff !important; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li button.active, @@ -4680,7 +5942,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-better:not(.completed) .plusminus .task-checker label:after { border: 1px solid #7eaab2 !important; } -.task-column:not(.rewards) .color-better:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-better:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #6398a2 !important; box-shadow: inset 0 0 0 1px #6398a2 !important; background-color: #c2d7db !important; @@ -4730,21 +5997,45 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-better:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-better:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #6d9fa9 !important; background-color: #b2ccd2 !important; outline: none; } .task-column:not(.rewards) .color-better:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-better:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #98bbc2 !important; } .task-column:not(.rewards) .color-better:not(.completed) .save-close > a:nth-of-type(2), -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > a:nth-of-type(2) { border-left: 1px solid #b8d0d5 !important; } @media screen and (min-width: 768px) { @@ -4760,9 +6051,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #98bbc2; } .task-column:not(.rewards) .color-better:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-better:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #98bbc2; } @@ -4799,17 +6100,39 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #4d7982 !important; } .task-column:not(.rewards) .color-better:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-better:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #6d9fa9 !important; border-color: #c2d7db !important; color: #fff !important; } .task-column:not(.rewards) .color-better:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .save-close.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-better:not(.completed) .save-close button:focus, @@ -4833,7 +6156,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-better:not(.completed) .task-actions a:focus { background-color: #6398a2; } -.task-column:not(.rewards) .color-better:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-better:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-better:not(.completed) input.habit:focus + a { background-color: #6398a2; } @@ -4932,26 +6258,56 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #144398 !important; outline: none; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #4781e7 !important; background-color: #b0c9f5 !important; outline: none; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > input + button:active, .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li textarea + button:active, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + textarea + + button:active, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li textarea + button:active, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #89aef0 !important; } .task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -4976,14 +6332,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #c9daf8; border-color: #89aef0; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #89aef0; } @@ -5037,26 +6433,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #bad0f6 !important; border-color: #1a58c7 !important; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #4781e7 !important; border-color: #c9daf8 !important; color: #fff !important; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li button.active, @@ -5077,7 +6524,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-best:not(.completed) .plusminus .task-checker label:after { border: 1px solid #5288e9 !important; } -.task-column:not(.rewards) .color-best:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-best:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #2a6de3 !important; box-shadow: inset 0 0 0 1px #2a6de3 !important; background-color: #b5ccf5 !important; @@ -5127,17 +6579,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-best:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #3a77e4 !important; background-color: #9ebcf2 !important; outline: none; } .task-column:not(.rewards) .color-best:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-best:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #78a2ed !important; } .task-column:not(.rewards) .color-best:not(.completed) .save-close > a:nth-of-type(2), @@ -5157,9 +6629,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #78a2ed; } .task-column:not(.rewards) .color-best:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-best:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #78a2ed; } @@ -5196,17 +6678,35 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #1954bc !important; } .task-column:not(.rewards) .color-best:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-best:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #3a77e4 !important; border-color: #b5ccf5 !important; color: #fff !important; } .task-column:not(.rewards) .color-best:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-best:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-best:not(.completed) .save-close button:focus, @@ -5230,7 +6730,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-best:not(.completed) .task-actions a:focus { background-color: #2a6de3; } -.task-column:not(.rewards) .color-best:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-best:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-best:not(.completed) input.habit:focus + a { background-color: #2a6de3; } @@ -5260,7 +6763,7 @@ html[dir="rtl"] .select2-search-choice-close { } .completed .task-text .habitica-emoji { opacity: 0.39; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=39)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=39)'; filter: alpha(opacity=39); } .completed .priority-multiplier li > a, @@ -5480,7 +6983,7 @@ html[dir="rtl"] .select2-search-choice-close { .completed .plusminus .task-checker label:after { border: 1px solid #989898 !important; } -.completed .plusminus .task-checker input[type=checkbox]:checked + label:after { +.completed .plusminus .task-checker input[type='checkbox']:checked + label:after { -webkit-box-shadow: inset 0 0 0 1px #828282 !important; box-shadow: inset 0 0 0 1px #828282 !important; background-color: #cecece !important; @@ -5627,7 +7130,7 @@ html[dir="rtl"] .select2-search-choice-close { .completed .task-action-btn:focus { background-color: #828282; } -.completed input[type=checkbox]:focus + label { +.completed input[type='checkbox']:focus + label { background-color: #828282; } .completed .task-options { @@ -5668,7 +7171,7 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:after { clear: both; display: block; - content: ""; + content: ''; } .task-column h2 { color: #4c666e; @@ -5840,8 +7343,8 @@ html[dir="rtl"] .select2-search-choice-close { .task label { font-weight: 400; } -.task input[type="text"], -.task input[type="number"], +.task input[type='text'], +.task input[type='number'], .task textarea.option-content { border: 1px solid #aaa; -webkit-border-radius: 0.382em; @@ -5865,14 +7368,18 @@ html[dir="rtl"] .select2-search-choice-close { margin-left: 20px; } .task.ui-sortable-helper { - -webkit-box-shadow: 0 0 3px rgba(0,0,0,0.15), 0 0 5px rgba(0,0,0,0.1); - box-shadow: 0 0 3px rgba(0,0,0,0.15), 0 0 5px rgba(0,0,0,0.1); + -webkit-box-shadow: + 0 0 3px rgba(0, 0, 0, 0.15), + 0 0 5px rgba(0, 0, 0, 0.1); + box-shadow: + 0 0 3px rgba(0, 0, 0, 0.15), + 0 0 5px rgba(0, 0, 0, 0.1); -webkit-transform: scale(1.05); -moz-transform: scale(1.05); -o-transform: scale(1.05); -ms-transform: scale(1.05); transform: scale(1.05); - outline: 1px solid rgba(0,0,0,0.2); + outline: 1px solid rgba(0, 0, 0, 0.2); } .task-controls { display: inline-block; @@ -5898,7 +7405,7 @@ html[dir="rtl"] .select2-search-choice-close { text-align: center; color: #222; vertical-align: top; - border-right: 1px solid rgba(0,0,0,0.25); + border-right: 1px solid rgba(0, 0, 0, 0.25); } .task-action-btn:last-child { border: 0; @@ -5908,14 +7415,14 @@ html[dir="rtl"] .select2-search-choice-close { color: #222; text-decoration: none; } -.task-checker input[type=checkbox], -.task-checker input[type=checkbox]:focus { +.task-checker input[type='checkbox'], +.task-checker input[type='checkbox']:focus { position: absolute; margin: 0; padding: 0; height: 10px; opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); width: 10px; } @@ -5941,10 +7448,10 @@ html[dir="rtl"] .select2-search-choice-close { width: 2em; line-height: 1.5; } -.plusminus .task-checker label[for$="plus"]:after { +.plusminus .task-checker label[for$='plus']:after { content: '+'; } -.plusminus .task-checker label[for$="minus"]:after { +.plusminus .task-checker label[for$='minus']:after { content: '−'; } .action-yesno { @@ -5961,7 +7468,7 @@ html[dir="rtl"] .select2-search-choice-close { text-align: center; color: #000; opacity: 0.2; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=20)'; filter: alpha(opacity=20); } .action-yesno label:after { @@ -5978,15 +7485,15 @@ html[dir="rtl"] .select2-search-choice-close { .action-yesno label:focus:before { content: ''; } -.action-yesno input[type=checkbox]:focus + label { +.action-yesno input[type='checkbox']:focus + label { opacity: 1 !important; -ms-filter: none; filter: none; border: none; } .action-yesno label:hover:after { - content: "\E013"; - font-family: "Glyphicons Halflings"; + content: '\E013'; + font-family: 'Glyphicons Halflings'; border: none; margin: 0; line-height: 1.714285714em; @@ -5994,17 +7501,17 @@ html[dir="rtl"] .select2-search-choice-close { width: 1.714285714em; text-align: center; opacity: 0.5 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)" !important; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=50)' !important; filter: alpha(opacity=50) !important; } .action-yesno label:active:after { opacity: 0.75 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=75)" !important; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=75)' !important; filter: alpha(opacity=75) !important; } -.action-yesno input[type=checkbox]:checked + label:after { - content: "\E013"; - font-family: "Glyphicons Halflings"; +.action-yesno input[type='checkbox']:checked + label:after { + content: '\E013'; + font-family: 'Glyphicons Halflings'; border: none; margin: 0; line-height: 1.714285714em; @@ -6012,7 +7519,7 @@ html[dir="rtl"] .select2-search-choice-close { width: 1.714285714em; text-align: center; opacity: 0.75; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=75)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=75)'; filter: alpha(opacity=75); } .task-meta-controls { @@ -6020,7 +7527,7 @@ html[dir="rtl"] .select2-search-choice-close { margin: 0.75em 0.5em 0 0.5em; height: 1em; opacity: 0.25; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=25)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=25)'; filter: alpha(opacity=25); } .task-meta-controls a { @@ -6064,17 +7571,17 @@ form { color: #333; position: relative; }*/ -[class$="-options"] .option-group { +[class$='-options'] .option-group { padding: 0 0 1em; margin-bottom: 1em; margin-top: 1em; } -[class$="-options"] button.advanced-options-toggle { +[class$='-options'] button.advanced-options-toggle { display: block; width: 100%; background: none; } -[class$="-options"] .option-title { +[class$='-options'] .option-title { font-size: 1em; margin: 0.5em 0 0.5em; line-height: 1; @@ -6083,17 +7590,17 @@ form { font-weight: bold; text-align: center; } -[class$="-options"] .option-title.mega { +[class$='-options'] .option-title.mega { cursor: pointer; } -[class$="-options"] .option-title.mega:after { - font-family: "Glyphicons Halflings"; +[class$='-options'] .option-title.mega:after { + font-family: 'Glyphicons Halflings'; font-size: 0.75em; - content: "\E114"; + content: '\E114'; padding-left: 0.75em; } -[class$="-options"] .option-title.mega.active:after { - content: "\E113"; +[class$='-options'] .option-title.mega.active:after { + content: '\E113'; } .option-content { height: 2.5em; @@ -6135,12 +7642,12 @@ textarea.option-content { border: 0; font-size: 1.15em; font-weight: 300; - outline: 1px solid rgba(0,0,0,0.2); + outline: 1px solid rgba(0, 0, 0, 0.2); outline-offset: -1px; margin: 0 0 0 3px; text-align: inherit; opacity: 0.5; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=50)'; filter: alpha(opacity=50); width: auto; padding: 0 0.5em; @@ -6171,7 +7678,7 @@ textarea.option-content { } .tile.flush { margin-left: 0; - border: 1px solid rgba(0,0,0,0.2); + border: 1px solid rgba(0, 0, 0, 0.2); outline: 0; line-height: 2em; } @@ -6266,7 +7773,7 @@ textarea.option-content { } .task-checklist-edit > .checklist-form input { width: 70%; -/* Add interaction cues on hover and focus */ + /* Add interaction cues on hover and focus */ } .task-checklist-edit > .checklist-form input:hover, .task-checklist-edit > .checklist-form input:focus { @@ -6279,7 +7786,7 @@ textarea.option-content { } .task-checklist-edit > .checklist-form .checklist-icon { opacity: 0.25; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=25)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=25)'; filter: alpha(opacity=25); text-align: center; line-height: 1.5; @@ -6434,7 +7941,7 @@ textarea.option-content { -webkit-box-shadow: none; box-shadow: none; opacity: 0.65; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=65)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=65)'; filter: alpha(opacity=65); } .rewards .btn-buy span { @@ -6463,7 +7970,7 @@ textarea.option-content { margin: 15px auto; border: 1px solid #222; opacity: 0.2 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)" !important; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=20)' !important; filter: alpha(opacity=20) !important; } .locked-task .action-yesno label:focus, @@ -6533,7 +8040,7 @@ textarea.option-content { top: 4px; left: 4px; opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); -webkit-transition: opacity 0.2s ease-out; -moz-transition: opacity 0.2s ease-out; @@ -6589,7 +8096,7 @@ textarea.option-content { left: 4px; z-index: 2; opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } .herobox:hover .addthis_native_toolbox { @@ -6716,7 +8223,7 @@ menu { } .btn-buy input:focus { opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } input:focus + a.btn-buy { @@ -6730,7 +8237,7 @@ input:focus + a.btn-buy { .rewards { margin-bottom: 1.5em; padding-bottom: 1.5em; - border-bottom: 1px solid rgba(0,0,0,0.1); + border-bottom: 1px solid rgba(0, 0, 0, 0.1); } .reward-item { background: #fff; @@ -6755,8 +8262,8 @@ input:focus + a.btn-buy { padding-left: 0.25em; background-color: #d0e0e3; cursor: pointer; - -webkit-box-shadow: inset -1px -1px 0 rgba(0,0,0,0.1); - box-shadow: inset -1px -1px 0 rgba(0,0,0,0.1); + -webkit-box-shadow: inset -1px -1px 0 rgba(0, 0, 0, 0.1); + box-shadow: inset -1px -1px 0 rgba(0, 0, 0, 0.1); } .btn-reroll:hover, .btn-reroll:focus { @@ -6781,7 +8288,7 @@ input:focus + a.btn-buy { } .gem-wallet .add-gems-btn { opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } .gem-wallet:hover .add-gems-btn, @@ -6890,9 +8397,9 @@ menu.pets .customize-menu .progress { .mount-not-owned { width: 81px; height: 99px; -/* Would use css3 filters and just display the original pet image with a black hue, + /* Would use css3 filters and just display the original pet image with a black hue, but doesn't seem to work in Firefox or Opera */ -/*filter: brightness(0%) + /*filter: brightness(0%) -webkit-filter: brightness(0%) -moz-filter: brightness(0%) -o-filter: brightness(0%) @@ -6904,7 +8411,7 @@ menu.pets .customize-menu .progress { } .pet-evolved { opacity: 0.1; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=10)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=10)'; filter: alpha(opacity=10); } .selectableInventory { @@ -6925,7 +8432,7 @@ menu.pets .customize-menu .progress { height: 0; z-index: 1010; } -.new-stuff> .alert { +.new-stuff > .alert { border-top: 0; -webkit-border-radius: 0 0 4px 4px; border-radius: 0 0 4px 4px; @@ -7034,7 +8541,7 @@ menu.pets .customize-menu .progress { } .transparent { opacity: 0.5; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=50)'; filter: alpha(opacity=50); } .col-centered { @@ -7075,7 +8582,7 @@ menu.pets .customize-menu .progress { z-index: 100; padding: 46px 0 0 0; background-color: #c5d3d6; - border-bottom: 1px solid rgba(0,0,0,0.2); + border-bottom: 1px solid rgba(0, 0, 0, 0.2); overflow-y: hidden; overflow-x: auto; } @@ -7104,7 +8611,7 @@ menu.pets .customize-menu .progress { cursor: pointer; font-weight: 400; color: #494949; - color: rgba(38,38,38,0.8); + color: rgba(38, 38, 38, 0.8); background-color: #c2d7db; } .user-menu .tile:hover, @@ -7152,7 +8659,7 @@ menu.pets .customize-menu .progress { } .stacked .tile { outline: 0; - border: 1px solid rgba(0,0,0,0.2); + border: 1px solid rgba(0, 0, 0, 0.2); border-top: 0; } .site-header { @@ -7182,7 +8689,11 @@ menu.pets .customize-menu .progress { .hero-stats .meter-label { float: left; background-color: #b0c3c7 !important; - text-shadow: -1px -1px 1px #2f3f42, 1px -1px 1px #2f3f42, -1px 1px 1px #2f3f42, 1px 1px 1px #2f3f42; + text-shadow: + -1px -1px 1px #2f3f42, + 1px -1px 1px #2f3f42, + -1px 1px 1px #2f3f42, + 1px 1px 1px #2f3f42; width: 2.618em; text-align: center; margin-right: 0.618em; @@ -7243,7 +8754,7 @@ menu.pets .customize-menu .progress { .hero-stats .meter-text.value { right: 0.382em; } -[class^="quest_"] + .hero-stats { +[class^='quest_'] + .hero-stats { min-width: 220px; padding: 1.618em 0 1em; } @@ -7706,25 +9217,33 @@ button.party-invite { border-width: 4px 0 0; } .task-column::-webkit-scrollbar-track:hover { - background-color: rgba(150,150,150,0.05); - -webkit-box-shadow: inset 1px 0 0 rgba(150,150,150,0.1); - box-shadow: inset 1px 0 0 rgba(150,150,150,0.1); + background-color: rgba(150, 150, 150, 0.05); + -webkit-box-shadow: inset 1px 0 0 rgba(150, 150, 150, 0.1); + box-shadow: inset 1px 0 0 rgba(150, 150, 150, 0.1); } .task-column::-webkit-scrollbar-track:horizontal:hover { - -webkit-box-shadow: inset 0 1px 0 rgba(150,150,150,0.1); - box-shadow: inset 0 1px 0 rgba(150,150,150,0.1); + -webkit-box-shadow: inset 0 1px 0 rgba(150, 150, 150, 0.1); + box-shadow: inset 0 1px 0 rgba(150, 150, 150, 0.1); } .task-column::-webkit-scrollbar-track:active { - background-color: rgba(150,150,150,0.05); - -webkit-box-shadow: inset 1px 0 0 rgba(150,150,150,0.14), inset -1px 0 0 rgba(150,150,150,0.07); - box-shadow: inset 1px 0 0 rgba(150,150,150,0.14), inset -1px 0 0 rgba(150,150,150,0.07); + background-color: rgba(150, 150, 150, 0.05); + -webkit-box-shadow: + inset 1px 0 0 rgba(150, 150, 150, 0.14), + inset -1px 0 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 1px 0 0 rgba(150, 150, 150, 0.14), + inset -1px 0 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-track:horizontal:active { - -webkit-box-shadow: inset 0 1px 0 rgba(150,150,150,0.14), inset 0 -1px 0 rgba(150,150,150,0.07); - box-shadow: inset 0 1px 0 rgba(150,150,150,0.14), inset 0 -1px 0 rgba(150,150,150,0.07); + -webkit-box-shadow: + inset 0 1px 0 rgba(150, 150, 150, 0.14), + inset 0 -1px 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 0 1px 0 rgba(150, 150, 150, 0.14), + inset 0 -1px 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-thumb { - background-color: rgba(150,150,150,0.2); + background-color: rgba(150, 150, 150, 0.2); -webkit-background-clip: padding; -moz-background-clip: padding; background-clip: padding-box; @@ -7732,24 +9251,32 @@ button.party-invite { border-width: 1px 1px 1px 6px; min-height: 28px; padding: 100px 0 0; - -webkit-box-shadow: inset 1px 1px 0 rgba(150,150,150,0.1), inset 0 -1px 0 rgba(150,150,150,0.07); - box-shadow: inset 1px 1px 0 rgba(150,150,150,0.1), inset 0 -1px 0 rgba(150,150,150,0.07); + -webkit-box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.1), + inset 0 -1px 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.1), + inset 0 -1px 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-thumb:horizontal { border-width: 6px 1px 1px; padding: 0 0 0 100px; - -webkit-box-shadow: inset 1px 1px 0 rgba(150,150,150,0.1), inset -1px 0 0 rgba(150,150,150,0.07); - box-shadow: inset 1px 1px 0 rgba(150,150,150,0.1), inset -1px 0 0 rgba(150,150,150,0.07); + -webkit-box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.1), + inset -1px 0 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.1), + inset -1px 0 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-thumb:hover { - background-color: rgba(150,150,150,0.4); - -webkit-box-shadow: inset 1px 1px 1px rgba(150,150,150,0.25); - box-shadow: inset 1px 1px 1px rgba(150,150,150,0.25); + background-color: rgba(150, 150, 150, 0.4); + -webkit-box-shadow: inset 1px 1px 1px rgba(150, 150, 150, 0.25); + box-shadow: inset 1px 1px 1px rgba(150, 150, 150, 0.25); } .task-column::-webkit-scrollbar-thumb:active { - background-color: rgba(150,150,150,0.5); - -webkit-box-shadow: inset 1px 1px 3px rgba(150,150,150,0.35); - box-shadow: inset 1px 1px 3px rgba(150,150,150,0.35); + background-color: rgba(150, 150, 150, 0.5); + -webkit-box-shadow: inset 1px 1px 3px rgba(150, 150, 150, 0.35); + box-shadow: inset 1px 1px 3px rgba(150, 150, 150, 0.35); } .task-column::-webkit-scrollbar-track { border-width: 0 1px 0 6px; @@ -7758,9 +9285,13 @@ button.party-invite { border-width: 6px 0 1px; } .task-column::-webkit-scrollbar-track:hover { - background-color: rgba(150,150,150,0.035); - -webkit-box-shadow: inset 1px 1px 0 rgba(150,150,150,0.14), inset -1px -1px 0 rgba(150,150,150,0.07); - box-shadow: inset 1px 1px 0 rgba(150,150,150,0.14), inset -1px -1px 0 rgba(150,150,150,0.07); + background-color: rgba(150, 150, 150, 0.035); + -webkit-box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.14), + inset -1px -1px 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.14), + inset -1px -1px 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-thumb { border-width: 0 1px 0 6px; @@ -7817,7 +9348,7 @@ button.party-invite { } .chat-message .chat-plus-one { opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); background-color: #eee; padding: 3px 3px 0px 3px; @@ -7922,8 +9453,8 @@ button.party-invite { .party-chat markdown blockquote p:first-child { display: block; } -.tavern-chat markdown blockquote>:last-child, -.party-chat markdown blockquote>:last-child { +.tavern-chat markdown blockquote > :last-child, +.party-chat markdown blockquote > :last-child { margin-bottom: 0; } .panel-tiers div { @@ -7932,43 +9463,83 @@ button.party-invite { } .label-contributor-1 { background-color: #f57a9d !important; - text-shadow: -1px -1px 1px #660823, 1px -1px 1px #660823, -1px 1px 1px #660823, 1px 1px 1px #660823; + text-shadow: + -1px -1px 1px #660823, + 1px -1px 1px #660823, + -1px 1px 1px #660823, + 1px 1px 1px #660823; } .label-contributor-2 { background-color: #b93030 !important; - text-shadow: -1px -1px 1px #380e0e, 1px -1px 1px #380e0e, -1px 1px 1px #380e0e, 1px 1px 1px #380e0e; + text-shadow: + -1px -1px 1px #380e0e, + 1px -1px 1px #380e0e, + -1px 1px 1px #380e0e, + 1px 1px 1px #380e0e; } .label-contributor-3 { background-color: #f30 !important; - text-shadow: -1px -1px 1px #4d0f00, 1px -1px 1px #4d0f00, -1px 1px 1px #4d0f00, 1px 1px 1px #4d0f00; + text-shadow: + -1px -1px 1px #4d0f00, + 1px -1px 1px #4d0f00, + -1px 1px 1px #4d0f00, + 1px 1px 1px #4d0f00; } .label-contributor-4 { background-color: #ff9500 !important; - text-shadow: -1px -1px 1px #4d2d00, 1px -1px 1px #4d2d00, -1px 1px 1px #4d2d00, 1px 1px 1px #4d2d00; + text-shadow: + -1px -1px 1px #4d2d00, + 1px -1px 1px #4d2d00, + -1px 1px 1px #4d2d00, + 1px 1px 1px #4d2d00; } .label-contributor-5 { background-color: #fff700 !important; - text-shadow: -1px -1px 1px #4d4a00, 1px -1px 1px #4d4a00, -1px 1px 1px #4d4a00, 1px 1px 1px #4d4a00; + text-shadow: + -1px -1px 1px #4d4a00, + 1px -1px 1px #4d4a00, + -1px 1px 1px #4d4a00, + 1px 1px 1px #4d4a00; } .label-contributor-6 { background-color: #5eff00 !important; - text-shadow: -1px -1px 1px #1c4d00, 1px -1px 1px #1c4d00, -1px 1px 1px #1c4d00, 1px 1px 1px #1c4d00; + text-shadow: + -1px -1px 1px #1c4d00, + 1px -1px 1px #1c4d00, + -1px 1px 1px #1c4d00, + 1px 1px 1px #1c4d00; } .label-contributor-7 { background-color: #0af !important; - text-shadow: -1px -1px 1px #00334d, 1px -1px 1px #00334d, -1px 1px 1px #00334d, 1px 1px 1px #00334d; + text-shadow: + -1px -1px 1px #00334d, + 1px -1px 1px #00334d, + -1px 1px 1px #00334d, + 1px 1px 1px #00334d; } .label-contributor-8 { background-color: #130ead !important; - text-shadow: -1px -1px 1px #060434, 1px -1px 1px #060434, -1px 1px 1px #060434, 1px 1px 1px #060434; + text-shadow: + -1px -1px 1px #060434, + 1px -1px 1px #060434, + -1px 1px 1px #060434, + 1px 1px 1px #060434; } .label-contributor-9 { background-color: #88108f !important; - text-shadow: -1px -1px 1px #29052b, 1px -1px 1px #29052b, -1px 1px 1px #29052b, 1px 1px 1px #29052b; + text-shadow: + -1px -1px 1px #29052b, + 1px -1px 1px #29052b, + -1px 1px 1px #29052b, + 1px 1px 1px #29052b; } .label-npc { background-color: #000 !important; - text-shadow: -1px -1px 1px #000, 1px -1px 1px #000, -1px 1px 1px #000, 1px 1px 1px #000; + text-shadow: + -1px -1px 1px #000, + 1px -1px 1px #000, + -1px 1px 1px #000, + 1px 1px 1px #000; color: #0f0 !important; } #market-tab { @@ -8951,7 +10522,7 @@ li.spaced { } .toolbar-notifs > a span.inactive { opacity: 0.236 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=24)" !important; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=24)' !important; filter: alpha(opacity=24) !important; } .toolbar-notifs div { @@ -9540,14 +11111,16 @@ noscript.banner { animation-delay: -0.2s; } @-moz-keyframes sk-bouncedelay { - 0%, 80%, 100% { + 0%, + 80%, + 100% { -webkit-transform: scale(0); -moz-transform: scale(0); -o-transform: scale(0); -ms-transform: scale(0); transform: scale(0); opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } 40% { @@ -9562,14 +11135,16 @@ noscript.banner { } } @-webkit-keyframes sk-bouncedelay { - 0%, 80%, 100% { + 0%, + 80%, + 100% { -webkit-transform: scale(0); -moz-transform: scale(0); -o-transform: scale(0); -ms-transform: scale(0); transform: scale(0); opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } 40% { @@ -9584,14 +11159,16 @@ noscript.banner { } } @-o-keyframes sk-bouncedelay { - 0%, 80%, 100% { + 0%, + 80%, + 100% { -webkit-transform: scale(0); -moz-transform: scale(0); -o-transform: scale(0); -ms-transform: scale(0); transform: scale(0); opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } 40% { @@ -9606,14 +11183,16 @@ noscript.banner { } } @keyframes sk-bouncedelay { - 0%, 80%, 100% { + 0%, + 80%, + 100% { -webkit-transform: scale(0); -moz-transform: scale(0); -o-transform: scale(0); -ms-transform: scale(0); transform: scale(0); opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } 40% { @@ -9646,12 +11225,12 @@ td { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; - font-family: "Lato", sans-serif; + font-family: 'Lato', sans-serif; } hr { border-top: 0; border-bottom: 1px solid #ddd; - border-color: rgba(0,0,0,0.1); + border-color: rgba(0, 0, 0, 0.1); } /* Customizations to make footer sticky */ html, @@ -9688,7 +11267,7 @@ body { position: relative; } .gem-cost::before { - content: ""; + content: ''; display: block; width: 0; height: 0; @@ -9701,7 +11280,7 @@ body { margin-top: -6px; } .gem-cost::after { - content: ""; + content: ''; display: block; width: 0; height: 0; @@ -9786,7 +11365,7 @@ a.label { .buy-gems .gem-wallet .task-action-btn { -webkit-border-radius: 0 4px 0 0; border-radius: 0 4px 0 0; - border: 1px solid rgba(0,0,0,0.2); + border: 1px solid rgba(0, 0, 0, 0.2); } .badge-info { background-color: #428bca; diff --git a/www/build/static.css b/www/build/static.css index 70f4de911..51f435fe8 100644 --- a/www/build/static.css +++ b/www/build/static.css @@ -54,7 +54,7 @@ right: 0; top: 0; height: 2px; - opacity: .45; + opacity: 0.45; -moz-box-shadow: #29d 1px 0 6px 1px; -ms-box-shadow: #29d 1px 0 6px 1px; -webkit-box-shadow: #29d 1px 0 6px 1px; @@ -76,37 +76,67 @@ width: 14px; height: 14px; - border: solid 2px transparent; - border-top-color: #29d; + border: solid 2px transparent; + border-top-color: #29d; border-left-color: #29d; border-radius: 10px; -webkit-animation: loading-bar-spinner 400ms linear infinite; - -moz-animation: loading-bar-spinner 400ms linear infinite; - -ms-animation: loading-bar-spinner 400ms linear infinite; - -o-animation: loading-bar-spinner 400ms linear infinite; - animation: loading-bar-spinner 400ms linear infinite; + -moz-animation: loading-bar-spinner 400ms linear infinite; + -ms-animation: loading-bar-spinner 400ms linear infinite; + -o-animation: loading-bar-spinner 400ms linear infinite; + animation: loading-bar-spinner 400ms linear infinite; } @-webkit-keyframes loading-bar-spinner { - 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } } @-moz-keyframes loading-bar-spinner { - 0% { -moz-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -moz-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -moz-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } } @-o-keyframes loading-bar-spinner { - 0% { -o-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -o-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } } @-ms-keyframes loading-bar-spinner { - 0% { -ms-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -ms-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -ms-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } } @keyframes loading-bar-spinner { - 0% { transform: rotate(0deg); transform: rotate(0deg); } - 100% { transform: rotate(360deg); transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + transform: rotate(360deg); + } } .subscription-features tr td { @@ -256,7 +286,7 @@ body { .muted i, i.muted { opacity: 0.5; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=50)'; filter: alpha(opacity=50); } #header-play-button { @@ -302,13 +332,13 @@ a.h2.accordion { a.h2.accordion:before { font-family: 'Glyphicons Halflings'; color: #808080; - content: "\e114"; + content: '\e114'; margin-right: 0.5em; } a.h2.accordion.collapsed:before { font-family: 'Glyphicons Halflings'; color: #808080; - content: "\e080"; + content: '\e080'; margin-right: 0.5em; } .merch-block { diff --git a/www/css/main.diary.css b/www/css/main.diary.css index 474d57256..4fad2e975 100644 --- a/www/css/main.diary.css +++ b/www/css/main.diary.css @@ -1,78 +1,72 @@ .no-margin { - margin:0px !important; - padding:0px !important; + margin: 0px !important; + padding: 0px !important; } .item { - border:0px !important; + border: 0px !important; border-color: #fff; padding: 0 10px; /* Changed from 16px. This change was to ensure the correct alignment of the diary card */ } .main { - padding-top:50px; + padding-top: 50px; min-height: 100%; } .row { - padding:0px; + padding: 0px; } .col { - padding:0px !important; + padding: 0px !important; } .small { - font-size:7px; + font-size: 7px; } -.bg-color{ - background:#71bc98!important; - color:whitesmoke !important; +.bg-color { + background: #71bc98 !important; + color: whitesmoke !important; } -.summary-color{ - background:#1b9e77!important; - color:whitesmoke !important; +.summary-color { + background: #1b9e77 !important; + color: whitesmoke !important; } -.place-color{ - background:#7570b3!important; - color:whitesmoke !important; +.place-color { + background: #7570b3 !important; + color: whitesmoke !important; } - /* leaflet */ /* ----------- iPhone 5 and 5S ----------- */ /* Portrait and Landscape */ -@media only screen - and (min-device-width: 320px) - and (max-device-width: 568px) - and (-webkit-min-device-pixel-ratio: 2) { - .angular-leaflet-map { width: 100%; } +@media only screen and (min-device-width: 320px) and (max-device-width: 568px) and (-webkit-min-device-pixel-ratio: 2) { + .angular-leaflet-map { + width: 100%; + } } /* ----------- iPhone 6 ----------- */ /* Portrait and Landscape */ -@media only screen - and (min-device-width: 375px) - and (max-device-width: 667px) - and (-webkit-min-device-pixel-ratio: 2) { - .angular-leaflet-map { width: 100%; } +@media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (-webkit-min-device-pixel-ratio: 2) { + .angular-leaflet-map { + width: 100%; + } } /* ----------- iPhone 6+ ----------- */ /* Portrait and Landscape */ -@media only screen - and (min-device-width: 414px) - and (max-device-width: 736px) - and (-webkit-min-device-pixel-ratio: 3) { - .angular-leaflet-map { width: 100%; } +@media only screen and (min-device-width: 414px) and (max-device-width: 736px) and (-webkit-min-device-pixel-ratio: 3) { + .angular-leaflet-map { + width: 100%; + } } - .list .item.item-accordion { - transition: 0.09s all linear; } .list .item.item-accordion.ng-hide { @@ -110,16 +104,18 @@ a.item-content { font-size: 13px; line-height: 1; font-weight: 500; - box-shadow: 0 1px 2px rgb(0 0 0 / .1), 0 2px 3px rgb(0 0 0 / .12); + box-shadow: + 0 1px 2px rgb(0 0 0 / 0.1), + 0 2px 3px rgb(0 0 0 / 0.12); background-color: white; color: var(--accent-dark); - border: .12em solid var(--accent); + border: 0.12em solid var(--accent); } .diary-btn-yellow, .diary-btn-yellow:hover, .diary-btn-yellow:active { - background-color: #FFC108; /* tentatively orange for now */ + background-color: #ffc108; /* tentatively orange for now */ color: white; border: 2px solid rgba(0, 136, 206, 0.2); } @@ -135,7 +131,7 @@ a.item-content { .diary-btn:before { font-weight: bold; scale: 1.7; - margin-right: .6em; + margin-right: 0.6em; line-height: 100%; } @@ -188,19 +184,20 @@ a.item-content { font-size: 13px; line-height: 1.2; margin: 0; - border: 1px solid rgb(0 0 0 / .2); + border: 1px solid rgb(0 0 0 / 0.2); box-sizing: border-box; border-radius: 30px; position: relative; - box-shadow: 0 3px 4px rgb(0 0 0 / 5%), 0 4px 4px rgb(0 0 0 / 8%); + box-shadow: + 0 3px 4px rgb(0 0 0 / 5%), + 0 4px 4px rgb(0 0 0 / 8%); display: flex; flex-wrap: wrap; - background: linear-gradient(40deg, - hsla(200, 30%, 97%, 1) 40%, - hsla(0, 0%, 100%, 1)), + background: linear-gradient(40deg, hsla(200, 30%, 97%, 1) 40%, hsla(0, 0%, 100%, 1)); } -.diary-card.place, .diary-card.untracked { +.diary-card.place, +.diary-card.untracked { color: #222; background: hsl(200 100% 85%); border: 1px solid hsl(200 100% 10% / 0.2); @@ -209,23 +206,23 @@ a.item-content { } .diary-card.untracked { - color: #333; - /* untracked time will have a reddish color */ - --accent: hsl(350, 25%, 50%); - --accent-light: hsl(350, 65%, 85%); - --accent-dark: hsl(350, 65%, 30%); - - --grid: hsla(350, 25%, 80%, .2); - /* subtle x-grid lines in the background, fading to white */ - background: linear-gradient(15deg, - hsla(350, 10%, 92%, 1) 40%, - hsla(350, 10%, 100%, 0.5)), - repeating-linear-gradient(45deg, - var(--grid), var(--grid) 0px, - transparent 2px, transparent 20px), - repeating-linear-gradient(-45deg, - var(--grid), var(--grid) 0px, - #fff 2px, #fff 21px); + color: #333; + /* untracked time will have a reddish color */ + --accent: hsl(350, 25%, 50%); + --accent-light: hsl(350, 65%, 85%); + --accent-dark: hsl(350, 65%, 30%); + + --grid: hsla(350, 25%, 80%, 0.2); + /* subtle x-grid lines in the background, fading to white */ + background: linear-gradient(15deg, hsla(350, 10%, 92%, 1) 40%, hsla(350, 10%, 100%, 0.5)), + repeating-linear-gradient( + 45deg, + var(--grid), + var(--grid) 0px, + transparent 2px, + transparent 20px + ), + repeating-linear-gradient(-45deg, var(--grid), var(--grid) 0px, #fff 2px, #fff 21px); } .diary-card.untracked .card-title b { @@ -236,23 +233,24 @@ a.item-content { border-radius: 5px; } -.diary-card.draft, .diary-details.draft { +.diary-card.draft, +.diary-details.draft { /* draft trips will have a muted, greenish color */ --accent: hsl(150, 15%, 40%); --accent-light: hsl(150, 25%, 72%); --accent-dark: hsl(150, 35%, 30%); - --grid: hsla(150, 25%, 70%, .3); + --grid: hsla(150, 25%, 70%, 0.3); /* subtle grid lines in the background, fading to white */ - background: linear-gradient(30deg, - hsla(150, 4%, 94%, .9) 50%, - hsla(0, 0%, 100%, .5)), - repeating-linear-gradient(90deg, - var(--grid), var(--grid) 0px, - transparent 2px, transparent 20px), - repeating-linear-gradient(0deg, - var(--grid), var(--grid) 0px, - #fff 2px, #fff 21px); + background: linear-gradient(30deg, hsla(150, 4%, 94%, 0.9) 50%, hsla(0, 0%, 100%, 0.5)), + repeating-linear-gradient( + 90deg, + var(--grid), + var(--grid) 0px, + transparent 2px, + transparent 20px + ), + repeating-linear-gradient(0deg, var(--grid), var(--grid) 0px, #fff 2px, #fff 21px); } .card-title { @@ -304,7 +302,7 @@ a.item-content { @media screen and (orientation: portrait) { .hr-lines:before { - content: " "; + content: ' '; display: block; height: 2px; width: 30%; @@ -315,7 +313,7 @@ a.item-content { } .hr-lines:after { - content: " "; + content: ' '; display: block; height: 2px; width: 30%; @@ -327,7 +325,7 @@ a.item-content { } @media screen and (orientation: landscape) { .hr-lines:before { - content: " "; + content: ' '; display: block; height: 2px; width: 41%; @@ -338,7 +336,7 @@ a.item-content { } .hr-lines:after { - content: " "; + content: ' '; display: block; height: 2px; width: 41%; @@ -387,12 +385,13 @@ a.item-content { z-index: 0; } -.diary-map, .diary-map * { +.diary-map, +.diary-map * { pointer-events: none !important; } /* when trip notes are enabled, the map has rounded right corners */ -.enhanced-trip-item .diary-map > div{ +.enhanced-trip-item .diary-map > div { border-radius: 30px 0px 30px 0px; } @@ -479,11 +478,13 @@ a.item-content { background-color: var(--accent) !important; } -.ionic_datepicker_popup .popup-body .month_select, .ionic_datepicker_popup .popup-body .year_select { +.ionic_datepicker_popup .popup-body .month_select, +.ionic_datepicker_popup .popup-body .year_select { border-bottom: 1px solid var(--accent) !important; } -.ionic_datepicker_popup .popup-body .month_select:after, .ionic_datepicker_popup .popup-body .year_select:after { +.ionic_datepicker_popup .popup-body .month_select:after, +.ionic_datepicker_popup .popup-body .year_select:after { color: var(--accent) !important; } @@ -502,13 +503,15 @@ div.labelfilterlist { color: var(--accent); border-radius: 0px; border-width: 0; - box-shadow: 0 1px 2px rgba(0,0,0,0.16), 0 2px 2px rgba(0,0,0,0.23); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.16), + 0 2px 2px rgba(0, 0, 0, 0.23); padding: 0 0.1em !important; } .button.labelfilter.on { - background-color: var(--accent); - color: white; + background-color: var(--accent); + color: white; } .labelfilter:first-of-type { diff --git a/www/css/style.css b/www/css/style.css index dea003e7b..a2ac29368 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -6,8 +6,8 @@ .question.non-select { display: inline-block; } - .question input[name*="_date"], - .question input[name*="_time"] { + .question input[name*='_date'], + .question input[name*='_time'] { width: calc(40vw - 10px); margin-right: 5px; display: flex; @@ -15,7 +15,7 @@ } .enketo-plugin .form-header { - max-height: 50px; + max-height: 50px; } .fill-container { @@ -67,19 +67,21 @@ label-tab > div { text-align: center; } -[ng\:cloak], [ng-cloak], .ng-cloak { +[ng\:cloak], +[ng-cloak], +.ng-cloak { display: none !important; } .popup-title { - color: #6e6e6e; + color: #6e6e6e; } .pull-right { - float: right + float: right; } .pull-left { - float: left + float: left; } .button.button-icon.ion-help:before { @@ -87,14 +89,13 @@ label-tab > div { } .popup-buttons.row { - height: 40px !important; + height: 40px !important; } .popup-buttons.button { height: 40px !important; } .button.ng-binding.button-stable { height: 40px; - } .button.ng-binding.button-positive { background-color: var(--accent); @@ -103,31 +104,29 @@ label-tab > div { .button.ng-binding.button-assertive { background-color: var(--accent); height: 40px; - } .button.ng-binding.button-cancel { background-color: #d02001; height: 40px; - color: #ffffff + color: #ffffff; } .selected_date_full.ng-binding { color: var(--accent); } .icon.ion-chevron-left { - color: var(--accent); + color: var(--accent); } .icon.ion-chevron-right { - color: var(--accent); + color: var(--accent); } .date_col.date_selected { - background-color: var(--accent) !important; - + background-color: var(--accent) !important; } .date_col:active { - background-color: var(--accent) !important; + background-color: var(--accent) !important; } .customButtomIconSize:before { - font-size: 25px !important; + font-size: 25px !important; } #dashboard-footprint.card { @@ -138,7 +137,9 @@ label-tab > div { margin: 10px; margin-top: 55px; position: relative; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); /*background: rgba(40,218,183,1); background: -moz-linear-gradient(-45deg, rgba(40,218,183,1) 0%, rgba(99,230,199,1) 30%, rgba(94,235,212,1) 51%, rgba(87,235,223,1) 69%, rgba(99,237,226,1) 85%, rgba(127,250,250,1) 100%); background: -webkit-gradient(left top, right bottom, color-stop(0%, rgba(40,218,183,1)), color-stop(30%, rgba(99,230,199,1)), color-stop(51%, rgba(94,235,212,1)), color-stop(69%, rgba(87,235,223,1)), color-stop(85%, rgba(99,237,226,1)), color-stop(100%, rgba(127,250,250,1))); @@ -152,11 +153,11 @@ label-tab > div { overflow: hidden; } -.small-footprint-card{ +.small-footprint-card { height: 140px !important; } -.expanded-footprint-card{ +.expanded-footprint-card { height: 460px !important; } @@ -167,7 +168,9 @@ label-tab > div { display: block; margin: 10px; position: relative; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); /*background: rgba(40,218,183,1); background: -moz-linear-gradient(-45deg, rgba(40,218,183,1) 0%, rgba(99,230,199,1) 30%, rgba(94,235,212,1) 51%, rgba(87,235,223,1) 69%, rgba(99,237,226,1) 85%, rgba(127,250,250,1) 100%); background: -webkit-gradient(left top, right bottom, color-stop(0%, rgba(40,218,183,1)), color-stop(30%, rgba(99,230,199,1)), color-stop(51%, rgba(94,235,212,1)), color-stop(69%, rgba(87,235,223,1)), color-stop(85%, rgba(99,237,226,1)), color-stop(100%, rgba(127,250,250,1))); @@ -181,11 +184,11 @@ label-tab > div { overflow: hidden; } -.small-calorie-card{ +.small-calorie-card { height: 140px !important; } -.expanded-calorie-card{ +.expanded-calorie-card { height: 370px !important; } @@ -210,10 +213,12 @@ label-tab > div { display: block; /* height: 140px; */ margin: 10px; - margin-top:0px; + margin-top: 0px; position: relative; margin-bottom: 5px !important; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); /*background: rgba(40,218,183,1); background: -moz-linear-gradient(-45deg, rgba(40,218,183,1) 0%, rgba(99,230,199,1) 30%, rgba(94,235,212,1) 51%, rgba(87,235,223,1) 69%, rgba(99,237,226,1) 85%, rgba(127,250,250,1) 100%); background: -webkit-gradient(left top, right bottom, color-stop(0%, rgba(40,218,183,1)), color-stop(30%, rgba(99,230,199,1)), color-stop(51%, rgba(94,235,212,1)), color-stop(69%, rgba(87,235,223,1)), color-stop(85%, rgba(99,237,226,1)), color-stop(100%, rgba(127,250,250,1))); @@ -225,12 +230,12 @@ label-tab > div { text-align: center; } -#arrow-color{ +#arrow-color { color: var(--accent); font-size: 25px !important; } -h4.dashboard-headers{ +h4.dashboard-headers { color: #fff; background: var(--accent); padding-top: 5px; @@ -241,55 +246,55 @@ h4.dashboard-headers{ margin-bottom: 0px !important; } -.user-carbon-no-percentage{ +.user-carbon-no-percentage { padding-top: 30px; position: absolute; width: 100%; } -.user-carbon-percentage{ +.user-carbon-percentage { padding-top: 10px; position: absolute; width: 100%; } -.user-carbon{ +.user-carbon { font-weight: 700; color: var(--accent); font-size: 16px; } -.user-calorie-no-percentage{ +.user-calorie-no-percentage { padding-top: 30px; position: absolute; width: 100%; } -.user-calorie-percentage{ +.user-calorie-percentage { padding-top: 10px; position: absolute; width: 100%; } -.user-calorie{ +.user-calorie { font-weight: 700; color: var(--accent); font-size: 18px; } -.percentage-change{ +.percentage-change { font-weight: 700; color: var(--accent); margin-bottom: 20px; } -.calorie-change{ +.calorie-change { padding-top: 5px; font-weight: 700; color: var(--accent); } -.dashboard-list{ +.dashboard-list { padding-top: 10px; font-weight: 700; color: #fff; @@ -306,7 +311,9 @@ h4.dashboard-headers{ width: 60px; height: 60px; background: #fff; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); -moz-border-radius: 50px; -webkit-border-radius: 50px; border-radius: 50px; @@ -316,43 +323,47 @@ h4.dashboard-headers{ margin-top: 10px; } -#circle-food.circle{ +#circle-food.circle { position: relative !important; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1), 0 2px 3px rgba(0, 0, 0, 0.05) !important; - margin:auto; + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.1), + 0 2px 3px rgba(0, 0, 0, 0.05) !important; + margin: auto; float: none; display: inline-block; margin-right: 20px; margin-top: 20px; } -#circle-food.circle:active{ +#circle-food.circle:active { background-color: #eeeeee; - box-shadow: 0 0px 0px rgba(0, 0, 0, 0.1), 0 0px 0px rgba(0, 0, 0, 0.05) !important; + box-shadow: + 0 0px 0px rgba(0, 0, 0, 0.1), + 0 0px 0px rgba(0, 0, 0, 0.05) !important; } -#green-leaf{ +#green-leaf { color: var(--accent-light); font-size: 45px; padding-top: 5px; } -#food{ +#food { width: 45px; padding-top: 7px; } -#foodB{ +#foodB { width: 45px; padding-top: 7px; padding-right: 4px; } -.arrow-position{ +.arrow-position { position: absolute; bottom: 5px; right: 10px; - color:#b2b2b2; + color: #b2b2b2; font-size: 20px; } @@ -360,9 +371,9 @@ h4.dashboard-headers{ height:245px !important; } */ -#modes.slider-slide{ +#modes.slider-slide { padding-top: 0 !important; - background-color:transparent; + background-color: transparent; } /*.ion-view-background-dashboard{ @@ -376,12 +387,14 @@ h4.dashboard-headers{ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#87f5f5', endColorstr='#5ffad8', GradientType=0 ); }*/ -.bar.bar-header.no-bgColor, .bar.bar-footer.no-bgColor{ -border: 0px !important; -border-color: transparent !important; -border-top: transparent !important; -border-bottom: transparent !important; -background-image: none !important; } +.bar.bar-header.no-bgColor, +.bar.bar-footer.no-bgColor { + border: 0px !important; + border-color: transparent !important; + border-top: transparent !important; + border-bottom: transparent !important; + background-image: none !important; +} .list .item.item-accordion { line-height: 38px; @@ -414,24 +427,24 @@ background-image: none !important; } background: white; border-radius: 50px; color: #222; - border: 1px solid rgb(0 0 0 / .2); + border: 1px solid rgb(0 0 0 / 0.2); padding: 3px 20px; margin: auto; display: block; } /* Light theme */ -.control-icon-button{ +.control-icon-button { text-align: center; max-height: 56px; background-color: #6c757d; color: #fff; padding-top: 16px; width: 64px; - font-size:20px; + font-size: 20px; } -.diary-button{ +.diary-button { text-align: center; float: right; height: 48px; @@ -442,7 +455,7 @@ background-image: none !important; } width: 48px; /* Changed to fit the diary card in full view */ } -.control-version-number{ +.control-version-number { text-align: center; float: right; height: 100%; @@ -450,32 +463,37 @@ background-image: none !important; } padding-top: 16px; width: 64px; - font-size:20px; + font-size: 20px; } -#switch-user.control-icon-button{ +#switch-user.control-icon-button { background-color: #dc3545 !important; } -.gray-icon.control-icon-button{ - background-color: #CCCCCC !important; +.gray-icon.control-icon-button { + background-color: #cccccc !important; } -.toggle-on-ourcolor-bg{ +.toggle-on-ourcolor-bg { border-color: var(--accent) !important; background-color: var(--accent) !important; } -.control-info{ +.control-info { padding: 2px 4px !important; } -.tab-nav{ - background-color: #f5f5f5 !important; background-size: 0 !important; - box-shadow: 0 1px 2px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); -} -.tab-item.tab-item-active, .tab-item.active, .tab-item.activated { - color: var(--accent); +.tab-nav { + background-color: #f5f5f5 !important; + background-size: 0 !important; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.16), + 0 3px 6px rgba(0, 0, 0, 0.23); +} +.tab-item.tab-item-active, +.tab-item.active, +.tab-item.activated { + color: var(--accent); } .platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader) > * { margin-top: 15px; @@ -485,7 +503,9 @@ background-image: none !important; } } ion-header-bar { background-color: #f5f5f5 !important; - box-shadow: 0 1px 2px rgb(0 0 0 / 8%), 0 3px 6px rgb(0 0 0 / 12%); + box-shadow: + 0 1px 2px rgb(0 0 0 / 8%), + 0 3px 6px rgb(0 0 0 / 12%); } ion-nav-view { z-index: 10; @@ -496,29 +516,37 @@ ion-nav-view { } .tabs-custom > .tabs, .tabs.tabs-custom { - border-color: #5D3A23; - background-color: #5D3A23; + border-color: #5d3a23; + background-color: #5d3a23; background-image: linear-gradient(0deg, #0c60ee, #0c60ee 70%, transparent 70%); - color: #999; } - .tabs-custom > .tabs .tab-item .badge, - .tabs.tabs-custom .tab-item .badge { - background-color: #999; - color: #387ef5; } + color: #999; +} +.tabs-custom > .tabs .tab-item .badge, +.tabs.tabs-custom .tab-item .badge { + background-color: #999; + color: #387ef5; +} .tabs-striped.tabs-custom .tabs { - background-color: #5D3A23; } + background-color: #5d3a23; +} .tabs-striped.tabs-custom .tab-item { color: rgba(255, 255, 255, 0.7); - opacity: 1; } - .tabs-striped.tabs-custom .tab-item .badge { - opacity: 0.7; } - .tabs-striped.tabs-custom .tab-item.tab-item-active, .tabs-striped.tabs-custom .tab-item.active, .tabs-striped.tabs-positive .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } + opacity: 1; +} +.tabs-striped.tabs-custom .tab-item .badge { + opacity: 0.7; +} +.tabs-striped.tabs-custom .tab-item.tab-item-active, +.tabs-striped.tabs-custom .tab-item.active, +.tabs-striped.tabs-positive .tab-item.activated { + margin-top: -2px; + color: #fff; + border-style: solid; + border-width: 2px 0 0 0; + border-color: #fff; +} .title.title-center.header-item { color: #303030; } @@ -526,9 +554,15 @@ ion-nav-view { display: none; } .date-picker-button { - color: var(--accent) !important; padding: 0 15px; border-color: transparent; margin-top: 4px; - /* box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); */ - border-style: solid; border-color: white; border-width: 0px; border-radius: 5px; + color: var(--accent) !important; + padding: 0 15px; + border-color: transparent; + margin-top: 4px; + /* box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); */ + border-style: solid; + border-color: white; + border-width: 0px; + border-radius: 5px; } .button.date-picker-button { @@ -536,11 +570,12 @@ ion-nav-view { } .date-picker-arrow { - color: #303030 !important; margin-top: 4px; background-color: transparent !important; + color: #303030 !important; + margin-top: 4px; + background-color: transparent !important; } /* Light theme ends */ - .earlier-later-expand { color: #303030; margin: 16px 16px 0 6px; @@ -555,7 +590,7 @@ ion-nav-view { padding-bottom: 5px; padding-left: 30px; margin-top: 0 !important; - margin-bottom: 0!important; + margin-bottom: 0 !important; } p.list-text { color: #303030; @@ -570,38 +605,52 @@ a.list-text { } .card-1 { - box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); - transition: all 0.3s cubic-bezier(.25,.8,.25,1); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.24); + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); } .card-1:hover { - box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); + box-shadow: + 0 14px 28px rgba(0, 0, 0, 0.25), + 0 10px 10px rgba(0, 0, 0, 0.22); } .card-2 { - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); + box-shadow: + 0 3px 6px rgba(0, 0, 0, 0.16), + 0 3px 6px rgba(0, 0, 0, 0.23); } .card-3 { - box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); + box-shadow: + 0 10px 20px rgba(0, 0, 0, 0.19), + 0 6px 6px rgba(0, 0, 0, 0.23); } .card-4 { - box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); + box-shadow: + 0 14px 28px rgba(0, 0, 0, 0.25), + 0 10px 10px rgba(0, 0, 0, 0.22); } .card-5 { - box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22); + box-shadow: + 0 19px 38px rgba(0, 0, 0, 0.3), + 0 15px 12px rgba(0, 0, 0, 0.22); } button.button.ng-binding i.icon.ion-edit { font-size: 12px; } button.button.back-button.buttons.button-clear.header-item { - color: #303030; opacity: 0.7; + color: #303030; + opacity: 0.7; } .nav-bar-title { - color: #303030; opacity: 0.7; + color: #303030; + opacity: 0.7; } /* Profile tab */ .control-list-item { @@ -619,20 +668,25 @@ button.button.back-button.buttons.button-clear.header-item { display: -webkit-box; line-height: 1.1; -webkit-line-clamp: 5; /* number of lines to show */ - line-clamp: 5; + line-clamp: 5; -webkit-box-orient: vertical; text-overflow: ellipsis; } .control-list-toggle { - float: right; margin-top: 5px; margin-right: 2px; + float: right; + margin-top: 5px; + margin-right: 2px; } /* Diary list tab */ .lightrail { - color: blue + color: blue; } .dev-zone-input { - padding: 7px 0; font-size: 16px; line-height: 22px; height: 36px; + padding: 7px 0; + font-size: 16px; + line-height: 22px; + height: 36px; } .dev-zone-title { padding: 18px 16px; @@ -644,14 +698,16 @@ button.button.back-button.buttons.button-clear.header-item { } .list-card { margin: 16px 0; - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); + box-shadow: + 0 3px 6px rgba(0, 0, 0, 0.16), + 0 3px 6px rgba(0, 0, 0, 0.23); border: 1px solid #ccc; } .bg-light { - background-color: #ffffff; + background-color: #ffffff; } .bg-unprocessed { - background-color: #9eb2aa; + background-color: #9eb2aa; } .list-card-sm { width: 95%; @@ -660,20 +716,38 @@ button.button.back-button.buttons.button-clear.header-item { width: 95%; } .list-card-lg { - width: 95%; + width: 95%; } .list-card .row { padding-left: 5px; padding-right: 5px; } .list-col-left-margin { - text-align: center; padding: 0.7em 0.8em 0.4em 0.8em; border-right-width: 0.5px; border-right-color: #ccc; border-right-style: solid; border-bottom-color: #ccc; border-bottom-width: 0.5px; border-bottom-style: solid; + text-align: center; + padding: 0.7em 0.8em 0.4em 0.8em; + border-right-width: 0.5px; + border-right-color: #ccc; + border-right-style: solid; + border-bottom-color: #ccc; + border-bottom-width: 0.5px; + border-bottom-style: solid; } .list-col-left { - text-align: center; padding: 1.1em 0.8em 0.6em 0.8em; border-right-width: 0.5px; border-right-color: #ccc; border-right-style: solid; border-bottom-color: #ccc; border-bottom-width: 0.5px; border-bottom-style: solid; + text-align: center; + padding: 1.1em 0.8em 0.6em 0.8em; + border-right-width: 0.5px; + border-right-color: #ccc; + border-right-style: solid; + border-bottom-color: #ccc; + border-bottom-width: 0.5px; + border-bottom-style: solid; } .list-col-right { - text-align: center; padding: 0.25em 0.8em; border-bottom-color: #ccc; border-bottom-width: 0.5px; border-bottom-style: solid; + text-align: center; + padding: 0.25em 0.8em; + border-bottom-color: #ccc; + border-bottom-width: 0.5px; + border-bottom-style: solid; } timestamp-badge { @@ -703,15 +777,15 @@ timestamp-badge[light-bg] { } .diary-checkmark-container i.can-verify { - color: #30A64A; + color: #30a64a; background-color: #ddd; border-radius: 5px; } .diary-checkmark-container i.cannot-verify { - color: #E6B8B8; + color: #e6b8b8; } .diary-checkmark-container i.already-verified { - color: #B8E6C2; + color: #b8e6c2; } /* .diary-checkmark-container i.already-verified, .diary-checkmark-container i.cannot-verify { color: #BFBFBF; @@ -740,22 +814,24 @@ timestamp-badge[light-bg] { .metric-datepicker { /*height: 33px;*/ - display: flex; /* establish flex container */ + display: flex; /* establish flex container */ /*flex-direction: column; make main axis vertical */ justify-content: center; /* center items vertically, in this case */ - align-items: center; /* center items horizontally, in this case */ + align-items: center; /* center items horizontally, in this case */ border-radius: 5px; background-color: white; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05); + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } .metric-title { height: 35px; - display: flex; /* establish flex container */ - flex-direction: column; /* make main axis vertical */ + display: flex; /* establish flex container */ + flex-direction: column; /* make main axis vertical */ justify-content: center; /* center items vertically, in this case */ - align-items: left; /* center items horizontally, in this case */ + align-items: left; /* center items horizontally, in this case */ padding-left: 10px; } @@ -768,7 +844,9 @@ timestamp-badge[light-bg] { float: right; border-radius: 5px; background-color: white; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05); + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } @@ -778,7 +856,9 @@ timestamp-badge[light-bg] { top: 40px; border-radius: 5px; background-color: white; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05); + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } @@ -796,17 +876,19 @@ timestamp-badge[light-bg] { width: 75%; float: right; } -.metric-change-data-button{ +.metric-change-data-button { margin: auto; width: 120px; border-radius: 20px; background-color: white; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); color: var(--accent); font-weight: 700; height: 30px; } -.metric-change-data-button:active{ +.metric-change-data-button:active { background-color: var(--accent); color: white; box-shadow: none; @@ -816,7 +898,9 @@ timestamp-badge[light-bg] { width: 49%; border-radius: 5px; background-color: white; - box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.03), + 0 1px 2px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } @@ -825,22 +909,23 @@ timestamp-badge[light-bg] { width: 33%; border-radius: 5px; background-color: white; - box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.03), + 0 1px 2px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; - } .current-mode-button { border: none; - background-color:#2D9CDB; - display:inline-block; - cursor:pointer; - color:#ffffff; + background-color: #2d9cdb; + display: inline-block; + cursor: pointer; + color: #ffffff; opacity: 0.4; - font-size:28px; + font-size: 28px; width: 100%; - text-decoration:none; + text-decoration: none; height: 80px; z-index: 1; position: relative; @@ -854,11 +939,13 @@ timestamp-badge[light-bg] { display: block; width: 40%; height: 25px; - background-color: #f5f5f5;; + background-color: #f5f5f5; border-radius: 10px; - color: #6A6A6A; + color: #6a6a6a; left: 30%; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.05), + 0 1px 2px rgba(0, 0, 0, 0.05); } #current-start-time-text { @@ -876,8 +963,10 @@ timestamp-badge[light-bg] { } #current-speed { - background-color: #8F8F8F; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 2px 4px rgba(0, 0, 0, 0.05); + background-color: #8f8f8f; + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.2), + 0 2px 4px rgba(0, 0, 0, 0.05); opacity: 0.9; color: white; width: 60px; @@ -885,7 +974,7 @@ timestamp-badge[light-bg] { height: 60px; border-style: solid; border-radius: 50%; - border-color: #6A6A6A; + border-color: #6a6a6a; border-width: 4px; } @@ -901,7 +990,7 @@ timestamp-badge[light-bg] { } #current-direction-text { - color:#6A6A6A; + color: #6a6a6a; font-size: 20px; font-weight: 600; margin-top: 5px; @@ -917,21 +1006,25 @@ timestamp-badge[light-bg] { .report-button { border-radius: 10px; border: none; - background-color: #E34949; + background-color: #e34949; color: #ffffff; font-size: 20px; width: 60%; height: 35px; z-index: 1; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.05); + box-shadow: + 0 4px 8px rgba(0, 0, 0, 0.3), + 0 2px 4px rgba(0, 0, 0, 0.05); position: absolute; display: block; bottom: 40px; left: 20%; } -.report-button:active{ - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1), 0 1px 1px rgba(0, 0, 0, 0.05) !important; +.report-button:active { + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.1), + 0 1px 1px rgba(0, 0, 0, 0.05) !important; } .metric-freq-button { @@ -939,7 +1032,9 @@ timestamp-badge[light-bg] { width: 100%; border-radius: 5px; background-color: white; - box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.03), + 0 1px 2px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } @@ -959,25 +1054,30 @@ timestamp-badge[light-bg] { height: 35px; } .hvcenter { - display: flex; /* establish flex container */ - flex-direction: column; /* make main axis vertical */ + display: flex; /* establish flex container */ + flex-direction: column; /* make main axis vertical */ justify-content: center; /* center items vertically, in this case */ - align-items: center; /* center items horizontally, in this case */ + align-items: center; /* center items horizontally, in this case */ } .metric-basic { width: 100%; border-radius: 5px; background-color: white; - box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.03), + 0 1px 2px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } .metric-half { - float: left; + float: left; width: 100%; border-radius: 5px; background-color: white; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); color: #01D0A7; + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); + color: #01d0a7; height: 30px; overflow: hidden; position: relative; @@ -1095,8 +1195,8 @@ timestamp-badge[light-bg] { border-top-left-radius: 5px; border-bottom-left-radius: 5px; } -.distance-button{ - width:25%; +.distance-button { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; @@ -1105,8 +1205,8 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.speed-button{ - width:25%; +.speed-button { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; @@ -1115,24 +1215,24 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.trips-button{ - width:25%; +.trips-button { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; float: left; height: 30px; } -.duration-button{ - width:25%; +.duration-button { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; float: left; height: 30px; } -.distance-button-active{ - width:25%; +.distance-button-active { + width: 25%; font-size: 12px; font-weight: 700; background-color: var(--accent); @@ -1143,8 +1243,8 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.speed-button-active{ - width:25%; +.speed-button-active { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; @@ -1155,8 +1255,8 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.trips-button-active{ - width:25%; +.trips-button-active { + width: 25%; font-size: 12px; font-weight: 700; background-color: var(--accent); @@ -1165,8 +1265,8 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.duration-button-active{ - width:25%; +.duration-button-active { + width: 25%; font-size: 12px; font-weight: 700; background-color: var(--accent); @@ -1182,7 +1282,6 @@ timestamp-badge[light-bg] { height: 33px; } .metric-me-toggle { - } .metric-icon { color: #ccc; @@ -1192,10 +1291,10 @@ timestamp-badge[light-bg] { position: absolute; width: 15%; height: 35px; - display: flex; /* establish flex container */ - flex-direction: column; /* make main axis vertical */ + display: flex; /* establish flex container */ + flex-direction: column; /* make main axis vertical */ justify-content: center; /* center items vertically, in this case */ - align-items: left; /* center items horizontally, in this case */ + align-items: left; /* center items horizontally, in this case */ padding-left: 10px; } .metric-filter-year { @@ -1204,7 +1303,9 @@ timestamp-badge[light-bg] { left: 21%; height: 35px; border-width: 0; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05) !important; + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05) !important; } .metric-filter-month { position: absolute; @@ -1212,7 +1313,9 @@ timestamp-badge[light-bg] { left: 42%; height: 35px; border-width: 0; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05) !important; + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05) !important; } .metric-filter-day { position: absolute; @@ -1220,7 +1323,9 @@ timestamp-badge[light-bg] { left: 57%; height: 35px; border-width: 0; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05) !important; + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05) !important; } .metric-summary-title { padding: 2px; @@ -1237,7 +1342,6 @@ timestamp-badge[light-bg] { margin-top: 10px; width: 40px !important; margin-left: 10px; - } .metric-summary-right { margin-left: 40px; @@ -1259,12 +1363,12 @@ timestamp-badge[light-bg] { width: 80px; margin-right: 5px; margin-top: -28px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1), 0 1px 1px rgba(0, 0, 0, 0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 1px 1px rgba(0, 0, 0, 0.05); } .full-toggle-container { - height: 35px; - - + height: 35px; } .full-toggle-left { width: 50%; @@ -1317,7 +1421,7 @@ timestamp-badge[light-bg] { color: white; } .unit-toggle-container { - height: 35px; + height: 35px; } .unit-toggle-left { width: 50%; @@ -1374,7 +1478,7 @@ timestamp-badge[light-bg] { #no-border.item { border-width: 0 !important; } -#goal-signup-field{ +#goal-signup-field { width: 50%; margin-left: auto; margin-right: auto; @@ -1387,16 +1491,16 @@ timestamp-badge[light-bg] { opacity: 0.7; } .full-toggle-left:active { - opacity: 0.7; + opacity: 0.7; } .full-toggle-right:active { opacity: 0.7; } #iframe { - /*width: 375px !important;*/ - height: 100%; - -webkit-overflow-scrolling: touch !important; - overflow: scroll !important; + /*width: 375px !important;*/ + height: 100%; + -webkit-overflow-scrolling: touch !important; + overflow: scroll !important; } .buttons { @@ -1414,7 +1518,7 @@ timestamp-badge[light-bg] { .buttons input, .buttons select { text-align: center; - border: 1px solid rgb(20 20 20 / .2); + border: 1px solid rgb(20 20 20 / 0.2); border-radius: 10px; font-size: 13px; color: #222; @@ -1422,7 +1526,7 @@ timestamp-badge[light-bg] { min-width: 11ch; } -.buttons input[type="date"] { +.buttons input[type='date'] { color: transparent; } @@ -1440,15 +1544,14 @@ timestamp-badge[light-bg] { text-decoration: underline; } - .date-input-wrapper:after { - content: ""; + content: ''; position: absolute; top: 50%; translate: 0 -50%; right: 8px; font-size: 14px; - font-family: "Ionicons"; + font-family: 'Ionicons'; } .date-input-divider { diff --git a/www/i18n/en.json b/www/i18n/en.json index 4d9aea168..7f3798f16 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -1,464 +1,464 @@ { - "loading" : "Loading...", - "pull-to-refresh": "Pull to refresh", + "loading": "Loading...", + "pull-to-refresh": "Pull to refresh", - "weekdays-all": "All", - "weekdays-select": "Select day of the week", + "weekdays-all": "All", + "weekdays-select": "Select day of the week", - "trip-confirm": { - "services-please-fill-in": "Please fill in the {{text}} not listed.", - "services-cancel": "Cancel", - "services-save": "Save" - }, + "trip-confirm": { + "services-please-fill-in": "Please fill in the {{text}} not listed.", + "services-cancel": "Cancel", + "services-save": "Save" + }, - "control":{ - "profile-tab": "Profile", - "edit-demographics": "Edit Demographics", - "tracking": "Tracking", - "app-status": "App Status", - "incorrect-app-status": "Please update permissions", - "fix-app-status": "Click to view and fix app status", - "fix": "Fix", - "medium-accuracy": "Medium accuracy", - "force-sync": "Force sync", - "share": "Share", - "download-json-dump": "Download json dump", - "email-log": "Email log", - "upload-log": "Upload log", - "view-privacy": "View Privacy Policy", - "user-data": "User data", - "erase-data": "Erase data", - "dev-zone": "Developer zone", - "refresh": "Refresh", - "end-trip-sync": "End trip + sync", - "check-consent": "Check consent", - "invalidate-cached-docs": "Invalidate cached docs", - "nuke-all": "Nuke all buffers and cache", - "test-notification": "Test local notification", - "check-log": "Check log", - "log-title" : "Log", - "check-sensed-data": "Check sensed data", - "sensed-title": "Sensed Data: Transitions", - "collection": "Collection", - "sync": "Sync", - "button-accept": "I accept", - "view-qrc": "My OPcode", - "app-version": "App Version", - "reminders-time-of-day": "Time of Day for Reminders ({{time}})", - "upcoming-notifications": "Upcoming Notifications", - "dummy-notification" : "Dummy Notification in 5 Seconds", - "log-out": "Log Out" - }, + "control": { + "profile-tab": "Profile", + "edit-demographics": "Edit Demographics", + "tracking": "Tracking", + "app-status": "App Status", + "incorrect-app-status": "Please update permissions", + "fix-app-status": "Click to view and fix app status", + "fix": "Fix", + "medium-accuracy": "Medium accuracy", + "force-sync": "Force sync", + "share": "Share", + "download-json-dump": "Download json dump", + "email-log": "Email log", + "upload-log": "Upload log", + "view-privacy": "View Privacy Policy", + "user-data": "User data", + "erase-data": "Erase data", + "dev-zone": "Developer zone", + "refresh": "Refresh", + "end-trip-sync": "End trip + sync", + "check-consent": "Check consent", + "invalidate-cached-docs": "Invalidate cached docs", + "nuke-all": "Nuke all buffers and cache", + "test-notification": "Test local notification", + "check-log": "Check log", + "log-title": "Log", + "check-sensed-data": "Check sensed data", + "sensed-title": "Sensed Data: Transitions", + "collection": "Collection", + "sync": "Sync", + "button-accept": "I accept", + "view-qrc": "My OPcode", + "app-version": "App Version", + "reminders-time-of-day": "Time of Day for Reminders ({{time}})", + "upcoming-notifications": "Upcoming Notifications", + "dummy-notification": "Dummy Notification in 5 Seconds", + "log-out": "Log Out" + }, - "general-settings":{ - "choose-date" : "Choose date to download data", - "choose-dataset" : "Choose a dataset for carbon footprint calculations", - "carbon-dataset" : "Carbon dataset", - "nuke-ui-state-only" : "UI state only", - "nuke-native-cache-only" : "Native cache only", - "nuke-everything" : "Everything", - "clear-data": "Clear data", - "are-you-sure": "Are you sure?", - "log-out-warning": "You will be logged out and your credentials will not be saved. Unsynced data may be lost.", - "cancel": "Cancel", - "confirm": "Confirm", - "user-data-erased": "User data erased.", - "consent-not-found": "Consent for data collection not found, consent now?", - "no-consent-logout": "Consent for data collection not found, please save your opcode, log out, and log back in with the same opcode. Note that you won't get any personalized stats until you do!", - "no-consent-message": "OK! Note that you won't get any personalized stats until you do!", - "consent-found": "Consent found!", - "consented-to": "Consented to protocol last updated on {{approval_date}}", - "consented-ok": "OK", - "qrcode": "My OPcode", - "qrcode-share-title": "You can save your OPcode to login easily in the future!" - }, + "general-settings": { + "choose-date": "Choose date to download data", + "choose-dataset": "Choose a dataset for carbon footprint calculations", + "carbon-dataset": "Carbon dataset", + "nuke-ui-state-only": "UI state only", + "nuke-native-cache-only": "Native cache only", + "nuke-everything": "Everything", + "clear-data": "Clear data", + "are-you-sure": "Are you sure?", + "log-out-warning": "You will be logged out and your credentials will not be saved. Unsynced data may be lost.", + "cancel": "Cancel", + "confirm": "Confirm", + "user-data-erased": "User data erased.", + "consent-not-found": "Consent for data collection not found, consent now?", + "no-consent-logout": "Consent for data collection not found, please save your opcode, log out, and log back in with the same opcode. Note that you won't get any personalized stats until you do!", + "no-consent-message": "OK! Note that you won't get any personalized stats until you do!", + "consent-found": "Consent found!", + "consented-to": "Consented to protocol last updated on {{approval_date}}", + "consented-ok": "OK", + "qrcode": "My OPcode", + "qrcode-share-title": "You can save your OPcode to login easily in the future!" + }, - "metrics":{ - "dashboard-tab": "Dashboard", - "cancel": "Cancel", - "confirm": "Confirm", - "get": "Get", - "range": "Range", - "filter": "Filter", - "from": "From:", - "to": "To:", - "last-week": "last week", - "frequency": "Frequency:", - "pandafreqoptions-daily": "DAILY", - "pandafreqoptions-weekly": "WEEKLY", - "pandafreqoptions-biweekly": "BIWEEKLY", - "pandafreqoptions-monthly": "MONTHLY", - "pandafreqoptions-yearly": "YEARLY", - "freqoptions-daily": "DAILY", - "freqoptions-monthly": "MONTHLY", - "freqoptions-yearly": "YEARLY", - "select-pandafrequency": "Select summary freqency", - "select-frequency": "Select summary freqency", - "chart-xaxis-date": "Date", - "chart-no-data": "No Data Available", - "trips-yaxis-number": "Number", - "calorie-data-change": " change", - "calorie-data-unknown": "Unknown...", - "greater-than": " greater than ", - "greater": " greater ", - "or": "or", - "less-than": " less than ", - "less": " less ", - "week-before": "vs. week before", - "this-week": "this week", - "pick-a-date": "Pick a date", - "trips": "trips", - "hours": "hours", - "minutes": "minutes", - "custom": "Custom" - }, - - "diary": { - "label-tab": "Label", - "distance-in-time": "{{distance}} {{distsuffix}} in {{time}}", - "distance": "Distance", - "time": "Time", - "mode": "Mode", - "replaces": "Replaces", - "purpose": "Purpose", - "survey": "Details", - "untracked-time-range": "Untracked: {{start}} - {{end}}", - "unlabeled": "All Unlabeled", - "invalid-ebike": "Invalid", - "to-label": "To Label", - "show-all": "All Trips", - "no-trips-found": "No trips found", - "choose-mode": "Mode 📝 ", - "choose-replaced-mode": "Replaces 📝", - "choose-purpose": "Purpose 📝", - "choose-survey": "Add Trip Details 📝 ", - "select-mode-scroll": "Mode (👇 for more)", - "select-replaced-mode-scroll": "Replaces (👇 for more)", - "select-purpose-scroll": "Purpose (👇 for more)", - "delete-entry-confirm": "Are you sure you wish to delete this entry?", - "detected": "Detected:", - "labeled-mode": "Labeled Mode", - "detected-modes": "Detected Modes", - "today": "Today", - "no-more-travel": "No more travel to show", - "show-more-travel": "Show More Travel", - "show-older-travel": "Show Older Travel", - "no-travel": "No travel to show", - "no-travel-hint": "To see more, change the filters above or go record some travel!" - }, + "metrics": { + "dashboard-tab": "Dashboard", + "cancel": "Cancel", + "confirm": "Confirm", + "get": "Get", + "range": "Range", + "filter": "Filter", + "from": "From:", + "to": "To:", + "last-week": "last week", + "frequency": "Frequency:", + "pandafreqoptions-daily": "DAILY", + "pandafreqoptions-weekly": "WEEKLY", + "pandafreqoptions-biweekly": "BIWEEKLY", + "pandafreqoptions-monthly": "MONTHLY", + "pandafreqoptions-yearly": "YEARLY", + "freqoptions-daily": "DAILY", + "freqoptions-monthly": "MONTHLY", + "freqoptions-yearly": "YEARLY", + "select-pandafrequency": "Select summary freqency", + "select-frequency": "Select summary freqency", + "chart-xaxis-date": "Date", + "chart-no-data": "No Data Available", + "trips-yaxis-number": "Number", + "calorie-data-change": " change", + "calorie-data-unknown": "Unknown...", + "greater-than": " greater than ", + "greater": " greater ", + "or": "or", + "less-than": " less than ", + "less": " less ", + "week-before": "vs. week before", + "this-week": "this week", + "pick-a-date": "Pick a date", + "trips": "trips", + "hours": "hours", + "minutes": "minutes", + "custom": "Custom" + }, - "multilabel":{ - "walk": "Walk", - "e-bike": "E-bike", - "bike": "Regular Bike", - "bikeshare": "Bikeshare", - "scootershare": "Scooter share", - "drove_alone": "Gas Car Drove Alone", - "shared_ride": "Gas Car Shared Ride", - "hybrid_drove_alone": "Hybrid Drove Alone", - "hybrid_shared_ride": "Hybrid Shared Ride", - "e_car_drove_alone": "E-Car Drove Alone", - "e_car_shared_ride": "E-Car Shared Ride", - "taxi": "Taxi / Uber / Lyft", - "bus": "Bus", - "train": "Train", - "free_shuttle": "Free Shuttle", - "air": "Air", - "not_a_trip": "Not a trip", - "no_travel": "No travel", - "home": "Home", - "work": "To Work", - "at_work": "At Work", - "school": "School", - "transit_transfer": "Transit transfer", - "shopping": "Shopping", - "meal": "Meal", - "pick_drop_person": "Pick-up / Drop off Person", - "pick_drop_item": "Pick-up / Drop off Item", - "personal_med": "Personal / Medical", - "access_recreation": "Access Recreation", - "exercise": "Recreation / Exercise", - "entertainment": "Entertainment / Social", - "religious": "Religious", - "other": "Other" - }, + "diary": { + "label-tab": "Label", + "distance-in-time": "{{distance}} {{distsuffix}} in {{time}}", + "distance": "Distance", + "time": "Time", + "mode": "Mode", + "replaces": "Replaces", + "purpose": "Purpose", + "survey": "Details", + "untracked-time-range": "Untracked: {{start}} - {{end}}", + "unlabeled": "All Unlabeled", + "invalid-ebike": "Invalid", + "to-label": "To Label", + "show-all": "All Trips", + "no-trips-found": "No trips found", + "choose-mode": "Mode 📝 ", + "choose-replaced-mode": "Replaces 📝", + "choose-purpose": "Purpose 📝", + "choose-survey": "Add Trip Details 📝 ", + "select-mode-scroll": "Mode (👇 for more)", + "select-replaced-mode-scroll": "Replaces (👇 for more)", + "select-purpose-scroll": "Purpose (👇 for more)", + "delete-entry-confirm": "Are you sure you wish to delete this entry?", + "detected": "Detected:", + "labeled-mode": "Labeled Mode", + "detected-modes": "Detected Modes", + "today": "Today", + "no-more-travel": "No more travel to show", + "show-more-travel": "Show More Travel", + "show-older-travel": "Show Older Travel", + "no-travel": "No travel to show", + "no-travel-hint": "To see more, change the filters above or go record some travel!" + }, - "main-metrics":{ - "summary": "My Summary", - "chart": "Chart", - "change-data": "Change dates:", - "distance": "Distance", - "trips": "Trips", - "duration": "Duration", - "fav-mode": "My Favorite Mode", - "speed": "My Speed", - "footprint": "My Footprint", - "estimated-emissions": "Estimated CO₂ emissions", - "how-it-compares": "Ballpark comparisons", - "optimal": "Optimal (perfect mode choice for all my trips)", - "average": "Group Avg.", - "worst-case": "Worse Case", - "label-to-squish": "Label trips to collapse the range into a single number", - "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", - "lastweek": "My last week value:", - "us-2030-goal": "2030 Guideline¹", - "us-2050-goal": "2050 Guideline¹", - "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", - "past-week" : "Past Week", - "prev-week" : "Prev. Week", - "no-summary-data": "No summary data", - "mean-speed": "My Average Speed", - "user-totals": "My Totals", - "group-totals": "Group Totals", - "active-minutes": "Active Minutes", - "weekly-active-minutes": "Weekly minutes of active travel", - "daily-active-minutes": "Daily minutes of active travel", - "active-minutes-table": "Table of active minutes metrics", - "weekly-goal": "Weekly Goal³", - "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", - "labeled": "Labeled", - "unlabeled": "Unlabeled²", - "footprint-label": "Footprint (kg CO₂)" - }, + "multilabel": { + "walk": "Walk", + "e-bike": "E-bike", + "bike": "Regular Bike", + "bikeshare": "Bikeshare", + "scootershare": "Scooter share", + "drove_alone": "Gas Car Drove Alone", + "shared_ride": "Gas Car Shared Ride", + "hybrid_drove_alone": "Hybrid Drove Alone", + "hybrid_shared_ride": "Hybrid Shared Ride", + "e_car_drove_alone": "E-Car Drove Alone", + "e_car_shared_ride": "E-Car Shared Ride", + "taxi": "Taxi / Uber / Lyft", + "bus": "Bus", + "train": "Train", + "free_shuttle": "Free Shuttle", + "air": "Air", + "not_a_trip": "Not a trip", + "no_travel": "No travel", + "home": "Home", + "work": "To Work", + "at_work": "At Work", + "school": "School", + "transit_transfer": "Transit transfer", + "shopping": "Shopping", + "meal": "Meal", + "pick_drop_person": "Pick-up / Drop off Person", + "pick_drop_item": "Pick-up / Drop off Item", + "personal_med": "Personal / Medical", + "access_recreation": "Access Recreation", + "exercise": "Recreation / Exercise", + "entertainment": "Entertainment / Social", + "religious": "Religious", + "other": "Other" + }, - "details":{ - "speed": "Speed", - "time": "Time" - }, + "main-metrics": { + "summary": "My Summary", + "chart": "Chart", + "change-data": "Change dates:", + "distance": "Distance", + "trips": "Trips", + "duration": "Duration", + "fav-mode": "My Favorite Mode", + "speed": "My Speed", + "footprint": "My Footprint", + "estimated-emissions": "Estimated CO₂ emissions", + "how-it-compares": "Ballpark comparisons", + "optimal": "Optimal (perfect mode choice for all my trips)", + "average": "Group Avg.", + "worst-case": "Worse Case", + "label-to-squish": "Label trips to collapse the range into a single number", + "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", + "lastweek": "My last week value:", + "us-2030-goal": "2030 Guideline¹", + "us-2050-goal": "2050 Guideline¹", + "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", + "past-week": "Past Week", + "prev-week": "Prev. Week", + "no-summary-data": "No summary data", + "mean-speed": "My Average Speed", + "user-totals": "My Totals", + "group-totals": "Group Totals", + "active-minutes": "Active Minutes", + "weekly-active-minutes": "Weekly minutes of active travel", + "daily-active-minutes": "Daily minutes of active travel", + "active-minutes-table": "Table of active minutes metrics", + "weekly-goal": "Weekly Goal³", + "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", + "labeled": "Labeled", + "unlabeled": "Unlabeled²", + "footprint-label": "Footprint (kg CO₂)" + }, - "list-datepicker-today": "Today", - "list-datepicker-close": "Close", - "list-datepicker-set": "Set", + "details": { + "speed": "Speed", + "time": "Time" + }, - "service":{ - "reading-server": "Reading from server...", - "reading-unprocessed-data": "Reading unprocessed data..." - }, + "list-datepicker-today": "Today", + "list-datepicker-close": "Close", + "list-datepicker-set": "Set", - "email-service":{ - "email-account-not-configured": "Email account is not configured, cannot send email", - "email-account-mail-app": "You must have the mail app on your phone configured with an email address. Otherwise, this won't work", - "going-to-email": "Going to email database from {{parentDir}}", - "email-log":{ - "subject-logs": "emission logs", - "body-please-fill-in-what-is-wrong": "please fill in what is wrong" - }, - "no-email-address-configured": "No email address configured.", - "email-data":{ - "subject-data-dump-from-to": "Data dump from {{start}} to {{end}}", - "body-data-consists-of-list-of-entries": "Data consists of a list of entries.\nEntry formats are at https://github.com/e-mission/e-mission-server/tree/master/emission/core/wrapper \nData can be loaded locally using instructions at https://github.com/e-mission/e-mission-server#loading-test-data \n and can be manipulated using the example at https://github.com/e-mission/e-mission-server/blob/master/Timeseries_Sample.ipynb" - } - }, + "service": { + "reading-server": "Reading from server...", + "reading-unprocessed-data": "Reading unprocessed data..." + }, - "upload-service":{ - "upload-database": "Uploading database {{db}}", - "upload-from-dir": "from directory {{parentDir}}", - "upload-to-server": "to servers {{serverURL}}", - "please-fill-in-what-is-wrong": "please fill in what is wrong", - "upload-success": "Upload successful", - "upload-progress": "Sending {{filesizemb | number}} MB to {{serverURL}}", - "upload-details": "Sent {{filesizemb | number}} MB to {{serverURL}}" + "email-service": { + "email-account-not-configured": "Email account is not configured, cannot send email", + "email-account-mail-app": "You must have the mail app on your phone configured with an email address. Otherwise, this won't work", + "going-to-email": "Going to email database from {{parentDir}}", + "email-log": { + "subject-logs": "emission logs", + "body-please-fill-in-what-is-wrong": "please fill in what is wrong" }, + "no-email-address-configured": "No email address configured.", + "email-data": { + "subject-data-dump-from-to": "Data dump from {{start}} to {{end}}", + "body-data-consists-of-list-of-entries": "Data consists of a list of entries.\nEntry formats are at https://github.com/e-mission/e-mission-server/tree/master/emission/core/wrapper \nData can be loaded locally using instructions at https://github.com/e-mission/e-mission-server#loading-test-data \n and can be manipulated using the example at https://github.com/e-mission/e-mission-server/blob/master/Timeseries_Sample.ipynb" + } + }, + + "upload-service": { + "upload-database": "Uploading database {{db}}", + "upload-from-dir": "from directory {{parentDir}}", + "upload-to-server": "to servers {{serverURL}}", + "please-fill-in-what-is-wrong": "please fill in what is wrong", + "upload-success": "Upload successful", + "upload-progress": "Sending {{filesizemb | number}} MB to {{serverURL}}", + "upload-details": "Sent {{filesizemb | number}} MB to {{serverURL}}" + }, - "intro": { - "proceed": "Proceed", - "appstatus": { - "fix": "Fix", - "refresh":"Refresh", - "overall-description": "This app works in the background to automatically build a travel diary for you. Make sure that all the settings below are green so that the app can work properly!", - "explanation-title": "What are these used for?", - "overall-loc-name": "Location", - "overall-loc-description": "We use the background location permission to track your location in the background, even when the app is closed. Reading background locations removes the need to turn tracking on and off, making the app easier to use and preventing battery drain.", - "locsettings": { - "name": "Location Settings", - "description": { - "android-lt-9": "Location services should be enabled and set to High Accuracy. This allows us to accurately record the trajectory of the travel", - "android-gte-9": "Location services should be enabled. This allows us to access location data and generate the trip log", - "ios": "Location services should be enabled. This allows us to access location data and generate the trip log" - } - }, - "locperms": { - "name": "Location Permissions", - "description": { - "android-lt-6": "Enabled during app installation.", - "android-6-9": "Please select 'allow'", - "android-10": "Please select 'Allow all the time'", - "android-11": "On the app settings page, choose the 'Location' permission and set it to 'Allow all the time'", - "android-gte-12": "On the app settings page, choose the 'Location' permission and set it to 'Allow all the time' and 'Precise'", - "ios-lt-13": "Please select 'Always allow'", - "ios-gte-13": "On the app settings page, please select 'Always' and 'Precise' and return here to continue" - } - }, - "overall-fitness-name-android": "Physical activity", - "overall-fitness-name-ios": "Motion and Fitness", - "overall-fitness-description": "The fitness sensors distinguish between walking, bicycling and motorized modes. We use this data in order to separate the parts of multi-modal travel such as transit. We also use it to as a cross-check potentially spurious trips - if the location sensor jumps across town but the fitness sensor is stationary, we can guess that the trip was invalid.", - "fitnessperms": { - "name": "Fitness Permission", - "description": { - "android": "Please allow.", - "ios": "Please allow." - } - }, - "overall-notification-name": "Notifications", - "overall-notification-description": "We need to use notifications to inform you if the settings are incorrect. We also use hourly invisible push notifications to wake up the app and allow it to upload data and check app status. We also use notifications to remind you to label your trips.", - "notificationperms": { - "app-enabled-name": "App Notifications", - "description": { - "android-enable": "On the app settings page, ensure that all notifications and channels are enabled.", - "ios-enable": "Please allow, on the popup or the app settings page if necessary" - } - }, - "overall-background-restrictions-name": "Background restrictions", - "overall-background-restrictions-description": "The app runs in the background most of the time to make your life easier. You only need to open it periodically to label trips. Android sometimes restricts apps from working in the background. This prevents us from generating an accurate trip diary. Please remove background restrictions on this app.", - "unusedapprestrict": { - "name": "Unused apps disabled", - "description": { - "android-disable-lt-12": "On the app settings page, go to 'Permissions' and ensure that the app permissions will not be automatically reset.", - "android-disable-12": "On the app settings page, turn off 'Remove permissions and free up space.'", - "android-disable-gte-13": "On the app settings page, turn off 'Pause app activity if unused.'", - "ios": "Please allow." - } - }, - "ignorebatteryopt": { - "name": "Ignore battery optimizations", - "description": "Please allow." - } - }, - "permissions": { - "locationPermExplanation-ios-lt-13": "please select 'Always allow'. This allows us to understand your travel even when you are not actively using the app", - "locationPermExplanation-ios-gte-13": "please select 'always' and 'precise' in the app settings page and return here to continue" + "intro": { + "proceed": "Proceed", + "appstatus": { + "fix": "Fix", + "refresh": "Refresh", + "overall-description": "This app works in the background to automatically build a travel diary for you. Make sure that all the settings below are green so that the app can work properly!", + "explanation-title": "What are these used for?", + "overall-loc-name": "Location", + "overall-loc-description": "We use the background location permission to track your location in the background, even when the app is closed. Reading background locations removes the need to turn tracking on and off, making the app easier to use and preventing battery drain.", + "locsettings": { + "name": "Location Settings", + "description": { + "android-lt-9": "Location services should be enabled and set to High Accuracy. This allows us to accurately record the trajectory of the travel", + "android-gte-9": "Location services should be enabled. This allows us to access location data and generate the trip log", + "ios": "Location services should be enabled. This allows us to access location data and generate the trip log" + } + }, + "locperms": { + "name": "Location Permissions", + "description": { + "android-lt-6": "Enabled during app installation.", + "android-6-9": "Please select 'allow'", + "android-10": "Please select 'Allow all the time'", + "android-11": "On the app settings page, choose the 'Location' permission and set it to 'Allow all the time'", + "android-gte-12": "On the app settings page, choose the 'Location' permission and set it to 'Allow all the time' and 'Precise'", + "ios-lt-13": "Please select 'Always allow'", + "ios-gte-13": "On the app settings page, please select 'Always' and 'Precise' and return here to continue" + } + }, + "overall-fitness-name-android": "Physical activity", + "overall-fitness-name-ios": "Motion and Fitness", + "overall-fitness-description": "The fitness sensors distinguish between walking, bicycling and motorized modes. We use this data in order to separate the parts of multi-modal travel such as transit. We also use it to as a cross-check potentially spurious trips - if the location sensor jumps across town but the fitness sensor is stationary, we can guess that the trip was invalid.", + "fitnessperms": { + "name": "Fitness Permission", + "description": { + "android": "Please allow.", + "ios": "Please allow." } + }, + "overall-notification-name": "Notifications", + "overall-notification-description": "We need to use notifications to inform you if the settings are incorrect. We also use hourly invisible push notifications to wake up the app and allow it to upload data and check app status. We also use notifications to remind you to label your trips.", + "notificationperms": { + "app-enabled-name": "App Notifications", + "description": { + "android-enable": "On the app settings page, ensure that all notifications and channels are enabled.", + "ios-enable": "Please allow, on the popup or the app settings page if necessary" + } + }, + "overall-background-restrictions-name": "Background restrictions", + "overall-background-restrictions-description": "The app runs in the background most of the time to make your life easier. You only need to open it periodically to label trips. Android sometimes restricts apps from working in the background. This prevents us from generating an accurate trip diary. Please remove background restrictions on this app.", + "unusedapprestrict": { + "name": "Unused apps disabled", + "description": { + "android-disable-lt-12": "On the app settings page, go to 'Permissions' and ensure that the app permissions will not be automatically reset.", + "android-disable-12": "On the app settings page, turn off 'Remove permissions and free up space.'", + "android-disable-gte-13": "On the app settings page, turn off 'Pause app activity if unused.'", + "ios": "Please allow." + } + }, + "ignorebatteryopt": { + "name": "Ignore battery optimizations", + "description": "Please allow." + } }, - "allow_background": { - "samsung": "Disable 'Medium power saving mode'" + "permissions": { + "locationPermExplanation-ios-lt-13": "please select 'Always allow'. This allows us to understand your travel even when you are not actively using the app", + "locationPermExplanation-ios-gte-13": "please select 'always' and 'precise' in the app settings page and return here to continue" + } + }, + "allow_background": { + "samsung": "Disable 'Medium power saving mode'" + }, + "consent": { + "permissions": "Permissions", + "button-accept": "I accept", + "button-decline": "I refuse" + }, + "login": { + "make-sure-save-your-opcode": "Make sure to save your OPcode!", + "cannot-retrieve": "NREL cannot retrieve it for you later!", + "save": "Save", + "continue": "Continue", + "enter-existing-token": "Enter the existing token that you have", + "button-accept": "OK", + "button-decline": "Cancel" + }, + "survey": { + "loading-prior-survey": "Loading prior survey responses...", + "prev-survey-found": "Found previous survey response", + "use-prior-response": "Use prior response", + "edit-response": "Edit response", + "move-on": "Move on", + "survey": "Survey", + "save": "Save", + "back": "Back", + "next": "Next", + "powered-by": "Powered by", + "dismiss": "Dismiss", + "return-to-beginning": "Return to beginning", + "go-to-end": "Go to End", + "enketo-form-errors": "Form contains errors. Please see fields marked in red.", + "enketo-timestamps-invalid": "The times you entered are invalid. Please ensure that the start time is before the end time." + }, + "join": { + "welcome-to-app": "Welcome to {{appName}}!", + "app-name": "NREL OpenPATH", + "to-proceed-further": "To proceed further, please scan or paste an access code that has been provided by your program administrator through a website, email, text message, or printout.", + "code-hint": "The code begins with ‘nrelop’ and may be in barcode or text format.", + "scan-code": "Scan code", + "paste-code": "Paste code", + "scan-hint": "Scan the barcode with your phone camera", + "paste-hint": "Or, paste the code as text", + "about-app-title": "About {{appName}}", + "about-app-para-1": "The National Renewable Energy Laboratory’s Open Platform for Agile Trip Heuristics (NREL OpenPATH) enables people to track their travel modes—car, bus, bike, walking, etc.—and measure their associated energy use and carbon footprint.", + "about-app-para-2": "The app empowers communities to understand their travel mode choices and patterns, experiment with options to make them more sustainable, and evaluate the results. Such results can inform effective transportation policy and planning and be used to build more sustainable and accessible cities.", + "about-app-para-3": "It does so by building an automatic diary of all your trips, across all transportation modes. It reads multiple sensors, including location, in the background, and turns GPS tracking on and off automatically for minimal power consumption. The choice of the travel pattern information and the carbon footprint display style are study-specific.", + "tips-title": "Tip(s) for correct operation:", + "all-green-status": "Make sure that all status checks are green", + "dont-force-kill": "Do not force kill the app", + "background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off", + "close": "Close" + }, + "config": { + "unable-read-saved-config": "Unable to read saved config", + "unable-to-store-config": "Unable to store downladed config", + "not-enough-parts-old-style": "OPcode {{token}} does not have at least two '_' characters", + "no-nrelop-start": "OPcode {{token}} does not start with 'nrelop'", + "not-enough-parts": "OPcode {{token}} does not have at least three '_' characters", + "invalid-subgroup": "Invalid OPcode {{token}}, subgroup {{subgroup}} not found in list {{config_subgroups}}", + "invalid-subgroup-no-default": "Invalid OPcode {{token}}, no subgroups, expected 'default' subgroup", + "unable-download-config": "Unable to download study config", + "invalid-opcode-format": "Invalid OPcode format", + "error-loading-config-app-start": "Error loading config on app start", + "survey-missing-formpath": "Error while fetching resources in config: survey_info.surveys has a survey without a formPath" + }, + "errors": { + "registration-check-token": "User registration error. Please check your token and try again.", + "not-registered-cant-contact": "User is not registered, so the server cannot be contacted.", + "while-populating-composite": "Error while populating composite trips", + "while-loading-another-week": "Error while loading travel of {{when}} week", + "while-loading-specific-week": "Error while loading travel for the week of {{day}}", + "while-log-messages": "While getting messages from the log ", + "while-max-index": "While getting max index " + }, + "consent-text": { + "title": "NREL OPENPATH PRIVACY POLICY/TERMS OF USE", + "introduction": { + "header": "Introduction and Purpose", + "what-is-openpath": "This data is being collected through OpenPATH, an NREL open-sourced platform. The smart phone application, NREL OpenPATH (“App”), combines data from smartphone sensors, semantic user labels and a short demographic survey.", + "what-is-NREL": "NREL is a national laboratory of the U.S. Department of Energy, Office of Energy Efficiency and Renewable Energy, operated by Alliance for Sustainable Energy, LLC under Prime Contract No. DE-AC36-08GO28308. This Privacy Policy applies to the App provided by Alliance for Sustainable Energy, LLC. This App is provided solely for the purposes of collecting travel behavior data for the {{program_or_study}} and for research to inform public policy. None of the data collected by the App will never be sold or used for any commercial purposes, including advertising.", + "if-disagree": "IF YOU DO NOT AGREE WITH THE TERMS OF THIS PRIVACY POLICY, PLEASE DELETE THE APP" }, - "consent":{ - "permissions" : "Permissions", - "button-accept": "I accept", - "button-decline": "I refuse" + "why": { + "header": "Why we collect this information" }, - "login":{ - "make-sure-save-your-opcode":"Make sure to save your OPcode!", - "cannot-retrieve":"NREL cannot retrieve it for you later!", - "save":"Save", - "continue": "Continue", - "enter-existing-token": "Enter the existing token that you have", - "button-accept": "OK", - "button-decline": "Cancel" + "what": { + "header": "What information we collect", + "no-pii": "The App will never ask for any Personally Identifying Information (PII) such as name, email, address, or phone number.", + "phone-sensor": "It collects phone sensor data pertaining to your location (including background location), accelerometer, device-generated activity and mode recognition, App usage time, and battery usage. The App will create a “travel diary” based on your background location data to determine your travel patterns and location history.", + "labeling": "It will also ask you to periodically annotate these sensed trips with semantic labels, such as the trip mode, purpose, and replaced mode.", + "demographics": "It will also request sociodemographic information such as your approximate age, gender, and household type. The sociodemographic factors can be used to understand the influence of lifestyle on travel behavior, as well as generalize the results to a broader population.", + "open-source-data": "For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background at", + "open-source-analysis": "the analysis pipeline at", + "open-source-dashboard": "and the dashboard metrics at", + "on-nrel-site": "For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background, the analysis pipeline, and the dashboard metrics through links on the NREL OpenPATH website." }, - "survey": { - "loading-prior-survey": "Loading prior survey responses...", - "prev-survey-found": "Found previous survey response", - "use-prior-response": "Use prior response", - "edit-response": "Edit response", - "move-on": "Move on", - "survey": "Survey", - "save": "Save", - "back": "Back", - "next": "Next", - "powered-by": "Powered by", - "dismiss": "Dismiss", - "return-to-beginning": "Return to beginning", - "go-to-end": "Go to End", - "enketo-form-errors": "Form contains errors. Please see fields marked in red.", - "enketo-timestamps-invalid": "The times you entered are invalid. Please ensure that the start time is before the end time." + "opcode": { + "header": "How we associate information with you", + "not-autogen": "Program administrators will provide you with a 'opcode' that you will use to log in to the system. This long random string will be used for all further communication with the server. If you forget or lose your opcode, you may request it by providing your name and/or email address to the program administrator. Please do not contact NREL staff with opcode retrieval requests since we do not have access to the connection between your name/email and your opcode. The data that NREL automatically collects (phone sensor data, semidemographic data, etc.) will only be associated with your 'opcode'", + "autogen": "You are logging in with a randomly generated 'opcode' that has been generated by the platform. This long random string will used for all further communication with the server. Only you know the opcode that is associated with you. There is no “Forgot password” option, and NREL staff cannot retrieve your opcode, even if you provide your name or email address. This means that, unless you store your opcode in a safe place, you will not have access to your prior data if you switch phones or uninstall and reinstall the App." }, - "join": { - "welcome-to-app": "Welcome to {{appName}}!", - "app-name": "NREL OpenPATH", - "to-proceed-further": "To proceed further, please scan or paste an access code that has been provided by your program administrator through a website, email, text message, or printout.", - "code-hint": "The code begins with ‘nrelop’ and may be in barcode or text format.", - "scan-code": "Scan code", - "paste-code": "Paste code", - "scan-hint": "Scan the barcode with your phone camera", - "paste-hint": "Or, paste the code as text", - "about-app-title": "About {{appName}}", - "about-app-para-1": "The National Renewable Energy Laboratory’s Open Platform for Agile Trip Heuristics (NREL OpenPATH) enables people to track their travel modes—car, bus, bike, walking, etc.—and measure their associated energy use and carbon footprint.", - "about-app-para-2": "The app empowers communities to understand their travel mode choices and patterns, experiment with options to make them more sustainable, and evaluate the results. Such results can inform effective transportation policy and planning and be used to build more sustainable and accessible cities.", - "about-app-para-3": "It does so by building an automatic diary of all your trips, across all transportation modes. It reads multiple sensors, including location, in the background, and turns GPS tracking on and off automatically for minimal power consumption. The choice of the travel pattern information and the carbon footprint display style are study-specific.", - "tips-title": "Tip(s) for correct operation:", - "all-green-status": "Make sure that all status checks are green", - "dont-force-kill": "Do not force kill the app", - "background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off", - "close": "Close" + "who-sees": { + "header": "Who gets to see the information", + "public-dash": "Aggregate metrics derived from the travel patterns will be made available on a public dashboard to provide transparency into the impact of the program. These metrics will focus on information summaries such as counts, distances and durations, and will not display individual travel locations or times.", + "individual-info": "Individual labeling rates and trip level information will only be made available to:", + "program-admins": "🧑 Program administrators from {{deployment_partner_name}} to {{raw_data_use}}, and", + "nrel-devs": "💻 NREL OpenPATH developers for debugging", + "TSDC-info": "The data will also be periodically archived in NREL’s Transportation Secure Data Center (TSDC) after a delay of 3 to 6 months. It will then be made available for legitimate research through existing, privacy-preserving TSDC operating procedures. Further information on the procedures is available", + "on-website": " on the website ", + "and-in": "and in", + "this-pub": " this publication ", + "and": "and", + "fact-sheet": " fact sheet", + "on-nrel-site": " through links on the NREL OpenPATH website." }, - "config": { - "unable-read-saved-config": "Unable to read saved config", - "unable-to-store-config": "Unable to store downladed config", - "not-enough-parts-old-style": "OPcode {{token}} does not have at least two '_' characters", - "no-nrelop-start": "OPcode {{token}} does not start with 'nrelop'", - "not-enough-parts": "OPcode {{token}} does not have at least three '_' characters", - "invalid-subgroup": "Invalid OPcode {{token}}, subgroup {{subgroup}} not found in list {{config_subgroups}}", - "invalid-subgroup-no-default": "Invalid OPcode {{token}}, no subgroups, expected 'default' subgroup", - "unable-download-config": "Unable to download study config", - "invalid-opcode-format": "Invalid OPcode format", - "error-loading-config-app-start": "Error loading config on app start", - "survey-missing-formpath": "Error while fetching resources in config: survey_info.surveys has a survey without a formPath" + "rights": { + "header": "Your rights", + "app-required": "You are required to track your travel patterns using the App as a condition of participation in the Program. If you wish to withdraw from the Program, you should contact the program administrator, {{program_admin_contact}} to discuss termination options. If you wish to stay in the program but not use the app, please contact your program administrator to negotiate an alternative data collection procedure before uninstalling the app. If you uninstall the app without approval from the program administrator, you may not have access to the benefits provided by the program.", + "app-not-required": "Participation in the {{program_or_study}} is completely voluntary. You have the right to decline to participate or to withdraw at any point in the Study without providing notice to NREL or the point of contact. If you do not wish to participate in the Study or to discontinue your participation in the Study, please delete the App.", + "destroy-data-pt1": "If you would like to have your data destroyed, please contact K. Shankari ", + "destroy-data-pt2": " requesting deletion. You must include your token in the request for deletion. Because we do not connect your identity with your token, we cannot delete your information without obtaining the token as part of the deletion request. We will then destroy all data associated with that deletion request, both in the online and archived datasets." }, - "errors": { - "registration-check-token": "User registration error. Please check your token and try again.", - "not-registered-cant-contact": "User is not registered, so the server cannot be contacted.", - "while-populating-composite": "Error while populating composite trips", - "while-loading-another-week": "Error while loading travel of {{when}} week", - "while-loading-specific-week": "Error while loading travel for the week of {{day}}", - "while-log-messages": "While getting messages from the log ", - "while-max-index" : "While getting max index " + "questions": { + "header": "Questions", + "for-questions": "If you have any questions about the data collection goals and results, please contact the primary point of contact for the study, {{program_admin_contact}}. If you have any technical questions about app operation, please contact NREL’s K. Shankari (k.shankari@nrel.gov)." }, - "consent-text": { - "title":"NREL OPENPATH PRIVACY POLICY/TERMS OF USE", - "introduction":{ - "header":"Introduction and Purpose", - "what-is-openpath":"This data is being collected through OpenPATH, an NREL open-sourced platform. The smart phone application, NREL OpenPATH (“App”), combines data from smartphone sensors, semantic user labels and a short demographic survey.", - "what-is-NREL":"NREL is a national laboratory of the U.S. Department of Energy, Office of Energy Efficiency and Renewable Energy, operated by Alliance for Sustainable Energy, LLC under Prime Contract No. DE-AC36-08GO28308. This Privacy Policy applies to the App provided by Alliance for Sustainable Energy, LLC. This App is provided solely for the purposes of collecting travel behavior data for the {{program_or_study}} and for research to inform public policy. None of the data collected by the App will never be sold or used for any commercial purposes, including advertising.", - "if-disagree":"IF YOU DO NOT AGREE WITH THE TERMS OF THIS PRIVACY POLICY, PLEASE DELETE THE APP" - }, - "why":{ - "header":"Why we collect this information" - }, - "what":{ - "header":"What information we collect", - "no-pii":"The App will never ask for any Personally Identifying Information (PII) such as name, email, address, or phone number.", - "phone-sensor":"It collects phone sensor data pertaining to your location (including background location), accelerometer, device-generated activity and mode recognition, App usage time, and battery usage. The App will create a “travel diary” based on your background location data to determine your travel patterns and location history.", - "labeling":"It will also ask you to periodically annotate these sensed trips with semantic labels, such as the trip mode, purpose, and replaced mode.", - "demographics":"It will also request sociodemographic information such as your approximate age, gender, and household type. The sociodemographic factors can be used to understand the influence of lifestyle on travel behavior, as well as generalize the results to a broader population.", - "open-source-data":"For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background at", - "open-source-analysis":"the analysis pipeline at", - "open-source-dashboard":"and the dashboard metrics at", - "on-nrel-site": "For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background, the analysis pipeline, and the dashboard metrics through links on the NREL OpenPATH website." - }, - "opcode":{ - "header":"How we associate information with you", - "not-autogen":"Program administrators will provide you with a 'opcode' that you will use to log in to the system. This long random string will be used for all further communication with the server. If you forget or lose your opcode, you may request it by providing your name and/or email address to the program administrator. Please do not contact NREL staff with opcode retrieval requests since we do not have access to the connection between your name/email and your opcode. The data that NREL automatically collects (phone sensor data, semidemographic data, etc.) will only be associated with your 'opcode'", - "autogen":"You are logging in with a randomly generated 'opcode' that has been generated by the platform. This long random string will used for all further communication with the server. Only you know the opcode that is associated with you. There is no “Forgot password” option, and NREL staff cannot retrieve your opcode, even if you provide your name or email address. This means that, unless you store your opcode in a safe place, you will not have access to your prior data if you switch phones or uninstall and reinstall the App." - }, - "who-sees":{ - "header":"Who gets to see the information", - "public-dash":"Aggregate metrics derived from the travel patterns will be made available on a public dashboard to provide transparency into the impact of the program. These metrics will focus on information summaries such as counts, distances and durations, and will not display individual travel locations or times.", - "individual-info":"Individual labeling rates and trip level information will only be made available to:", - "program-admins":"🧑 Program administrators from {{deployment_partner_name}} to {{raw_data_use}}, and", - "nrel-devs":"💻 NREL OpenPATH developers for debugging", - "TSDC-info":"The data will also be periodically archived in NREL’s Transportation Secure Data Center (TSDC) after a delay of 3 to 6 months. It will then be made available for legitimate research through existing, privacy-preserving TSDC operating procedures. Further information on the procedures is available", - "on-website":" on the website ", - "and-in":"and in", - "this-pub":" this publication ", - "and":"and", - "fact-sheet":" fact sheet", - "on-nrel-site": " through links on the NREL OpenPATH website." - }, - "rights":{ - "header":"Your rights", - "app-required":"You are required to track your travel patterns using the App as a condition of participation in the Program. If you wish to withdraw from the Program, you should contact the program administrator, {{program_admin_contact}} to discuss termination options. If you wish to stay in the program but not use the app, please contact your program administrator to negotiate an alternative data collection procedure before uninstalling the app. If you uninstall the app without approval from the program administrator, you may not have access to the benefits provided by the program.", - "app-not-required":"Participation in the {{program_or_study}} is completely voluntary. You have the right to decline to participate or to withdraw at any point in the Study without providing notice to NREL or the point of contact. If you do not wish to participate in the Study or to discontinue your participation in the Study, please delete the App.", - "destroy-data-pt1":"If you would like to have your data destroyed, please contact K. Shankari ", - "destroy-data-pt2":" requesting deletion. You must include your token in the request for deletion. Because we do not connect your identity with your token, we cannot delete your information without obtaining the token as part of the deletion request. We will then destroy all data associated with that deletion request, both in the online and archived datasets." - }, - "questions":{ - "header":"Questions", - "for-questions":"If you have any questions about the data collection goals and results, please contact the primary point of contact for the study, {{program_admin_contact}}. If you have any technical questions about app operation, please contact NREL’s K. Shankari (k.shankari@nrel.gov)." - }, - "consent":{ - "header":"Consent", - "press-button-to-consent":"Please select the button below to indicate that you have read and agree to this Privacy Policy, consent to the collection of your information, and want to participate in the {{program_or_study}}." - } + "consent": { + "header": "Consent", + "press-button-to-consent": "Please select the button below to indicate that you have read and agree to this Privacy Policy, consent to the collection of your information, and want to participate in the {{program_or_study}}." } + } } diff --git a/www/index.html b/www/index.html index 451c3047f..b46904cca 100644 --- a/www/index.html +++ b/www/index.html @@ -1,9 +1,13 @@ - + - - - + + + @@ -12,7 +16,7 @@ -
+
diff --git a/www/js/App.tsx b/www/js/App.tsx index b1806a5cc..ab4caebf7 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -7,24 +7,42 @@ import MetricsTab from './metrics/MetricsTab'; import ProfileSettings from './control/ProfileSettings'; import useAppConfig from './useAppConfig'; import OnboardingStack from './onboarding/OnboardingStack'; -import { OnboardingRoute, OnboardingState, getPendingOnboardingState } from './onboarding/onboardingHelper'; +import { + OnboardingRoute, + OnboardingState, + getPendingOnboardingState, +} from './onboarding/onboardingHelper'; import { setServerConnSettings } from './config/serverConn'; import AppStatusModal from './control/AppStatusModal'; import usePermissionStatus from './usePermissionStatus'; const defaultRoutes = (t) => [ - { key: 'label', title: t('diary.label-tab'), focusedIcon: 'check-bold', unfocusedIcon: 'check-outline' }, - { key: 'metrics', title: t('metrics.dashboard-tab'), focusedIcon: 'chart-box', unfocusedIcon: 'chart-box-outline' }, - { key: 'control', title: t('control.profile-tab'), focusedIcon: 'account', unfocusedIcon: 'account-outline' }, + { + key: 'label', + title: t('diary.label-tab'), + focusedIcon: 'check-bold', + unfocusedIcon: 'check-outline', + }, + { + key: 'metrics', + title: t('metrics.dashboard-tab'), + focusedIcon: 'chart-box', + unfocusedIcon: 'chart-box-outline', + }, + { + key: 'control', + title: t('control.profile-tab'), + focusedIcon: 'account', + unfocusedIcon: 'account-outline', + }, ]; export const AppContext = createContext({}); const App = () => { - const [index, setIndex] = useState(0); // will remain null while the onboarding state is still being determined - const [onboardingState, setOnboardingState] = useState(null); + const [onboardingState, setOnboardingState] = useState(null); const [permissionsPopupVis, setPermissionsPopupVis] = useState(false); const appConfig = useAppConfig(); const permissionStatus = usePermissionStatus(); @@ -33,7 +51,7 @@ const App = () => { const routes = useMemo(() => { const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; - return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter(r => r.key != 'metrics'); + return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics'); }, [appConfig, t]); const renderScene = BottomNavigation.SceneMap({ @@ -43,7 +61,9 @@ const App = () => { }); const refreshOnboardingState = () => getPendingOnboardingState().then(setOnboardingState); - useEffect(() => { refreshOnboardingState() }, []); + useEffect(() => { + refreshOnboardingState(); + }, []); useEffect(() => { if (!appConfig) return; @@ -54,17 +74,20 @@ const App = () => { const appContextValue = { appConfig, - onboardingState, setOnboardingState, refreshOnboardingState, + onboardingState, + setOnboardingState, + refreshOnboardingState, permissionStatus, - permissionsPopupVis, setPermissionsPopupVis, - } + permissionsPopupVis, + setPermissionsPopupVis, + }; console.debug('onboardingState in App', onboardingState); let appContent; if (onboardingState == null) { // if onboarding state is not yet determined, show a loading spinner - appContent = + appContent = ; } else if (onboardingState?.route == OnboardingRoute.DONE) { // if onboarding route is DONE, show the main app with navigation between tabs appContent = ( @@ -78,24 +101,27 @@ const App = () => { barStyle={{ height: 68, justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0)' }} // BottomNavigation uses secondaryContainer color for the background, but we want primaryContainer // (light blue), so we override here. - theme={{ colors: { secondaryContainer: colors.primaryContainer } }} /> + theme={{ colors: { secondaryContainer: colors.primaryContainer } }} + /> ); } else { // if there is an onboarding route that is not DONE, show the onboarding stack - appContent = + appContent = ; } - return (<> - - {appContent} + return ( + <> + + {appContent} - { /* If we are fully consented, (route > PROTOCOL), the permissions popup can show if needed. - This also includes if onboarding is DONE altogether (because "DONE" is > "PROTOCOL") */ } - {(onboardingState && onboardingState.route > OnboardingRoute.PROTOCOL) && - - } - - ); -} + {/* If we are fully consented, (route > PROTOCOL), the permissions popup can show if needed. + This also includes if onboarding is DONE altogether (because "DONE" is > "PROTOCOL") */} + {onboardingState && onboardingState.route > OnboardingRoute.PROTOCOL && ( + + )} + + + ); +}; export default App; diff --git a/www/js/angular-react-helper.tsx b/www/js/angular-react-helper.tsx index 4813ae2e9..3cf891666 100644 --- a/www/js/angular-react-helper.tsx +++ b/www/js/angular-react-helper.tsx @@ -15,11 +15,11 @@ export function getAngularService(name: string) { throw new Error(`Couldn't find "${name}" angular service`); } - return (service as any); // casting to 'any' because not all Angular services are typed + return service as any; // casting to 'any' because not all Angular services are typed } export function createScopeWithVars(vars) { - const scope = getAngularService("$rootScope").$new(); + const scope = getAngularService('$rootScope').$new(); Object.assign(scope, vars); return scope; } diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index a8660e811..641d1f680 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -28,7 +28,7 @@ const AppTheme = { }, success: '#00a665', // lch(60% 55 155) warn: '#f8cf53', //lch(85% 65 85) - danger: '#f23934' // lch(55% 85 35) + danger: '#f23934', // lch(55% 85 35) }, roundness: 5, }; @@ -47,23 +47,26 @@ type DPartial = { [P in keyof T]?: DPartial }; // https://stackoverflow type PartialTheme = DPartial; const flavorOverrides = { - place: { // for PlaceCards; a blueish color scheme + place: { + // for PlaceCards; a blueish color scheme colors: { elevation: { level1: '#cbe6ff', // lch(90, 20, 250) }, - } + }, }, - untracked: { // for UntrackedTimeCards; a reddish color scheme + untracked: { + // for UntrackedTimeCards; a reddish color scheme colors: { primary: '#8c4a57', // lch(40 30 10) primaryContainer: '#e3bdc2', // lch(80 15 10) elevation: { level1: '#f8ebec', // lch(94 5 10) }, - } + }, }, - draft: { // for TripCards and LabelDetailsScreen of draft trips; a greyish color scheme + draft: { + // for TripCards and LabelDetailsScreen of draft trips; a greyish color scheme colors: { primary: '#616971', // lch(44 6 250) primaryContainer: '#b6bcc2', // lch(76 4 250) @@ -74,7 +77,7 @@ const flavorOverrides = { level1: '#e1e3e4', // lch(90 1 250) level2: '#d2d5d8', // lch(85 2 250) }, - } + }, }, } satisfies Record; @@ -83,7 +86,10 @@ const flavorOverrides = { export const getTheme = (flavor?: keyof typeof flavorOverrides) => { if (!flavorOverrides[flavor]) return AppTheme; const typeStyle = flavorOverrides[flavor]; - const scopedElevation = {...AppTheme.colors.elevation, ...typeStyle?.colors?.elevation}; - const scopedColors = {...AppTheme.colors, ...{...typeStyle.colors, elevation: scopedElevation}}; - return {...AppTheme, colors: scopedColors}; -} + const scopedElevation = { ...AppTheme.colors.elevation, ...typeStyle?.colors?.elevation }; + const scopedColors = { + ...AppTheme.colors, + ...{ ...typeStyle.colors, elevation: scopedElevation }, + }; + return { ...AppTheme, colors: scopedColors }; +}; diff --git a/www/js/appstatus/ExplainPermissions.tsx b/www/js/appstatus/ExplainPermissions.tsx index cb0db4bba..d0d63ebe7 100644 --- a/www/js/appstatus/ExplainPermissions.tsx +++ b/www/js/appstatus/ExplainPermissions.tsx @@ -1,41 +1,34 @@ -import React from "react"; -import { Modal, ScrollView, useWindowDimensions, View } from "react-native"; +import React from 'react'; +import { Modal, ScrollView, useWindowDimensions, View } from 'react-native'; import { Button, Dialog, Text } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; const ExplainPermissions = ({ explanationList, visible, setVisible }) => { - const { t } = useTranslation(); - const { height: windowHeight } = useWindowDimensions(); + const { t } = useTranslation(); + const { height: windowHeight } = useWindowDimensions(); - return ( - setVisible(false)} > - setVisible(false)} > - {t('intro.appstatus.explanation-title')} - - - {explanationList?.map((li) => - - - {li.name} - - - {li.desc} - - - )} - - - - - - - - ); + return ( + setVisible(false)}> + setVisible(false)}> + {t('intro.appstatus.explanation-title')} + + + {explanationList?.map((li) => ( + + + {li.name} + + {li.desc} + + ))} + + + + + + + + ); }; -export default ExplainPermissions; \ No newline at end of file +export default ExplainPermissions; diff --git a/www/js/appstatus/PermissionItem.tsx b/www/js/appstatus/PermissionItem.tsx index 2899943f1..cd111f3b3 100644 --- a/www/js/appstatus/PermissionItem.tsx +++ b/www/js/appstatus/PermissionItem.tsx @@ -1,21 +1,19 @@ -import React from "react"; +import React from 'react'; import { List, Button } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; const PermissionItem = ({ check }) => { - const { t } = useTranslation(); + const { t } = useTranslation(); - return ( - } - right={() => } - /> - ); + return ( + } + right={() => } + /> + ); }; - -export default PermissionItem; \ No newline at end of file + +export default PermissionItem; diff --git a/www/js/appstatus/PermissionsControls.tsx b/www/js/appstatus/PermissionsControls.tsx index 97ce7081a..39d5386d3 100644 --- a/www/js/appstatus/PermissionsControls.tsx +++ b/www/js/appstatus/PermissionsControls.tsx @@ -1,67 +1,63 @@ //component to view and manage permission settings -import React, { useContext, useState } from "react"; -import { StyleSheet, ScrollView, View } from "react-native"; +import React, { useContext, useState } from 'react'; +import { StyleSheet, ScrollView, View } from 'react-native'; import { Button, Text } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import PermissionItem from "./PermissionItem"; -import { refreshAllChecks } from "../usePermissionStatus"; -import ExplainPermissions from "./ExplainPermissions"; -import AlertBar from "../control/AlertBar"; -import { AppContext } from "../App"; +import { useTranslation } from 'react-i18next'; +import PermissionItem from './PermissionItem'; +import { refreshAllChecks } from '../usePermissionStatus'; +import ExplainPermissions from './ExplainPermissions'; +import AlertBar from '../control/AlertBar'; +import { AppContext } from '../App'; const PermissionsControls = ({ onAccept }) => { - const { t } = useTranslation(); - const [explainVis, setExplainVis] = useState(false); - const { permissionStatus } = useContext(AppContext); - const { checkList, overallStatus, error, errorVis, setErrorVis, explanationList } = permissionStatus; + const { t } = useTranslation(); + const [explainVis, setExplainVis] = useState(false); + const { permissionStatus } = useContext(AppContext); + const { checkList, overallStatus, error, errorVis, setErrorVis, explanationList } = + permissionStatus; - return ( - <> - {t('consent.permissions')} - - {t('intro.appstatus.overall-description')} - - - {checkList?.map((lc) => - - - )} - - - - - + return ( + <> + {t('consent.permissions')} + + {t('intro.appstatus.overall-description')} + + + {checkList?.map((lc) => )} + + + + + - - - ) -} + + + ); +}; const styles = StyleSheet.create({ - title: { - fontWeight: "bold", - fontSize: 22, - paddingBottom: 10 - }, - buttonBox: { - paddingHorizontal: 15, - paddingVertical: 10, - flexDirection: "row", - justifyContent: "space-evenly" - } - }); + title: { + fontWeight: 'bold', + fontSize: 22, + paddingBottom: 10, + }, + buttonBox: { + paddingHorizontal: 15, + paddingVertical: 10, + flexDirection: 'row', + justifyContent: 'space-evenly', + }, +}); export default PermissionsControls; diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index b9584a044..5f144888b 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -1,5 +1,5 @@ -import { DateTime } from "luxon"; -import { logDebug } from "./plugin/logger"; +import { DateTime } from 'luxon'; +import { logDebug } from './plugin/logger'; /** * @param url URL endpoint for the request @@ -19,8 +19,14 @@ export async function fetchUrlCached(url) { return text; } -export function getRawEntries(key_list, start_ts, end_ts, time_key = "metadata.write_ts", - max_entries = undefined, trunc_method = "sample") { +export function getRawEntries( + key_list, + start_ts, + end_ts, + time_key = 'metadata.write_ts', + max_entries = undefined, + trunc_method = 'sample', +) { return new Promise((rs, rj) => { const msgFiller = (message) => { message.key_list = key_list; @@ -32,18 +38,29 @@ export function getRawEntries(key_list, start_ts, end_ts, time_key = "metadata.w message.trunc_method = trunc_method; } logDebug(`About to return message ${JSON.stringify(message)}`); - } - logDebug("getRawEntries: about to get pushGetJSON for the timestamp"); - window['cordova'].plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/timestamp", msgFiller, rs, rj); - }).catch(error => { + }; + logDebug('getRawEntries: about to get pushGetJSON for the timestamp'); + window['cordova'].plugins.BEMServerComm.pushGetJSON( + '/datastreams/find_entries/timestamp', + msgFiller, + rs, + rj, + ); + }).catch((error) => { error = `While getting raw entries, ${error}`; - throw(error); + throw error; }); } // time_key is typically metadata.write_ts or data.ts -export function getRawEntriesForLocalDate(key_list, start_ts, end_ts, time_key = "metadata.write_ts", - max_entries = undefined, trunc_method = "sample") { +export function getRawEntriesForLocalDate( + key_list, + start_ts, + end_ts, + time_key = 'metadata.write_ts', + max_entries = undefined, + trunc_method = 'sample', +) { return new Promise((rs, rj) => { const msgFiller = (message) => { message.key_list = key_list; @@ -54,105 +71,137 @@ export function getRawEntriesForLocalDate(key_list, start_ts, end_ts, time_key = message.max_entries = max_entries; message.trunc_method = trunc_method; } - logDebug("About to return message " + JSON.stringify(message)); + logDebug('About to return message ' + JSON.stringify(message)); }; - logDebug("getRawEntries: about to get pushGetJSON for the timestamp"); - window['cordova'].plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/local_date", msgFiller, rs, rj); - }).catch(error => { - error = "While getting raw entries for local date, " + error; - throw (error); + logDebug('getRawEntries: about to get pushGetJSON for the timestamp'); + window['cordova'].plugins.BEMServerComm.pushGetJSON( + '/datastreams/find_entries/local_date', + msgFiller, + rs, + rj, + ); + }).catch((error) => { + error = 'While getting raw entries for local date, ' + error; + throw error; }); -}; +} export function getPipelineRangeTs() { return new Promise((rs, rj) => { - logDebug("getting pipeline range timestamps"); - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/pipeline/get_range_ts", rs, rj); - }).catch(error => { + logDebug('getting pipeline range timestamps'); + window['cordova'].plugins.BEMServerComm.getUserPersonalData('/pipeline/get_range_ts', rs, rj); + }).catch((error) => { error = `While getting pipeline range timestamps, ${error}`; - throw(error); + throw error; }); } export function getPipelineCompleteTs() { return new Promise((rs, rj) => { - logDebug("getting pipeline complete timestamp"); - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/pipeline/get_complete_ts", rs, rj); - }).catch(error => { + logDebug('getting pipeline complete timestamp'); + window['cordova'].plugins.BEMServerComm.getUserPersonalData( + '/pipeline/get_complete_ts', + rs, + rj, + ); + }).catch((error) => { error = `While getting pipeline complete timestamp, ${error}`; - throw(error); + throw error; }); } -export function getMetrics(timeType: 'timestamp'|'local_date', metricsQuery) { +export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { return new Promise((rs, rj) => { const msgFiller = (message) => { for (let key in metricsQuery) { message[key] = metricsQuery[key]; } - } - window['cordova'].plugins.BEMServerComm.pushGetJSON(`/result/metrics/${timeType}`, msgFiller, rs, rj); - }).catch(error => { + }; + window['cordova'].plugins.BEMServerComm.pushGetJSON( + `/result/metrics/${timeType}`, + msgFiller, + rs, + rj, + ); + }).catch((error) => { error = `While getting metrics, ${error}`; - throw(error); + throw error; }); } export function getAggregateData(path: string, data: any) { return new Promise((rs, rj) => { const fullUrl = `${window['$rootScope'].connectUrl}/${path}`; - data["aggregate"] = true; + data['aggregate'] = true; - if (window['$rootScope'].aggregateAuth === "no_auth") { - logDebug(`getting aggregate data without user authentication from ${fullUrl} with arguments ${JSON.stringify(data)}`); + if (window['$rootScope'].aggregateAuth === 'no_auth') { + logDebug( + `getting aggregate data without user authentication from ${fullUrl} with arguments ${JSON.stringify( + data, + )}`, + ); const options = { method: 'post', data: data, - responseType: 'json' - } - window['cordova'].plugin.http.sendRequest(fullUrl, options, + responseType: 'json', + }; + window['cordova'].plugin.http.sendRequest( + fullUrl, + options, (response) => { rs(response.data); - }, (error) => { + }, + (error) => { rj(error); - }); + }, + ); } else { - logDebug(`getting aggregate data with user authentication from ${fullUrl} with arguments ${JSON.stringify(data)}`); + logDebug( + `getting aggregate data with user authentication from ${fullUrl} with arguments ${JSON.stringify( + data, + )}`, + ); const msgFiller = (message) => { return Object.assign(message, data); - } + }; window['cordova'].plugins.BEMServerComm.pushGetJSON(`/${path}`, msgFiller, rs, rj); } - }).catch(error => { + }).catch((error) => { error = `While getting aggregate data, ${error}`; - throw(error); + throw error; }); } export function registerUser() { return new Promise((rs, rj) => { - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/create", rs, rj); - }).catch(error => { + window['cordova'].plugins.BEMServerComm.getUserPersonalData('/profile/create', rs, rj); + }).catch((error) => { error = `While registering user, ${error}`; - throw(error); + throw error; }); } export function updateUser(updateDoc) { return new Promise((rs, rj) => { - window['cordova'].plugins.BEMServerComm.postUserPersonalData("/profile/update", "update_doc", updateDoc, rs, rj); - }).catch(error => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/profile/update', + 'update_doc', + updateDoc, + rs, + rj, + ); + }).catch((error) => { error = `While updating user, ${error}`; - throw(error); + throw error; }); } export function getUser() { return new Promise((rs, rj) => { - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/get", rs, rj); - }).catch(error => { + window['cordova'].plugins.BEMServerComm.getUserPersonalData('/profile/get', rs, rj); + }).catch((error) => { error = `While getting user, ${error}`; - throw(error); + throw error; }); } @@ -162,15 +211,21 @@ export function putOne(key, data) { write_ts: nowTs, read_ts: nowTs, time_zone: DateTime.local().zoneName, - type: "message", + type: 'message', key: key, platform: window['device'].platform, }; const entryToPut = { metadata, data }; return new Promise((rs, rj) => { - window['cordova'].plugins.BEMServerComm.postUserPersonalData("/usercache/putone", "the_entry", entryToPut, rs, rj); - }).catch(error => { - error = "While putting one entry, " + error; - throw(error); + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/usercache/putone', + 'the_entry', + entryToPut, + rs, + rj, + ); + }).catch((error) => { + error = 'While putting one entry, ' + error; + throw error; }); -}; +} diff --git a/www/js/components/ActionMenu.tsx b/www/js/components/ActionMenu.tsx index 296717a00..0693acc8b 100644 --- a/www/js/components/ActionMenu.tsx +++ b/www/js/components/ActionMenu.tsx @@ -1,41 +1,44 @@ -import React from "react"; -import { Modal } from "react-native"; -import { Dialog, Button, useTheme } from "react-native-paper"; -import { useTranslation } from "react-i18next"; -import { settingStyles } from "../control/ProfileSettings"; +import React from 'react'; +import { Modal } from 'react-native'; +import { Dialog, Button, useTheme } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import { settingStyles } from '../control/ProfileSettings'; -const ActionMenu = ({vis, setVis, title, actionSet, onAction, onExit}) => { +const ActionMenu = ({ vis, setVis, title, actionSet, onAction, onExit }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); - const { t } = useTranslation(); - const { colors } = useTheme(); + return ( + setVis(false)} transparent={true}> + setVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {title} + + {actionSet?.map((e) => ( + + ))} + + + + + + + ); +}; - return ( - setVis(false)} - transparent={true}> - setVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {title} - - {actionSet?.map((e) => - - )} - - - - - - - ) -} - -export default ActionMenu; \ No newline at end of file +export default ActionMenu; diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx index 1e957923b..ccf1a6f74 100644 --- a/www/js/components/BarChart.tsx +++ b/www/js/components/BarChart.tsx @@ -1,13 +1,12 @@ -import React from "react"; -import Chart, { Props as ChartProps } from "./Chart"; -import { useTheme } from "react-native-paper"; -import { getGradient } from "./charting"; +import React from 'react'; +import Chart, { Props as ChartProps } from './Chart'; +import { useTheme } from 'react-native-paper'; +import { getGradient } from './charting'; type Props = Omit & { - meter?: {high: number, middle: number, dash_key: string}, -} + meter?: { high: number; middle: number; dash_key: string }; +}; const BarChart = ({ meter, ...rest }: Props) => { - const { colors } = useTheme(); if (meter) { @@ -15,13 +14,11 @@ const BarChart = ({ meter, ...rest }: Props) => { const darkenDegree = colorFor == 'border' ? 0.25 : 0; const alpha = colorFor == 'border' ? 1 : 0; return getGradient(chart, meter, dataset, ctx, alpha, darkenDegree); - } + }; rest.borderWidth = 3; } - return ( - - ); -} + return ; +}; export default BarChart; diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx index 28a31ff6a..92febb32b 100644 --- a/www/js/components/Carousel.tsx +++ b/www/js/components/Carousel.tsx @@ -2,26 +2,27 @@ import React from 'react'; import { ScrollView, View } from 'react-native'; type Props = { - children: React.ReactNode, - cardWidth: number, - cardMargin: number, -} + children: React.ReactNode; + cardWidth: number; + cardMargin: number; +}; const Carousel = ({ children, cardWidth, cardMargin }: Props) => { const numCards = React.Children.count(children); return ( - + contentContainerStyle={{ alignItems: 'flex-start' }}> {React.Children.map(children, (child, i) => ( - + {child} ))} - ) + ); }; export const s = { @@ -31,8 +32,8 @@ export const s = { paddingVertical: 10, }), carouselCard: (cardWidth, cardMargin, isFirst, isLast) => ({ - marginLeft: isFirst ? cardMargin : cardMargin/2, - marginRight: isLast ? cardMargin : cardMargin/2, + marginLeft: isFirst ? cardMargin : cardMargin / 2, + marginRight: isLast ? cardMargin : cardMargin / 2, width: cardWidth, scrollSnapAlign: 'center', scrollSnapStop: 'always', diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 79c6e40e4..d7687e424 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -1,4 +1,3 @@ - import React, { useEffect, useRef, useState, useMemo } from 'react'; import { View } from 'react-native'; import { useTheme } from 'react-native-paper'; @@ -9,48 +8,62 @@ import { dedupColors, getChartHeight, darkenOrLighten } from './charting'; ChartJS.register(...registerables, Annotation); -type XYPair = { x: number|string, y: number|string }; +type XYPair = { x: number | string; y: number | string }; type ChartDataset = { - label: string, - data: XYPair[], + label: string; + data: XYPair[]; }; export type Props = { - records: { label: string, x: number|string, y: number|string }[], - axisTitle: string, - type: 'bar'|'line', - getColorForLabel?: (label: string) => string, - getColorForChartEl?: (chart, currDataset: ChartDataset, ctx: ScriptableContext<'bar'|'line'>, colorFor: 'background'|'border') => string|CanvasGradient|null, - borderWidth?: number, - lineAnnotations?: { value: number, label?: string, color?:string, position?: LabelPosition }[], - isHorizontal?: boolean, - timeAxis?: boolean, - stacked?: boolean, -} -const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, borderWidth, lineAnnotations, isHorizontal, timeAxis, stacked }: Props) => { - + records: { label: string; x: number | string; y: number | string }[]; + axisTitle: string; + type: 'bar' | 'line'; + getColorForLabel?: (label: string) => string; + getColorForChartEl?: ( + chart, + currDataset: ChartDataset, + ctx: ScriptableContext<'bar' | 'line'>, + colorFor: 'background' | 'border', + ) => string | CanvasGradient | null; + borderWidth?: number; + lineAnnotations?: { value: number; label?: string; color?: string; position?: LabelPosition }[]; + isHorizontal?: boolean; + timeAxis?: boolean; + stacked?: boolean; +}; +const Chart = ({ + records, + axisTitle, + type, + getColorForLabel, + getColorForChartEl, + borderWidth, + lineAnnotations, + isHorizontal, + timeAxis, + stacked, +}: Props) => { const { colors } = useTheme(); - const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); + const [numVisibleDatasets, setNumVisibleDatasets] = useState(1); const indexAxis = isHorizontal ? 'y' : 'x'; - const chartRef = useRef>(null); + const chartRef = useRef>(null); const [chartDatasets, setChartDatasets] = useState([]); - - const chartData = useMemo>(() => { + + const chartData = useMemo>(() => { let labelColorMap; // object mapping labels to colors if (getColorForLabel) { - const colorEntries = chartDatasets.map(d => [d.label, getColorForLabel(d.label)] ); + const colorEntries = chartDatasets.map((d) => [d.label, getColorForLabel(d.label)]); labelColorMap = dedupColors(colorEntries); } return { datasets: chartDatasets.map((e, i) => ({ ...e, - backgroundColor: (barCtx) => ( - labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background') - ), - borderColor: (barCtx) => ( - darkenOrLighten(labelColorMap?.[e.label], -.5) || getColorForChartEl(chartRef.current, e, barCtx, 'border') - ), + backgroundColor: (barCtx) => + labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background'), + borderColor: (barCtx) => + darkenOrLighten(labelColorMap?.[e.label], -0.5) || + getColorForChartEl(chartRef.current, e, barCtx, 'border'), borderWidth: borderWidth || 2, borderRadius: 3, })), @@ -60,14 +73,16 @@ const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, // group records by label (this is the format that Chart.js expects) useEffect(() => { const d = records?.reduce((acc, record) => { - const existing = acc.find(e => e.label == record.label); + const existing = acc.find((e) => e.label == record.label); if (!existing) { acc.push({ label: record.label, - data: [{ - x: record.x, - y: record.y, - }], + data: [ + { + x: record.x, + y: record.y, + }, + ], }); } else { existing.data.push({ @@ -80,11 +95,15 @@ const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, setChartDatasets(d); }, [records]); - const annotationsAtTop = isHorizontal && lineAnnotations?.some(a => (!a.position || a.position == 'start')); + const annotationsAtTop = + isHorizontal && lineAnnotations?.some((a) => !a.position || a.position == 'start'); return ( - - + { - setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) - }, - ticks: timeAxis ? {} : { - callback: (value, i) => { - const label = chartDatasets[0].data[i].y; - if (typeof label == 'string' && label.includes('\n')) - return label.split('\n'); - return label; + ...(isHorizontal + ? { + y: { + offset: true, + type: timeAxis ? 'time' : 'category', + adapters: timeAxis + ? { + date: { zone: 'utc' }, + } + : {}, + time: timeAxis + ? { + unit: 'day', + tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 + } + : {}, + beforeUpdate: (axis) => { + setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()); + }, + ticks: timeAxis + ? {} + : { + callback: (value, i) => { + const label = chartDatasets[0].data[i].y; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + font: { size: 11 }, // default is 12, we want a tad smaller + }, + reverse: true, + stacked, }, - font: { size: 11 }, // default is 12, we want a tad smaller - }, - reverse: true, - stacked, - }, - x: { - title: { display: true, text: axisTitle }, - stacked, - }, - } : { - x: { - offset: true, - type: timeAxis ? 'time' : 'category', - adapters: timeAxis ? { - date: { zone: 'utc' }, - } : {}, - time: timeAxis ? { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - } : {}, - ticks: timeAxis ? {} : { - callback: (value, i) => { - console.log("testing vertical", chartData, i); - const label = chartDatasets[0].data[i].x; - if (typeof label == 'string' && label.includes('\n')) - return label.split('\n'); - return label; + x: { + title: { display: true, text: axisTitle }, + stacked, }, - }, - stacked, - }, - y: { - title: { display: true, text: axisTitle }, - stacked, - }, - }), + } + : { + x: { + offset: true, + type: timeAxis ? 'time' : 'category', + adapters: timeAxis + ? { + date: { zone: 'utc' }, + } + : {}, + time: timeAxis + ? { + unit: 'day', + tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 + } + : {}, + ticks: timeAxis + ? {} + : { + callback: (value, i) => { + console.log('testing vertical', chartData, i); + const label = chartDatasets[0].data[i].x; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + }, + stacked, + }, + y: { + title: { display: true, text: axisTitle }, + stacked, + }, + }), }, plugins: { ...(lineAnnotations?.length > 0 && { annotation: { clip: false, - annotations: lineAnnotations.map((a, i) => ({ - type: 'line', - label: { - display: true, - padding: { x: 3, y: 1 }, - borderRadius: 0, - backgroundColor: 'rgba(0,0,0,.7)', - color: 'rgba(255,255,255,1)', - font: { size: 10 }, - position: a.position || 'start', - content: a.label, - yAdjust: annotationsAtTop ? -12 : 0, - }, - ...(isHorizontal ? { xMin: a.value, xMax: a.value } - : { yMin: a.value, yMax: a.value }), - borderColor: a.color || colors.onBackground, - borderWidth: 3, - borderDash: [3, 3], - } satisfies AnnotationOptions)), - } + annotations: lineAnnotations.map( + (a, i) => + ({ + type: 'line', + label: { + display: true, + padding: { x: 3, y: 1 }, + borderRadius: 0, + backgroundColor: 'rgba(0,0,0,.7)', + color: 'rgba(255,255,255,1)', + font: { size: 10 }, + position: a.position || 'start', + content: a.label, + yAdjust: annotationsAtTop ? -12 : 0, + }, + ...(isHorizontal + ? { xMin: a.value, xMax: a.value } + : { yMin: a.value, yMax: a.value }), + borderColor: a.color || colors.onBackground, + borderWidth: 3, + borderDash: [3, 3], + }) satisfies AnnotationOptions, + ), + }, }), - } + }, }} // if there are annotations at the top of the chart, it overlaps with the legend // so we need to increase the spacing between the legend and the chart // https://stackoverflow.com/a/73498454 - plugins={annotationsAtTop && [{ - id: "increase-legend-spacing", - beforeInit(chart) { - const originalFit = (chart.legend as any).fit; - (chart.legend as any).fit = function fit() { - originalFit.bind(chart.legend)(); - this.height += 12; - }; - } - }]} /> + plugins={ + annotationsAtTop && [ + { + id: 'increase-legend-spacing', + beforeInit(chart) { + const originalFit = (chart.legend as any).fit; + (chart.legend as any).fit = function fit() { + originalFit.bind(chart.legend)(); + this.height += 12; + }; + }, + }, + ] + } + /> - ) -} + ); +}; export default Chart; diff --git a/www/js/components/DiaryButton.tsx b/www/js/components/DiaryButton.tsx index 16c716f93..6a04cb079 100644 --- a/www/js/components/DiaryButton.tsx +++ b/www/js/components/DiaryButton.tsx @@ -1,28 +1,25 @@ -import React from "react"; +import React from 'react'; import { StyleSheet } from 'react-native'; import { Button, ButtonProps, useTheme } from 'react-native-paper'; import color from 'color'; -import { Icon } from "./Icon"; - -type Props = ButtonProps & { fillColor?: string, borderColor?: string }; -const DiaryButton = ({ children, fillColor, borderColor, icon, ...rest } : Props) => { +import { Icon } from './Icon'; +type Props = ButtonProps & { fillColor?: string; borderColor?: string }; +const DiaryButton = ({ children, fillColor, borderColor, icon, ...rest }: Props) => { const { colors } = useTheme(); const textColor = rest.textColor || (fillColor ? colors.onPrimary : colors.primary); return ( - @@ -51,7 +48,7 @@ const s = StyleSheet.create({ icon: { marginRight: 4, verticalAlign: 'middle', - } + }, }); export default DiaryButton; diff --git a/www/js/components/Icon.tsx b/www/js/components/Icon.tsx index 0b4c7253e..3d13d0996 100644 --- a/www/js/components/Icon.tsx +++ b/www/js/components/Icon.tsx @@ -7,14 +7,19 @@ import React from 'react'; import { StyleSheet } from 'react-native'; import { IconButton } from 'react-native-paper'; -import { Props as IconButtonProps } from 'react-native-paper/lib/typescript/src/components/IconButton/IconButton' +import { Props as IconButtonProps } from 'react-native-paper/lib/typescript/src/components/IconButton/IconButton'; -export const Icon = ({style, ...rest}: IconButtonProps) => { +export const Icon = ({ style, ...rest }: IconButtonProps) => { return ( - + ); -} +}; const s = StyleSheet.create({ icon: { diff --git a/www/js/components/LeafletView.tsx b/www/js/components/LeafletView.tsx index cf26cb933..b0b60912b 100644 --- a/www/js/components/LeafletView.tsx +++ b/www/js/components/LeafletView.tsx @@ -1,15 +1,14 @@ -import React, { useEffect, useRef, useState } from "react"; -import { View } from "react-native"; -import { useTheme } from "react-native-paper"; -import L from "leaflet"; +import React, { useEffect, useRef, useState } from 'react'; +import { View } from 'react-native'; +import { useTheme } from 'react-native-paper'; +import L from 'leaflet'; const mapSet = new Set(); export function invalidateMaps() { - mapSet.forEach(map => map.invalidateSize()); + mapSet.forEach((map) => map.invalidateSize()); } const LeafletView = ({ geojson, opts, ...otherProps }) => { - const mapElRef = useRef(null); const leafletMapRef = useRef(null); const geoJsonIdRef = useRef(null); @@ -23,7 +22,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { }).addTo(map); const gj = L.geoJson(geojson.data, { pointToLayer: pointToLayer, - style: (feature) => feature.style + style: (feature) => feature.style, }).addTo(map); const gjBounds = gj.getBounds().pad(0.2); map.fitBounds(gjBounds); @@ -46,7 +45,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { (happens because of FlashList's view recycling on the trip cards: https://shopify.github.io/flash-list/docs/recycling) */ if (geoJsonIdRef.current && geoJsonIdRef.current !== geojson.data.id) { - leafletMapRef.current.eachLayer(layer => leafletMapRef.current.removeLayer(layer)); + leafletMapRef.current.eachLayer((layer) => leafletMapRef.current.removeLayer(layer)); initMap(leafletMapRef.current); } @@ -54,7 +53,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { const mapElId = `map-${geojson.data.id.replace(/[^a-zA-Z0-9]/g, '')}`; return ( - + -
+
); }; -const startIcon = L.divIcon({className: 'leaflet-div-icon-start', iconSize: [18, 18]}); -const stopIcon = L.divIcon({className: 'leaflet-div-icon-stop', iconSize: [18, 18]}); +const startIcon = L.divIcon({ className: 'leaflet-div-icon-start', iconSize: [18, 18] }); +const stopIcon = L.divIcon({ className: 'leaflet-div-icon-stop', iconSize: [18, 18] }); - const pointToLayer = (feature, latlng) => { - switch(feature.properties.feature_type) { - case "start_place": return L.marker(latlng, {icon: startIcon}); - case "end_place": return L.marker(latlng, {icon: stopIcon}); +const pointToLayer = (feature, latlng) => { + switch (feature.properties.feature_type) { + case 'start_place': + return L.marker(latlng, { icon: startIcon }); + case 'end_place': + return L.marker(latlng, { icon: stopIcon }); // case "stop": return L.circleMarker(latlng); - default: alert("Found unknown type in feature" + feature); return L.marker(latlng) + default: + alert('Found unknown type in feature' + feature); + return L.marker(latlng); } }; diff --git a/www/js/components/LineChart.tsx b/www/js/components/LineChart.tsx index 66d21aac2..456642a63 100644 --- a/www/js/components/LineChart.tsx +++ b/www/js/components/LineChart.tsx @@ -1,11 +1,9 @@ -import React from "react"; -import Chart, { Props as ChartProps } from "./Chart"; +import React from 'react'; +import Chart, { Props as ChartProps } from './Chart'; -type Props = Omit & { } +type Props = Omit & {}; const LineChart = ({ ...rest }: Props) => { - return ( - - ); -} + return ; +}; export default LineChart; diff --git a/www/js/components/NavBarButton.tsx b/www/js/components/NavBarButton.tsx index 7e9cb1217..294015152 100644 --- a/www/js/components/NavBarButton.tsx +++ b/www/js/components/NavBarButton.tsx @@ -1,31 +1,39 @@ -import React from "react"; -import { View, StyleSheet } from "react-native"; -import color from "color"; -import { Button, useTheme } from "react-native-paper"; -import { Icon } from "./Icon"; +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import color from 'color'; +import { Button, useTheme } from 'react-native-paper'; +import { Icon } from './Icon'; const NavBarButton = ({ children, icon, onPressAction, ...otherProps }) => { - const { colors } = useTheme(); - const buttonColor = color(colors.onBackground).alpha(.07).rgb().string(); - const outlineColor = color(colors.onBackground).alpha(.2).rgb().string(); + const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string(); + const outlineColor = color(colors.onBackground).alpha(0.2).rgb().string(); - return (<> - - ); + return ( + <> + + + ); }; export const s = StyleSheet.create({ diff --git a/www/js/components/QrCode.tsx b/www/js/components/QrCode.tsx index 74c66863f..83498f5da 100644 --- a/www/js/components/QrCode.tsx +++ b/www/js/components/QrCode.tsx @@ -2,45 +2,56 @@ Once the parent components, anyplace this is used, are converted to React, we can remove this wrapper and just use the QRCode component directly */ -import React from "react"; -import QRCode from "react-qr-code"; +import React from 'react'; +import QRCode from 'react-qr-code'; export function shareQR(message) { /*code adapted from demo of react-qr-code*/ - const svg = document.querySelector(".qr-code"); + const svg = document.querySelector('.qr-code'); const svgData = new XMLSerializer().serializeToString(svg); const img = new Image(); img.onload = () => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); - const pngFile = canvas.toDataURL("image/png"); + const pngFile = canvas.toDataURL('image/png'); var prepopulateQRMessage = {}; prepopulateQRMessage['files'] = [pngFile]; prepopulateQRMessage['url'] = message; prepopulateQRMessage['message'] = message; //text saved to files with image! - window['plugins'].socialsharing.shareWithOptions(prepopulateQRMessage, function (result) { - console.log("Share completed? " + result.completed); // On Android apps mostly return false even while it's true - console.log("Shared to app: " + result.app); // On Android result.app is currently empty. On iOS it's empty when sharing is cancelled (result.completed=false) - }, function (msg) { - console.log("Sharing failed with message: " + msg); - }); - } + window['plugins'].socialsharing.shareWithOptions( + prepopulateQRMessage, + function (result) { + console.log('Share completed? ' + result.completed); // On Android apps mostly return false even while it's true + console.log('Shared to app: ' + result.app); // On Android result.app is currently empty. On iOS it's empty when sharing is cancelled (result.completed=false) + }, + function (msg) { + console.log('Sharing failed with message: ' + msg); + }, + ); + }; img.src = `data:image/svg+xml;base64,${btoa(svgData)}`; } const QrCode = ({ value, ...rest }) => { - let hasLink = value.toString().includes("//"); - if(!hasLink) { - value = "emission://login_token?token=" + value; + let hasLink = value.toString().includes('//'); + if (!hasLink) { + value = 'emission://login_token?token=' + value; } - - return ; + + return ( + + ); }; export default QrCode; diff --git a/www/js/components/ToggleSwitch.tsx b/www/js/components/ToggleSwitch.tsx index 5fdf1cc46..7f753a9a0 100644 --- a/www/js/components/ToggleSwitch.tsx +++ b/www/js/components/ToggleSwitch.tsx @@ -1,13 +1,14 @@ import React from 'react'; -import { SegmentedButtons, SegmentedButtonsProps, useTheme } from "react-native-paper"; - -const ToggleSwitch = ({ value, buttons, ...rest}: SegmentedButtonsProps) => { +import { SegmentedButtons, SegmentedButtonsProps, useTheme } from 'react-native-paper'; +const ToggleSwitch = ({ value, buttons, ...rest }: SegmentedButtonsProps) => { const { colors } = useTheme(); return ( - rest.onValueChange(v as any)} - buttons={buttons.map(o => ({ + rest.onValueChange(v as any)} + buttons={buttons.map((o) => ({ value: o.value, icon: o.icon, uncheckedColor: colors.onSurfaceDisabled, @@ -18,9 +19,11 @@ const ToggleSwitch = ({ value, buttons, ...rest}: SegmentedButtonsProps) => { borderBottomWidth: rest.density == 'high' ? 0 : 1, backgroundColor: value == o.value ? colors.elevation.level1 : colors.surfaceDisabled, }, - ...o - }))} {...rest} /> - ) -} + ...o, + }))} + {...rest} + /> + ); +}; export default ToggleSwitch; diff --git a/www/js/components/charting.ts b/www/js/components/charting.ts index f0da14619..77490f7ff 100644 --- a/www/js/components/charting.ts +++ b/www/js/components/charting.ts @@ -15,15 +15,23 @@ export const defaultPalette = [ '#80afad', // teal oklch(72% 0.05 192) ]; -export function getChartHeight(chartDatasets, numVisibleDatasets, indexAxis, isHorizontal, stacked) { +export function getChartHeight( + chartDatasets, + numVisibleDatasets, + indexAxis, + isHorizontal, + stacked, +) { /* when horizontal charts have more data, they should get taller so they don't look squished */ if (isHorizontal) { // 'ideal' chart height is based on the number of datasets and number of unique index values const uniqueIndexVals = []; - chartDatasets.forEach(e => e.data.forEach(r => { - if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); - })); + chartDatasets.forEach((e) => + e.data.forEach((r) => { + if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); + }), + ); const numIndexVals = uniqueIndexVals.length; const heightPerIndexVal = stacked ? 36 : numVisibleDatasets * 8; const idealChartHeight = heightPerIndexVal * numIndexVals; @@ -41,11 +49,11 @@ export function getChartHeight(chartDatasets, numVisibleDatasets, indexAxis, isH function getBarHeight(stacks) { let totalHeight = 0; - console.log("ctx stacks", stacks.x); - for(let val in stacks.x) { - if(!val.startsWith('_')){ + console.log('ctx stacks', stacks.x); + for (let val in stacks.x) { + if (!val.startsWith('_')) { totalHeight += stacks.x[val]; - console.log("ctx added ", val ); + console.log('ctx added ', val); } } return totalHeight; @@ -54,27 +62,34 @@ function getBarHeight(stacks) { //fill pattern creation //https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns function createDiagonalPattern(color = 'black') { - let shape = document.createElement('canvas') - shape.width = 10 - shape.height = 10 - let c = shape.getContext('2d') - c.strokeStyle = color - c.lineWidth = 2 - c.beginPath() - c.moveTo(2, 0) - c.lineTo(10, 8) - c.stroke() - c.beginPath() - c.moveTo(0, 8) - c.lineTo(2, 10) - c.stroke() - return c.createPattern(shape, 'repeat') + let shape = document.createElement('canvas'); + shape.width = 10; + shape.height = 10; + let c = shape.getContext('2d'); + c.strokeStyle = color; + c.lineWidth = 2; + c.beginPath(); + c.moveTo(2, 0); + c.lineTo(10, 8); + c.stroke(); + c.beginPath(); + c.moveTo(0, 8); + c.lineTo(2, 10); + c.stroke(); + return c.createPattern(shape, 'repeat'); } -export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, darken=0) { +export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, darken = 0) { if (!barCtx || !currDataset) return; let bar_height = getBarHeight(barCtx.parsed._stacks); - console.debug("bar height for", barCtx.raw.y, " is ", bar_height, "which in chart is", currDataset); + console.debug( + 'bar height for', + barCtx.raw.y, + ' is ', + bar_height, + 'which in chart is', + currDataset, + ); let meteredColor; if (bar_height > meter.high) meteredColor = colors.danger; else if (bar_height > meter.middle) meteredColor = colors.warn; @@ -95,7 +110,7 @@ const meterColors = { // https://www.joshwcomeau.com/gradient-generator?colors=fcab00|ba0000&angle=90&colorMode=lab&precision=3&easingCurve=0.25|0.75|0.75|0.25 between: ['#fcab00', '#ef8215', '#db5e0c', '#ce3d03', '#b70100'], // yellow-orange-red above: '#440000', // dark red -} +}; export function getGradient(chart, meter, currDataset, barCtx, alpha = null, darken = 0) { const { ctx, chartArea, scales } = chart; @@ -104,19 +119,26 @@ export function getGradient(chart, meter, currDataset, barCtx, alpha = null, dar const total = getBarHeight(barCtx.parsed._stacks); alpha = alpha || (currDataset.label == meter.dash_key ? 0.2 : 1); if (total < meter.middle) { - const adjColor = darken||alpha ? color(meterColors.below).darken(darken).alpha(alpha).rgb().string() : meterColors.below; + const adjColor = + darken || alpha + ? color(meterColors.below).darken(darken).alpha(alpha).rgb().string() + : meterColors.below; return adjColor; } const scaleMaxX = scales.x._range.max; gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0); meterColors.between.forEach((clr, i) => { - const clrPosition = ((i + 1) / meterColors.between.length) * (meter.high - meter.middle) + meter.middle; + const clrPosition = + ((i + 1) / meterColors.between.length) * (meter.high - meter.middle) + meter.middle; const adjColor = darken || alpha ? color(clr).darken(darken).alpha(alpha).rgb().string() : clr; gradient.addColorStop(Math.min(clrPosition, scaleMaxX) / scaleMaxX, adjColor); }); if (scaleMaxX > meter.high + 20) { - const adjColor = darken||alpha ? color(meterColors.above).darken(0.2).alpha(alpha).rgb().string() : meterColors.above; - gradient.addColorStop((meter.high+20) / scaleMaxX, adjColor); + const adjColor = + darken || alpha + ? color(meterColors.above).darken(0.2).alpha(alpha).rgb().string() + : meterColors.above; + gradient.addColorStop((meter.high + 20) / scaleMaxX, adjColor); } return gradient; } @@ -129,9 +151,9 @@ export function getGradient(chart, meter, currDataset, barCtx, alpha = null, dar export function darkenOrLighten(baseColor: string, change: number) { if (!baseColor) return baseColor; let colorObj = color(baseColor); - if(change < 0) { + if (change < 0) { // darkening appears more drastic than lightening, so we will be less aggressive (scale change by .5) - return colorObj.darken(Math.abs(change * .5)).hex(); + return colorObj.darken(Math.abs(change * 0.5)).hex(); } else { return colorObj.lighten(Math.abs(change)).hex(); } @@ -150,7 +172,7 @@ export const dedupColors = (colors: string[][]) => { if (duplicates.length > 1) { // there are duplicates; calculate an evenly-spaced adjustment for each one duplicates.forEach(([k, c], i) => { - const change = -maxAdjustment + (maxAdjustment*2 / (duplicates.length - 1)) * i; + const change = -maxAdjustment + ((maxAdjustment * 2) / (duplicates.length - 1)) * i; dedupedColors[k] = darkenOrLighten(clr, change); }); } else if (!dedupedColors[key]) { @@ -158,4 +180,4 @@ export const dedupColors = (colors: string[][]) => { } } return dedupedColors; -} +}; diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 6d9b2b372..9c28958ac 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -1,55 +1,59 @@ -import i18next from "i18next"; -import { displayError, logDebug, logWarn } from "../plugin/logger"; -import { getAngularService } from "../angular-react-helper"; -import { fetchUrlCached } from "../commHelper"; -import { storageClear, storageGet, storageSet } from "../plugin/storage"; +import i18next from 'i18next'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; +import { getAngularService } from '../angular-react-helper'; +import { fetchUrlCached } from '../commHelper'; +import { storageClear, storageGet, storageSet } from '../plugin/storage'; -export const CONFIG_PHONE_UI="config/app_ui_config"; -export const CONFIG_PHONE_UI_KVSTORE ="CONFIG_PHONE_UI"; +export const CONFIG_PHONE_UI = 'config/app_ui_config'; +export const CONFIG_PHONE_UI_KVSTORE = 'CONFIG_PHONE_UI'; export let storedConfig = null; export let configChanged = false; -export const setConfigChanged = (b) => configChanged = b; +export const setConfigChanged = (b) => (configChanged = b); const _getStudyName = function (connectUrl) { const orig_host = new URL(connectUrl).hostname; - const first_domain = orig_host.split(".")[0]; - if (first_domain == "openpath-stage") { return "stage"; } - const openpath_index = first_domain.search("-openpath"); - if (openpath_index == -1) { return undefined; } + const first_domain = orig_host.split('.')[0]; + if (first_domain == 'openpath-stage') { + return 'stage'; + } + const openpath_index = first_domain.search('-openpath'); + if (openpath_index == -1) { + return undefined; + } const study_name = first_domain.substr(0, openpath_index); return study_name; -} +}; const _fillStudyName = function (config) { if (!config.name) { if (config.server) { config.name = _getStudyName(config.server.connectUrl); } else { - config.name = "dev"; + config.name = 'dev'; } } -} +}; const _backwardsCompatSurveyFill = function (config) { if (!config.survey_info) { config.survey_info = { - "surveys": { - "UserProfileSurvey": { - "formPath": "json/demo-survey-v2.json", - "version": 1, - "compatibleWith": 1, - "dataKey": "manual/demographic_survey", - "labelTemplate": { - "en": "Answered", - "es": "Contestada" - } - } + surveys: { + UserProfileSurvey: { + formPath: 'json/demo-survey-v2.json', + version: 1, + compatibleWith: 1, + dataKey: 'manual/demographic_survey', + labelTemplate: { + en: 'Answered', + es: 'Contestada', + }, + }, }, - "trip-labels": "MULTILABEL" - } + 'trip-labels': 'MULTILABEL', + }; } -} +}; /* Fetch and cache any surveys resources that are referenced by URL in the config, as well as the label_options config if it is present. @@ -58,54 +62,57 @@ const _backwardsCompatSurveyFill = function (config) { const cacheResourcesFromConfig = (config) => { if (config.survey_info?.surveys) { Object.values(config.survey_info.surveys).forEach((survey) => { - if (!survey?.['formPath']) - throw new Error(i18next.t('config.survey-missing-formpath')); + if (!survey?.['formPath']) throw new Error(i18next.t('config.survey-missing-formpath')); fetchUrlCached(survey['formPath']); }); } if (config.label_options) { fetchUrlCached(config.label_options); } -} +}; const readConfigFromServer = async (label) => { const config = await fetchConfig(label); - logDebug("Successfully found config, result is " + JSON.stringify(config).substring(0, 10)); + logDebug('Successfully found config, result is ' + JSON.stringify(config).substring(0, 10)); // fetch + cache any resources referenced in the config, but don't 'await' them so we don't block // the config loading process cacheResourcesFromConfig(config); - const connectionURL = config.server ? config.server.connectUrl : "dev defaults"; + const connectionURL = config.server ? config.server.connectUrl : 'dev defaults'; _fillStudyName(config); _backwardsCompatSurveyFill(config); - logDebug("Successfully downloaded config with version " + config.version - + " for " + config.intro.translated_text.en.deployment_name - + " and data collection URL " + connectionURL); + logDebug( + 'Successfully downloaded config with version ' + + config.version + + ' for ' + + config.intro.translated_text.en.deployment_name + + ' and data collection URL ' + + connectionURL, + ); return config; -} +}; const fetchConfig = async (label, alreadyTriedLocal = false) => { - logDebug("Received request to join " + label); + logDebug('Received request to join ' + label); const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${label}.nrel-op.json`; if (!__DEV__ || alreadyTriedLocal) { - logDebug("Fetching config from github"); + logDebug('Fetching config from github'); const r = await fetch(downloadURL); if (!r.ok) throw new Error('Unable to fetch config from github'); return r.json(); - } - else { - logDebug("Running in dev environment, checking for locally hosted config"); + } else { + logDebug('Running in dev environment, checking for locally hosted config'); try { const r = await fetch('http://localhost:9090/configs/' + label + '.nrel-op.json'); if (!r.ok) throw new Error('Local config not found'); return r.json(); } catch (err) { - logDebug("Local config not found"); + logDebug('Local config not found'); return fetchConfig(label, true); } } -} +}; /* * We want to support both old style and new style tokens. @@ -120,12 +127,12 @@ const fetchConfig = async (label, alreadyTriedLocal = false) => { * So let's support two separate functions here - extractStudyName and extractSubgroup */ function extractStudyName(token) { - const tokenParts = token.split("_"); + const tokenParts = token.split('_'); if (tokenParts.length < 3) { // all tokens must have at least nrelop_[study name]_... - throw new Error(i18next.t('config.not-enough-parts-old-style', { "token": token })); + throw new Error(i18next.t('config.not-enough-parts-old-style', { token: token })); } - if (tokenParts[0] != "nrelop") { + if (tokenParts[0] != 'nrelop') { throw new Error(i18next.t('config.no-nrelop-start', { token: token })); } return tokenParts[1]; @@ -134,20 +141,27 @@ function extractStudyName(token) { function extractSubgroup(token, config) { if (config.opcode) { // new style study, expects token with sub-group - const tokenParts = token.split("_"); - if (tokenParts.length <= 3) { // no subpart defined + const tokenParts = token.split('_'); + if (tokenParts.length <= 3) { + // no subpart defined throw new Error(i18next.t('config.not-enough-parts', { token: token })); } if (config.opcode.subgroups) { if (config.opcode.subgroups.indexOf(tokenParts[2]) == -1) { // subpart not in config list - throw new Error(i18next.t('config.invalid-subgroup', { token: token, subgroup: tokenParts[2], config_subgroups: config.opcode.subgroups })); + throw new Error( + i18next.t('config.invalid-subgroup', { + token: token, + subgroup: tokenParts[2], + config_subgroups: config.opcode.subgroups, + }), + ); } else { - console.log("subgroup " + tokenParts[2] + " found in list " + config.opcode.subgroups); + console.log('subgroup ' + tokenParts[2] + ' found in list ' + config.opcode.subgroups); return tokenParts[2]; } } else { - if (tokenParts[2] != "default") { + if (tokenParts[2] != 'default') { // subpart not in config list throw new Error(i18next.t('config.invalid-subgroup-no-default', { token: token })); } else { @@ -161,59 +175,66 @@ function extractSubgroup(token, config) { * only validation required is `nrelop_` and valid study name * first is already handled in extractStudyName, second is handled * by default since download will fail if it is invalid - */ - console.log("Old-style study, expecting token without a subgroup..."); + */ + console.log('Old-style study, expecting token without a subgroup...'); return undefined; } } /** -* loadNewConfig download and load a new config from the server if it is a differ -* @param {[]} newToken the new token, which includes parts for the study label, subgroup, and user -* @param {} thenGoToIntro whether to go to the intro screen after loading the config -* @param {} [existingVersion=null] if the new config's version is the same, we won't update -* @returns {boolean} boolean representing whether the config was updated or not -*/ + * loadNewConfig download and load a new config from the server if it is a differ + * @param {[]} newToken the new token, which includes parts for the study label, subgroup, and user + * @param {} thenGoToIntro whether to go to the intro screen after loading the config + * @param {} [existingVersion=null] if the new config's version is the same, we won't update + * @returns {boolean} boolean representing whether the config was updated or not + */ function loadNewConfig(newToken, existingVersion = null) { const newStudyLabel = extractStudyName(newToken); - return readConfigFromServer(newStudyLabel).then((downloadedConfig) => { - if (downloadedConfig.version == existingVersion) { - logDebug("UI_CONFIG: Not updating config because version is the same"); - return Promise.resolve(false); - } - // we want to validate before saving because we don't want to save - // an invalid configuration - const subgroup = extractSubgroup(newToken, downloadedConfig); - const toSaveConfig = { - ...downloadedConfig, - joined: { opcode: newToken, study_name: newStudyLabel, subgroup: subgroup } - } - const storeConfigPromise = window['cordova'].plugins.BEMUserCache.putRWDocument( - CONFIG_PHONE_UI, toSaveConfig); - const storeInKVStorePromise = storageSet(CONFIG_PHONE_UI_KVSTORE, toSaveConfig); - logDebug("UI_CONFIG: about to store " + JSON.stringify(toSaveConfig)); - // loaded new config, so it is both ready and changed - return Promise.all([storeConfigPromise, storeInKVStorePromise]).then( - ([result, kvStoreResult]) => { - logDebug("UI_CONFIG: Stored dynamic config in KVStore successfully, result = " + JSON.stringify(kvStoreResult)); - storedConfig = toSaveConfig; - configChanged = true; - return true; - }).catch((storeError) => - displayError(storeError, i18next.t('config.unable-to-store-config')) + return readConfigFromServer(newStudyLabel) + .then((downloadedConfig) => { + if (downloadedConfig.version == existingVersion) { + logDebug('UI_CONFIG: Not updating config because version is the same'); + return Promise.resolve(false); + } + // we want to validate before saving because we don't want to save + // an invalid configuration + const subgroup = extractSubgroup(newToken, downloadedConfig); + const toSaveConfig = { + ...downloadedConfig, + joined: { opcode: newToken, study_name: newStudyLabel, subgroup: subgroup }, + }; + const storeConfigPromise = window['cordova'].plugins.BEMUserCache.putRWDocument( + CONFIG_PHONE_UI, + toSaveConfig, ); - }).catch((fetchErr) => { - displayError(fetchErr, i18next.t('config.unable-download-config')); - }); + const storeInKVStorePromise = storageSet(CONFIG_PHONE_UI_KVSTORE, toSaveConfig); + logDebug('UI_CONFIG: about to store ' + JSON.stringify(toSaveConfig)); + // loaded new config, so it is both ready and changed + return Promise.all([storeConfigPromise, storeInKVStorePromise]) + .then(([result, kvStoreResult]) => { + logDebug( + 'UI_CONFIG: Stored dynamic config in KVStore successfully, result = ' + + JSON.stringify(kvStoreResult), + ); + storedConfig = toSaveConfig; + configChanged = true; + return true; + }) + .catch((storeError) => + displayError(storeError, i18next.t('config.unable-to-store-config')), + ); + }) + .catch((fetchErr) => { + displayError(fetchErr, i18next.t('config.unable-download-config')); + }); } export function initByUser(urlComponents) { const { token } = urlComponents; try { - return loadNewConfig(token) - .catch((fetchErr) => { - displayError(fetchErr, i18next.t('config.unable-download-config')); - }); + return loadNewConfig(token).catch((fetchErr) => { + displayError(fetchErr, i18next.t('config.unable-download-config')); + }); } catch (error) { displayError(error, i18next.t('config.invalid-opcode-format')); return Promise.reject(error); @@ -229,19 +250,21 @@ export function getConfig() { if (storedConfig) return Promise.resolve(storedConfig); return storageGet(CONFIG_PHONE_UI_KVSTORE).then((config) => { if (config && Object.keys(config).length) { - logDebug("Got config from KVStore: " + JSON.stringify(config)); + logDebug('Got config from KVStore: ' + JSON.stringify(config)); storedConfig = config; return config; } - logDebug("No config found in KVStore, fetching from native storage"); - return window['cordova'].plugins.BEMUserCache.getDocument(CONFIG_PHONE_UI, false).then((config) => { - if (config && Object.keys(config).length) { - logDebug("Got config from native storage: " + JSON.stringify(config)); - storedConfig = config; - return config; - } - logWarn("No config found in native storage either. Returning null"); - return null; - }); + logDebug('No config found in KVStore, fetching from native storage'); + return window['cordova'].plugins.BEMUserCache.getDocument(CONFIG_PHONE_UI, false).then( + (config) => { + if (config && Object.keys(config).length) { + logDebug('Got config from native storage: ' + JSON.stringify(config)); + storedConfig = config; + return config; + } + logWarn('No config found in native storage either. Returning null'); + return null; + }, + ); }); } diff --git a/www/js/config/enketo-config.js b/www/js/config/enketo-config.js index 00ea6f4be..07ac599c2 100644 --- a/www/js/config/enketo-config.js +++ b/www/js/config/enketo-config.js @@ -1,10 +1,10 @@ // https://github.com/enketo/enketo-core#global-configuration const enketoConfig = { - swipePage: false, /* Enketo's use of swipe gestures depends on jquery-touchswipe, + swipePage: false /* Enketo's use of swipe gestures depends on jquery-touchswipe, which is a legacy package, and problematic to load in webpack. - Let's just turn it off. */ - experimentalOptimizations: {}, /* We aren't using any experimental optimizations, - but it has to be defined to avoid errors */ -} + Let's just turn it off. */, + experimentalOptimizations: {} /* We aren't using any experimental optimizations, + but it has to be defined to avoid errors */, +}; export default enketoConfig; diff --git a/www/js/config/serverConn.ts b/www/js/config/serverConn.ts index e3371270b..b0850974e 100644 --- a/www/js/config/serverConn.ts +++ b/www/js/config/serverConn.ts @@ -1,13 +1,14 @@ -import { logDebug } from "../plugin/logger"; +import { logDebug } from '../plugin/logger'; export async function setServerConnSettings(config) { if (!config) return Promise.resolve(null); if (config.server) { - logDebug("connectionConfig = " + JSON.stringify(config.server)); + logDebug('connectionConfig = ' + JSON.stringify(config.server)); return window['cordova'].plugins.BEMConnectionSettings.setSettings(config.server); } else { - const defaultConfig = await window['cordova'].plugins.BEMConnectionSettings.getDefaultSettings(); - logDebug("defaultConfig = " + JSON.stringify(defaultConfig)); + const defaultConfig = + await window['cordova'].plugins.BEMConnectionSettings.getDefaultSettings(); + logDebug('defaultConfig = ' + JSON.stringify(defaultConfig)); return window['cordova'].plugins.BEMConnectionSettings.setSettings(defaultConfig); } } diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index a9680048c..1b7e2c346 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from "react"; -import useAppConfig from "../useAppConfig"; -import i18next from "i18next"; +import React, { useEffect, useState } from 'react'; +import useAppConfig from '../useAppConfig'; +import i18next from 'i18next'; const KM_TO_MILES = 0.621371; const MPS_TO_KMPH = 3.6; @@ -15,26 +15,21 @@ const MPS_TO_KMPH = 3.6; e.g. "0.07 mi", "0.75 km" */ export const formatForDisplay = (value: number): string => { let opts: Intl.NumberFormatOptions = {}; - if (value >= 100) - opts.maximumFractionDigits = 0; - else if (value >= 1) - opts.maximumSignificantDigits = 3; - else - opts.maximumFractionDigits = 2; + if (value >= 100) opts.maximumFractionDigits = 0; + else if (value >= 1) opts.maximumSignificantDigits = 3; + else opts.maximumFractionDigits = 2; return Intl.NumberFormat(i18next.language, opts).format(value); -} +}; export const convertDistance = (distMeters: number, imperial: boolean): number => { - if (imperial) - return (distMeters / 1000) * KM_TO_MILES; + if (imperial) return (distMeters / 1000) * KM_TO_MILES; return distMeters / 1000; -} +}; export const convertSpeed = (speedMetersPerSec: number, imperial: boolean): number => { - if (imperial) - return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; + if (imperial) return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; return speedMetersPerSec * MPS_TO_KMPH; -} +}; export function useImperialConfig() { const appConfig = useAppConfig(); @@ -46,11 +41,13 @@ export function useImperialConfig() { }, [appConfig]); return { - distanceSuffix: useImperial ? "mi" : "km", - speedSuffix: useImperial ? "mph" : "kmph", - getFormattedDistance: useImperial ? (d) => formatForDisplay(convertDistance(d, true)) - : (d) => formatForDisplay(convertDistance(d, false)), - getFormattedSpeed: useImperial ? (s) => formatForDisplay(convertSpeed(s, true)) - : (s) => formatForDisplay(convertSpeed(s, false)), - } + distanceSuffix: useImperial ? 'mi' : 'km', + speedSuffix: useImperial ? 'mph' : 'kmph', + getFormattedDistance: useImperial + ? (d) => formatForDisplay(convertDistance(d, true)) + : (d) => formatForDisplay(convertDistance(d, false)), + getFormattedSpeed: useImperial + ? (s) => formatForDisplay(convertSpeed(s, true)) + : (s) => formatForDisplay(convertSpeed(s, false)), + }; } diff --git a/www/js/control/AlertBar.jsx b/www/js/control/AlertBar.jsx index fbac80056..c86401b03 100644 --- a/www/js/control/AlertBar.jsx +++ b/www/js/control/AlertBar.jsx @@ -1,38 +1,37 @@ -import React from "react"; -import { Modal } from "react-native"; +import React from 'react'; +import { Modal } from 'react-native'; import { Snackbar } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { useTranslation } from 'react-i18next'; +import { SafeAreaView } from 'react-native-safe-area-context'; -const AlertBar = ({visible, setVisible, messageKey, messageAddition=undefined}) => { - const { t } = useTranslation(); - const onDismissSnackBar = () => setVisible(false); +const AlertBar = ({ visible, setVisible, messageKey, messageAddition = undefined }) => { + const { t } = useTranslation(); + const onDismissSnackBar = () => setVisible(false); - let text = ""; - if(messageAddition){ - text = t(messageKey) + messageAddition; - } - else { - text = t(messageKey); - } - - return ( - setVisible(false)} transparent={true}> - - { - onDismissSnackBar() - }, + let text = ''; + if (messageAddition) { + text = t(messageKey) + messageAddition; + } else { + text = t(messageKey); + } + + return ( + setVisible(false)} transparent={true}> + + { + onDismissSnackBar(); + }, }}> - {text} - - + {text} + + - ); - }; - -export default AlertBar; \ No newline at end of file + ); +}; + +export default AlertBar; diff --git a/www/js/control/AppStatusModal.tsx b/www/js/control/AppStatusModal.tsx index e7f5aa97b..8666f9ccf 100644 --- a/www/js/control/AppStatusModal.tsx +++ b/www/js/control/AppStatusModal.tsx @@ -1,40 +1,44 @@ -import React, { useContext, useEffect } from "react"; -import { Modal, useWindowDimensions } from "react-native"; +import React, { useContext, useEffect } from 'react'; +import { Modal, useWindowDimensions } from 'react-native'; import { Dialog, useTheme } from 'react-native-paper'; -import PermissionsControls from "../appstatus/PermissionsControls"; -import { settingStyles } from "./ProfileSettings"; -import { AppContext } from "../App"; +import PermissionsControls from '../appstatus/PermissionsControls'; +import { settingStyles } from './ProfileSettings'; +import { AppContext } from '../App'; //TODO -- import settings styles for dialog const AppStatusModal = ({ permitVis, setPermitVis }) => { - const { height: windowHeight } = useWindowDimensions(); - const { permissionStatus } = useContext(AppContext); - const { overallStatus, checkList } = permissionStatus; - const { colors } = useTheme(); + const { height: windowHeight } = useWindowDimensions(); + const { permissionStatus } = useContext(AppContext); + const { overallStatus, checkList } = permissionStatus; + const { colors } = useTheme(); - /* Listen for permissions status changes to determine if we should show the modal. */ - useEffect(() => { - if (overallStatus === false) { - setPermitVis(true); - } + /* Listen for permissions status changes to determine if we should show the modal. */ + useEffect(() => { + if (overallStatus === false) { + setPermitVis(true); + } }, [overallStatus, checkList]); - return ( - { - if(overallStatus){(setPermitVis(false))} - }} - transparent={true}> - setPermitVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - - setPermitVis(false)}> - - - - - ) -} + return ( + { + if (overallStatus) { + setPermitVis(false); + } + }} + transparent={true}> + setPermitVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + + setPermitVis(false)}> + + + + ); +}; export default AppStatusModal; diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx index d93c498a9..cc3efa8c1 100644 --- a/www/js/control/ControlCollectionHelper.tsx +++ b/www/js/control/ControlCollectionHelper.tsx @@ -1,284 +1,362 @@ -import React, { useEffect, useState } from "react"; -import { Modal, View } from "react-native"; +import React, { useEffect, useState } from 'react'; +import { Modal, View } from 'react-native'; import { Dialog, Button, Switch, Text, useTheme, TextInput } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import ActionMenu from "../components/ActionMenu"; -import { settingStyles } from "./ProfileSettings"; -import { getAngularService } from "../angular-react-helper"; +import { useTranslation } from 'react-i18next'; +import ActionMenu from '../components/ActionMenu'; +import { settingStyles } from './ProfileSettings'; +import { getAngularService } from '../angular-react-helper'; -type collectionConfig = { - is_duty_cycling: boolean, - simulate_user_interaction: boolean, - accuracy: number, - accuracy_threshold: number, - filter_distance: number, - filter_time: number, - geofence_radius: number, - ios_use_visit_notifications_for_detection: boolean, - ios_use_remote_push_for_sync: boolean, - android_geofence_responsiveness: number +type collectionConfig = { + is_duty_cycling: boolean; + simulate_user_interaction: boolean; + accuracy: number; + accuracy_threshold: number; + filter_distance: number; + filter_time: number; + geofence_radius: number; + ios_use_visit_notifications_for_detection: boolean; + ios_use_remote_push_for_sync: boolean; + android_geofence_responsiveness: number; }; export async function forceTransition(transition) { - try { - let result = await forceTransitionWrapper(transition); - window.alert('success -> '+result); - } catch (err) { - window.alert('error -> '+err); - console.log("error forcing state", err); - } + try { + let result = await forceTransitionWrapper(transition); + window.alert('success -> ' + result); + } catch (err) { + window.alert('error -> ' + err); + console.log('error forcing state', err); + } } async function accuracy2String(config) { - var accuracy = config.accuracy; - let accuracyOptions = await getAccuracyOptions(); - for (var k in accuracyOptions) { - if (accuracyOptions[k] == accuracy) { - return k; - } + var accuracy = config.accuracy; + let accuracyOptions = await getAccuracyOptions(); + for (var k in accuracyOptions) { + if (accuracyOptions[k] == accuracy) { + return k; } - return accuracy; + } + return accuracy; } export async function isMediumAccuracy() { - let config = await getConfig(); - if (!config || config == null) { - return undefined; // config not loaded when loading ui, set default as false + let config = await getConfig(); + if (!config || config == null) { + return undefined; // config not loaded when loading ui, set default as false + } else { + var v = await accuracy2String(config); + console.log('window platform is', window['cordova'].platformId); + if (window['cordova'].platformId == 'ios') { + return ( + v != 'kCLLocationAccuracyBestForNavigation' && + v != 'kCLLocationAccuracyBest' && + v != 'kCLLocationAccuracyTenMeters' + ); + } else if (window['cordova'].platformId == 'android') { + return v != 'PRIORITY_HIGH_ACCURACY'; } else { - var v = await accuracy2String(config); - console.log("window platform is", window['cordova'].platformId); - if (window['cordova'].platformId == 'ios') { - return v != "kCLLocationAccuracyBestForNavigation" && v != "kCLLocationAccuracyBest" && v != "kCLLocationAccuracyTenMeters"; - } else if (window['cordova'].platformId == 'android') { - return v != "PRIORITY_HIGH_ACCURACY"; - } else { - window.alert("Emission does not support this platform"); - } + window.alert('Emission does not support this platform'); } + } } export async function helperToggleLowAccuracy() { - const Logger = getAngularService("Logger"); - let tempConfig = await getConfig(); - let accuracyOptions = await getAccuracyOptions(); - let medium = await isMediumAccuracy(); - if (medium) { - if (window['cordova'].platformId == 'ios') { - tempConfig.accuracy = accuracyOptions["kCLLocationAccuracyBest"]; - } else if (window['cordova'].platformId == 'android') { - tempConfig.accuracy = accuracyOptions["PRIORITY_HIGH_ACCURACY"]; - } - } else { - if (window['cordova'].platformId == 'ios') { - tempConfig.accuracy = accuracyOptions["kCLLocationAccuracyHundredMeters"]; - } else if (window['cordova'].platformId == 'android') { - tempConfig.accuracy = accuracyOptions["PRIORITY_BALANCED_POWER_ACCURACY"]; - } + const Logger = getAngularService('Logger'); + let tempConfig = await getConfig(); + let accuracyOptions = await getAccuracyOptions(); + let medium = await isMediumAccuracy(); + if (medium) { + if (window['cordova'].platformId == 'ios') { + tempConfig.accuracy = accuracyOptions['kCLLocationAccuracyBest']; + } else if (window['cordova'].platformId == 'android') { + tempConfig.accuracy = accuracyOptions['PRIORITY_HIGH_ACCURACY']; } - try{ - let set = await setConfig(tempConfig); - console.log("setConfig Sucess"); - } catch (err) { - Logger.displayError("Error while setting collection config", err); + } else { + if (window['cordova'].platformId == 'ios') { + tempConfig.accuracy = accuracyOptions['kCLLocationAccuracyHundredMeters']; + } else if (window['cordova'].platformId == 'android') { + tempConfig.accuracy = accuracyOptions['PRIORITY_BALANCED_POWER_ACCURACY']; } + } + try { + let set = await setConfig(tempConfig); + console.log('setConfig Sucess'); + } catch (err) { + Logger.displayError('Error while setting collection config', err); + } } /* -* Simple read/write wrappers -*/ + * Simple read/write wrappers + */ -export const getState = function() { - return window['cordova'].plugins.BEMDataCollection.getState(); +export const getState = function () { + return window['cordova'].plugins.BEMDataCollection.getState(); }; export async function getHelperCollectionSettings() { - let promiseList = []; - promiseList.push(getConfig()); - promiseList.push(getAccuracyOptions()); - let resultList = await Promise.all(promiseList); - let tempConfig = resultList[0]; - let tempAccuracyOptions = resultList[1]; - return formatConfigForDisplay(tempConfig, tempAccuracyOptions); + let promiseList = []; + promiseList.push(getConfig()); + promiseList.push(getAccuracyOptions()); + let resultList = await Promise.all(promiseList); + let tempConfig = resultList[0]; + let tempAccuracyOptions = resultList[1]; + return formatConfigForDisplay(tempConfig, tempAccuracyOptions); } -const setConfig = function(config) { - return window['cordova'].plugins.BEMDataCollection.setConfig(config); +const setConfig = function (config) { + return window['cordova'].plugins.BEMDataCollection.setConfig(config); }; -const getConfig = function() { - return window['cordova'].plugins.BEMDataCollection.getConfig(); +const getConfig = function () { + return window['cordova'].plugins.BEMDataCollection.getConfig(); }; -const getAccuracyOptions = function() { - return window['cordova'].plugins.BEMDataCollection.getAccuracyOptions(); +const getAccuracyOptions = function () { + return window['cordova'].plugins.BEMDataCollection.getAccuracyOptions(); }; -export const forceTransitionWrapper = function(transition) { - return window['cordova'].plugins.BEMDataCollection.forceTransition(transition); +export const forceTransitionWrapper = function (transition) { + return window['cordova'].plugins.BEMDataCollection.forceTransition(transition); }; -const formatConfigForDisplay = function(config, accuracyOptions) { - var retVal = []; - for (var prop in config) { - if (prop == "accuracy") { - for (var name in accuracyOptions) { - if (accuracyOptions[name] == config[prop]) { - retVal.push({'key': prop, 'val': name}); - } - } - } else { - retVal.push({'key': prop, 'val': config[prop]}); +const formatConfigForDisplay = function (config, accuracyOptions) { + var retVal = []; + for (var prop in config) { + if (prop == 'accuracy') { + for (var name in accuracyOptions) { + if (accuracyOptions[name] == config[prop]) { + retVal.push({ key: prop, val: name }); } + } + } else { + retVal.push({ key: prop, val: config[prop] }); } - return retVal; -} + } + return retVal; +}; const ControlCollectionHelper = ({ editVis, setEditVis }) => { - const {colors} = useTheme(); - const Logger = getAngularService("Logger"); + const { colors } = useTheme(); + const Logger = getAngularService('Logger'); - const [ localConfig, setLocalConfig ] = useState(); - const [ accuracyActions, setAccuracyActions ] = useState([]); - const [ accuracyVis, setAccuracyVis ] = useState(false); - - async function getCollectionSettings() { - let promiseList = []; - promiseList.push(getConfig()); - promiseList.push(getAccuracyOptions()); - let resultList = await Promise.all(promiseList); - let tempConfig = resultList[0]; - setLocalConfig(tempConfig); - let tempAccuracyOptions = resultList[1]; - setAccuracyActions(formatAccuracyForActions(tempAccuracyOptions)); - return formatConfigForDisplay(tempConfig, tempAccuracyOptions); - } + const [localConfig, setLocalConfig] = useState(); + const [accuracyActions, setAccuracyActions] = useState([]); + const [accuracyVis, setAccuracyVis] = useState(false); - useEffect(() => { - getCollectionSettings(); - }, [editVis]) + async function getCollectionSettings() { + let promiseList = []; + promiseList.push(getConfig()); + promiseList.push(getAccuracyOptions()); + let resultList = await Promise.all(promiseList); + let tempConfig = resultList[0]; + setLocalConfig(tempConfig); + let tempAccuracyOptions = resultList[1]; + setAccuracyActions(formatAccuracyForActions(tempAccuracyOptions)); + return formatConfigForDisplay(tempConfig, tempAccuracyOptions); + } + + useEffect(() => { + getCollectionSettings(); + }, [editVis]); - const formatAccuracyForActions = function(accuracyOptions) { - let tempAccuracyActions = []; - for (var name in accuracyOptions) { - tempAccuracyActions.push({text: name, value: accuracyOptions[name]}); - } - return tempAccuracyActions; + const formatAccuracyForActions = function (accuracyOptions) { + let tempAccuracyActions = []; + for (var name in accuracyOptions) { + tempAccuracyActions.push({ text: name, value: accuracyOptions[name] }); } + return tempAccuracyActions; + }; - /* - * Functions to edit and save values - */ + /* + * Functions to edit and save values + */ - async function saveAndReload() { - console.log("new config = ", localConfig); - try{ - let set = await setConfig(localConfig); - setEditVis(false); - } catch(err) { - Logger.displayError("Error while setting collection config", err); - } + async function saveAndReload() { + console.log('new config = ', localConfig); + try { + let set = await setConfig(localConfig); + setEditVis(false); + } catch (err) { + Logger.displayError('Error while setting collection config', err); } + } - const onToggle = function(config_key) { - let tempConfig = {...localConfig}; - tempConfig[config_key] = !localConfig[config_key]; - setLocalConfig(tempConfig); - } + const onToggle = function (config_key) { + let tempConfig = { ...localConfig }; + tempConfig[config_key] = !localConfig[config_key]; + setLocalConfig(tempConfig); + }; - const onChooseAccuracy = function(accuracyOption) { - let tempConfig = {...localConfig}; - tempConfig.accuracy = accuracyOption.value; - setLocalConfig(tempConfig); - } + const onChooseAccuracy = function (accuracyOption) { + let tempConfig = { ...localConfig }; + tempConfig.accuracy = accuracyOption.value; + setLocalConfig(tempConfig); + }; - const onChangeText = function(newText, config_key) { - let tempConfig = {...localConfig}; - tempConfig[config_key] = parseInt(newText); - setLocalConfig(tempConfig); - } + const onChangeText = function (newText, config_key) { + let tempConfig = { ...localConfig }; + tempConfig[config_key] = parseInt(newText); + setLocalConfig(tempConfig); + }; - /*ios vs android*/ - let filterComponent; - if(window['cordova'].platformId == 'ios') { - filterComponent = - Filter Distance - onChangeText(text, "filter_distance")}/> - - } else { - filterComponent = - Filter Interval - onChangeText(text, "filter_time")}/> - - } - let iosToggles; - if(window['cordova'].platformId == 'ios') { - iosToggles = <> + /*ios vs android*/ + let filterComponent; + if (window['cordova'].platformId == 'ios') { + filterComponent = ( + + Filter Distance + onChangeText(text, 'filter_distance')} + /> + + ); + } else { + filterComponent = ( + + Filter Interval + onChangeText(text, 'filter_time')} + /> + + ); + } + let iosToggles; + if (window['cordova'].platformId == 'ios') { + iosToggles = ( + <> {/* use visit notifications toggle NO ANDROID */} - - Use Visit Notifications - onToggle("ios_use_visit_notifications_for_detection")}> + + Use Visit Notifications + onToggle('ios_use_visit_notifications_for_detection')}> {/* sync on remote push toggle NO ANDROID */} - - Sync on remote push - onToggle("ios_use_remote_push_for_sync}")}> + + Sync on remote push + onToggle('ios_use_remote_push_for_sync}')}> - - } - let geofenceComponent; - if(window['cordova'].platformId == 'android') { - geofenceComponent = - Geofence Responsiveness - onChangeText(text, "android_geofence_responsiveness")}/> - - } + + ); + } + let geofenceComponent; + if (window['cordova'].platformId == 'android') { + geofenceComponent = ( + + Geofence Responsiveness + onChangeText(text, 'android_geofence_responsiveness')} + /> + + ); + } - return ( - <> - setEditVis(false)} transparent={true}> - setEditVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> - Edit Collection Settings - - {/* duty cycling toggle */} - - Duty Cycling - onToggle("is_duty_cycling")}> - - {/* simulate user toggle */} - - Simulate User - onToggle("simulate_user_interaction")}> - - {/* accuracy */} - - Accuracy - - - {/* accuracy threshold not editable*/} - - Accuracy Threshold - {localConfig?.accuracy_threshold} - - {filterComponent} - {/* geofence radius */} - - Geofence Radius - onChangeText(text, "geofence_radius")}/> - - {iosToggles} - {geofenceComponent} - - - - - - - + return ( + <> + setEditVis(false)} transparent={true}> + setEditVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + Edit Collection Settings + + {/* duty cycling toggle */} + + Duty Cycling + onToggle('is_duty_cycling')}> + + {/* simulate user toggle */} + + Simulate User + onToggle('simulate_user_interaction')}> + + {/* accuracy */} + + Accuracy + + + {/* accuracy threshold not editable*/} + + Accuracy Threshold + {localConfig?.accuracy_threshold} + + {filterComponent} + {/* geofence radius */} + + Geofence Radius + onChangeText(text, 'geofence_radius')} + /> + + {iosToggles} + {geofenceComponent} + + + + + + + + + {}}> + + ); +}; - {}}> - - ); - }; - export default ControlCollectionHelper; diff --git a/www/js/control/ControlDataTable.jsx b/www/js/control/ControlDataTable.jsx index 796b057ec..932762400 100644 --- a/www/js/control/ControlDataTable.jsx +++ b/www/js/control/ControlDataTable.jsx @@ -1,18 +1,18 @@ -import React from "react"; +import React from 'react'; import { DataTable } from 'react-native-paper'; // val with explicit call toString() to resolve bool values not showing const ControlDataTable = ({ controlData }) => { - console.log("Printing data trying to tabulate", controlData); + console.log('Printing data trying to tabulate', controlData); return ( //rows require unique keys! - {controlData?.map((e) => - + {controlData?.map((e) => ( + {e.key} {e.val.toString()} - )} + ))} ); }; @@ -23,7 +23,7 @@ const styles = { borderColor: 'rgba(0,0,0,0.25)', borderLeftWidth: 15, borderLeftColor: 'rgba(0,0,0,0.25)', - } -} + }, +}; export default ControlDataTable; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index edc0e7470..7fdf3fa37 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -1,284 +1,317 @@ -import React, { useEffect, useState } from "react"; -import { Modal, View } from "react-native"; +import React, { useEffect, useState } from 'react'; +import { Modal, View } from 'react-native'; import { Dialog, Button, Switch, Text, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import { settingStyles } from "./ProfileSettings"; -import { getAngularService } from "../angular-react-helper"; -import ActionMenu from "../components/ActionMenu"; -import SettingRow from "./SettingRow"; -import AlertBar from "./AlertBar"; -import moment from "moment"; -import { addStatEvent, statKeys } from "../plugin/clientStats"; -import { updateUser } from "../commHelper"; +import { useTranslation } from 'react-i18next'; +import { settingStyles } from './ProfileSettings'; +import { getAngularService } from '../angular-react-helper'; +import ActionMenu from '../components/ActionMenu'; +import SettingRow from './SettingRow'; +import AlertBar from './AlertBar'; +import moment from 'moment'; +import { addStatEvent, statKeys } from '../plugin/clientStats'; +import { updateUser } from '../commHelper'; /* -* BEGIN: Simple read/write wrappers -*/ + * BEGIN: Simple read/write wrappers + */ export function forcePluginSync() { - return window.cordova.plugins.BEMServerSync.forceSync(); -}; + return window.cordova.plugins.BEMServerSync.forceSync(); +} const formatConfigForDisplay = (configToFormat) => { - var formatted = []; - for (let prop in configToFormat) { - formatted.push({'key': prop, 'val': configToFormat[prop]}); - } - return formatted; -} + var formatted = []; + for (let prop in configToFormat) { + formatted.push({ key: prop, val: configToFormat[prop] }); + } + return formatted; +}; -const setConfig = function(config) { - return window.cordova.plugins.BEMServerSync.setConfig(config); - }; +const setConfig = function (config) { + return window.cordova.plugins.BEMServerSync.setConfig(config); +}; -const getConfig = function() { - return window.cordova.plugins.BEMServerSync.getConfig(); +const getConfig = function () { + return window.cordova.plugins.BEMServerSync.getConfig(); }; export async function getHelperSyncSettings() { - let tempConfig = await getConfig(); - return formatConfigForDisplay(tempConfig); + let tempConfig = await getConfig(); + return formatConfigForDisplay(tempConfig); } -const getEndTransitionKey = function() { - if(window.cordova.platformId == 'android') { - return "local.transition.stopped_moving"; - } - else if(window.cordova.platformId == 'ios') { - return "T_TRIP_ENDED"; - } -} +const getEndTransitionKey = function () { + if (window.cordova.platformId == 'android') { + return 'local.transition.stopped_moving'; + } else if (window.cordova.platformId == 'ios') { + return 'T_TRIP_ENDED'; + } +}; -type syncConfig = { sync_interval: number, - ios_use_remote_push: boolean }; +type syncConfig = { sync_interval: number; ios_use_remote_push: boolean }; //forceSync and endForceSync SettingRows & their actions -export const ForceSyncRow = ({getState}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const Logger = getAngularService('Logger'); - - const [dataPendingVis, setDataPendingVis] = useState(false); - const [dataPushedVis, setDataPushedVis] = useState(false); - - async function forceSync() { - try { - let addedEvent = addStatEvent(statKeys.BUTTON_FORCE_SYNC); - console.log("Added "+statKeys.BUTTON_FORCE_SYNC+" event"); - - let sync = await forcePluginSync(); - /* - * Change to sensorKey to "background/location" after fixing issues - * with getLastSensorData and getLastMessages in the usercache - * See https://github.com/e-mission/e-mission-phone/issues/279 for details - */ - var sensorKey = "statemachine/transition"; - let sensorDataList = await window.cordova.plugins.BEMUserCache.getAllMessages(sensorKey, true); - - // If everything has been pushed, we should - // have no more trip end transitions left - let isTripEnd = function(entry) { - return entry.metadata == getEndTransitionKey(); - } - let syncLaunchedCalls = sensorDataList.filter(isTripEnd); - let syncPending = syncLaunchedCalls.length > 0; - Logger.log("sensorDataList.length = "+sensorDataList.length+ - ", syncLaunchedCalls.length = "+syncLaunchedCalls.length+ - ", syncPending? = "+syncPending); - Logger.log("sync launched = "+syncPending); - - if(syncPending) { - Logger.log(Logger.log("data is pending, showing confirm dialog")); - setDataPendingVis(true); //consent handling in modal - } else { - setDataPushedVis(true); - } - } catch (error) { - Logger.displayError("Error while forcing sync", error); - } - }; - - const getStartTransitionKey = function() { - if(window.cordova.platformId == 'android') { - return "local.transition.exited_geofence"; - } - else if(window.cordova.platformId == 'ios') { - return "T_EXITED_GEOFENCE"; - } +export const ForceSyncRow = ({ getState }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const Logger = getAngularService('Logger'); + + const [dataPendingVis, setDataPendingVis] = useState(false); + const [dataPushedVis, setDataPushedVis] = useState(false); + + async function forceSync() { + try { + let addedEvent = addStatEvent(statKeys.BUTTON_FORCE_SYNC); + console.log('Added ' + statKeys.BUTTON_FORCE_SYNC + ' event'); + + let sync = await forcePluginSync(); + /* + * Change to sensorKey to "background/location" after fixing issues + * with getLastSensorData and getLastMessages in the usercache + * See https://github.com/e-mission/e-mission-phone/issues/279 for details + */ + var sensorKey = 'statemachine/transition'; + let sensorDataList = await window.cordova.plugins.BEMUserCache.getAllMessages( + sensorKey, + true, + ); + + // If everything has been pushed, we should + // have no more trip end transitions left + let isTripEnd = function (entry) { + return entry.metadata == getEndTransitionKey(); + }; + let syncLaunchedCalls = sensorDataList.filter(isTripEnd); + let syncPending = syncLaunchedCalls.length > 0; + Logger.log( + 'sensorDataList.length = ' + + sensorDataList.length + + ', syncLaunchedCalls.length = ' + + syncLaunchedCalls.length + + ', syncPending? = ' + + syncPending, + ); + Logger.log('sync launched = ' + syncPending); + + if (syncPending) { + Logger.log(Logger.log('data is pending, showing confirm dialog')); + setDataPendingVis(true); //consent handling in modal + } else { + setDataPushedVis(true); + } + } catch (error) { + Logger.displayError('Error while forcing sync', error); } + } - const getEndTransitionKey = function() { - if(window.cordova.platformId == 'android') { - return "local.transition.stopped_moving"; - } - else if(window.cordova.platformId == 'ios') { - return "T_TRIP_ENDED"; - } + const getStartTransitionKey = function () { + if (window.cordova.platformId == 'android') { + return 'local.transition.exited_geofence'; + } else if (window.cordova.platformId == 'ios') { + return 'T_EXITED_GEOFENCE'; } + }; - const getOngoingTransitionState = function() { - if(window.cordova.platformId == 'android') { - return "local.state.ongoing_trip"; - } - else if(window.cordova.platformId == 'ios') { - return "STATE_ONGOING_TRIP"; - } + const getEndTransitionKey = function () { + if (window.cordova.platformId == 'android') { + return 'local.transition.stopped_moving'; + } else if (window.cordova.platformId == 'ios') { + return 'T_TRIP_ENDED'; } + }; - async function getTransition(transKey) { - var entry_data = {}; - const curr_state = await getState(); - entry_data.curr_state = curr_state; - if (transKey == getEndTransitionKey()) { - entry_data.curr_state = getOngoingTransitionState(); - } - entry_data.transition = transKey; - entry_data.ts = moment().unix(); - return entry_data; + const getOngoingTransitionState = function () { + if (window.cordova.platformId == 'android') { + return 'local.state.ongoing_trip'; + } else if (window.cordova.platformId == 'ios') { + return 'STATE_ONGOING_TRIP'; } + }; - async function endForceSync() { - /* First, quickly start and end the trip. Let's listen to the promise - * result for start so that we ensure ordering */ - var sensorKey = "statemachine/transition"; - let entry_data = await getTransition(getStartTransitionKey()); - let messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); - entry_data = await getTransition(getEndTransitionKey()); - messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); - forceSync(); - }; - - return ( - <> - - - - {/* dataPending */} - setDataPendingVis(false)} transparent={true}> - setDataPendingVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('data pending for push')} - - - - - - - - - - ) -} + async function getTransition(transKey) { + var entry_data = {}; + const curr_state = await getState(); + entry_data.curr_state = curr_state; + if (transKey == getEndTransitionKey()) { + entry_data.curr_state = getOngoingTransitionState(); + } + entry_data.transition = transKey; + entry_data.ts = moment().unix(); + return entry_data; + } + + async function endForceSync() { + /* First, quickly start and end the trip. Let's listen to the promise + * result for start so that we ensure ordering */ + var sensorKey = 'statemachine/transition'; + let entry_data = await getTransition(getStartTransitionKey()); + let messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + entry_data = await getTransition(getEndTransitionKey()); + messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + forceSync(); + } + + return ( + <> + + + + {/* dataPending */} + setDataPendingVis(false)} transparent={true}> + setDataPendingVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('data pending for push')} + + + + + + + + + + ); +}; //UI for editing the sync config const ControlSyncHelper = ({ editVis, setEditVis }) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const Logger = getAngularService("Logger"); - - const [ localConfig, setLocalConfig ] = useState(); - const [ intervalVis, setIntervalVis ] = useState(false); - - /* - * Functions to read and format values for display - */ - async function getSyncSettings() { - let tempConfig = await getConfig(); - setLocalConfig(tempConfig); - } + const { t } = useTranslation(); + const { colors } = useTheme(); + const Logger = getAngularService('Logger'); - useEffect(() => { - getSyncSettings(); - }, [editVis]) - - const syncIntervalActions = [ - {text: "1 min", value: 60}, - {text: "10 min", value: 10 * 60}, - {text: "30 min", value: 30 * 60}, - {text: "1 hr", value: 60 * 60} - ] - - /* - * Functions to edit and save values - */ - async function saveAndReload() { - console.log("new config = "+localConfig); - try{ - let set = setConfig(localConfig); - //NOTE -- we need to make sure we update these settings in ProfileSettings :) -- getting rid of broadcast handling for migration!! - updateUser({ - // TODO: worth thinking about where best to set this - // Currently happens in native code. Now that we are switching - // away from parse, we can store this from javascript here. - // or continue to store from native - // this is easier for people to see, but means that calls to - // native, even through the javascript interface are not complete - curr_sync_interval: localConfig.sync_interval - }); - } catch (err) - { - console.log("error with setting sync config", err); - Logger.displayError("Error while setting sync config", err); - } - } + const [localConfig, setLocalConfig] = useState(); + const [intervalVis, setIntervalVis] = useState(false); - const onChooseInterval = function(interval) { - let tempConfig = {...localConfig}; - tempConfig.sync_interval = interval.value; - setLocalConfig(tempConfig); - } + /* + * Functions to read and format values for display + */ + async function getSyncSettings() { + let tempConfig = await getConfig(); + setLocalConfig(tempConfig); + } + + useEffect(() => { + getSyncSettings(); + }, [editVis]); - const onTogglePush = function() { - let tempConfig = {...localConfig}; - tempConfig.ios_use_remote_push = !localConfig.ios_use_remote_push; - setLocalConfig(tempConfig); + const syncIntervalActions = [ + { text: '1 min', value: 60 }, + { text: '10 min', value: 10 * 60 }, + { text: '30 min', value: 30 * 60 }, + { text: '1 hr', value: 60 * 60 }, + ]; + + /* + * Functions to edit and save values + */ + async function saveAndReload() { + console.log('new config = ' + localConfig); + try { + let set = setConfig(localConfig); + //NOTE -- we need to make sure we update these settings in ProfileSettings :) -- getting rid of broadcast handling for migration!! + updateUser({ + // TODO: worth thinking about where best to set this + // Currently happens in native code. Now that we are switching + // away from parse, we can store this from javascript here. + // or continue to store from native + // this is easier for people to see, but means that calls to + // native, even through the javascript interface are not complete + curr_sync_interval: localConfig.sync_interval, + }); + } catch (err) { + console.log('error with setting sync config', err); + Logger.displayError('Error while setting sync config', err); } + } - /* - * configure the UI - */ - let toggle; - if(window.cordova.platformId == 'ios'){ - toggle = - Use Remote Push - - - } - - return ( - <> - {/* popup to show when we want to edit */} - setEditVis(false)} transparent={true}> - setEditVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> - Edit Sync Settings - - - Sync Interval - - - {toggle} - - - - - - - - - {}}> - - ); + const onChooseInterval = function (interval) { + let tempConfig = { ...localConfig }; + tempConfig.sync_interval = interval.value; + setLocalConfig(tempConfig); }; - + + const onTogglePush = function () { + let tempConfig = { ...localConfig }; + tempConfig.ios_use_remote_push = !localConfig.ios_use_remote_push; + setLocalConfig(tempConfig); + }; + + /* + * configure the UI + */ + let toggle; + if (window.cordova.platformId == 'ios') { + toggle = ( + + Use Remote Push + + + ); + } + + return ( + <> + {/* popup to show when we want to edit */} + setEditVis(false)} transparent={true}> + setEditVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + Edit Sync Settings + + + Sync Interval + + + {toggle} + + + + + + + + + {}}> + + ); +}; + export default ControlSyncHelper; diff --git a/www/js/control/DataDatePicker.tsx b/www/js/control/DataDatePicker.tsx index 83e0986b2..7f143f3bd 100644 --- a/www/js/control/DataDatePicker.tsx +++ b/www/js/control/DataDatePicker.tsx @@ -1,14 +1,14 @@ // this date picker element is set up to handle the "download data from day" in ProfileSettings // it relies on an angular service (Control Helper) but when we migrate that we might want to download a range instead of single -import React from "react"; +import React from 'react'; import { DatePickerModal } from 'react-native-paper-dates'; -import { useTranslation } from "react-i18next"; -import { getAngularService } from "../angular-react-helper"; +import { useTranslation } from 'react-i18next'; +import { getAngularService } from '../angular-react-helper'; -const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { +const DataDatePicker = ({ date, setDate, open, setOpen, minDate }) => { const { t, i18n } = useTranslation(); //able to pull lang from this - const ControlHelper = getAngularService("ControlHelper"); + const ControlHelper = getAngularService('ControlHelper'); const onDismiss = React.useCallback(() => { setOpen(false); @@ -20,27 +20,27 @@ const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { setDate(params.date); ControlHelper.getMyData(params.date); }, - [setOpen, setDate] + [setOpen, setDate], ); const maxDate = new Date(); return ( <> - + ); -} +}; -export default DataDatePicker; \ No newline at end of file +export default DataDatePicker; diff --git a/www/js/control/DemographicsSettingRow.jsx b/www/js/control/DemographicsSettingRow.jsx index be02dd6d3..c8a0a7297 100644 --- a/www/js/control/DemographicsSettingRow.jsx +++ b/www/js/control/DemographicsSettingRow.jsx @@ -1,13 +1,12 @@ -import React, { useState } from "react"; -import SettingRow from "./SettingRow"; -import { loadPreviousResponseForSurvey } from "../survey/enketo/enketoHelper"; -import EnketoModal from "../survey/enketo/EnketoModal"; +import React, { useState } from 'react'; +import SettingRow from './SettingRow'; +import { loadPreviousResponseForSurvey } from '../survey/enketo/enketoHelper'; +import EnketoModal from '../survey/enketo/EnketoModal'; -export const DEMOGRAPHIC_SURVEY_NAME = "UserProfileSurvey"; -export const DEMOGRAPHIC_SURVEY_DATAKEY = "manual/demographic_survey"; - -const DemographicsSettingRow = ({ }) => { +export const DEMOGRAPHIC_SURVEY_NAME = 'UserProfileSurvey'; +export const DEMOGRAPHIC_SURVEY_DATAKEY = 'manual/demographic_survey'; +const DemographicsSettingRow = ({}) => { const [surveyModalVisible, setSurveyModalVisible] = useState(false); const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); @@ -20,16 +19,26 @@ const DemographicsSettingRow = ({ }) => { }); } - return (<> - - setSurveyModalVisible(false)} - onResponseSaved={() => setSurveyModalVisible(false)} surveyName={DEMOGRAPHIC_SURVEY_NAME} - opts={{ - prefilledSurveyResponse: prevSurveyResponse, - dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, - }} /> - ); + return ( + <> + + setSurveyModalVisible(false)} + onResponseSaved={() => setSurveyModalVisible(false)} + surveyName={DEMOGRAPHIC_SURVEY_NAME} + opts={{ + prefilledSurveyResponse: prevSurveyResponse, + dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, + }} + /> + + ); }; export default DemographicsSettingRow; diff --git a/www/js/control/ExpandMenu.jsx b/www/js/control/ExpandMenu.jsx index 2f8bb8ef1..65c2fb3b3 100644 --- a/www/js/control/ExpandMenu.jsx +++ b/www/js/control/ExpandMenu.jsx @@ -1,15 +1,15 @@ -import React from "react"; +import React from 'react'; import { StyleSheet } from 'react-native'; import { List, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import { styles as rowStyles } from "./SettingRow"; +import { useTranslation } from 'react-i18next'; +import { styles as rowStyles } from './SettingRow'; const ExpansionSection = (props) => { - const { t } = useTranslation(); //this accesses the translations - const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors - const [expanded, setExpanded] = React.useState(false); + const { t } = useTranslation(); //this accesses the translations + const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors + const [expanded, setExpanded] = React.useState(false); - const handlePress = () => setExpanded(!expanded); + const handlePress = () => setExpanded(!expanded); return ( { titleStyle={rowStyles.title} expanded={expanded} onPress={handlePress}> - {props.children} + {props.children} ); }; const styles = StyleSheet.create({ section: (surfaceColor) => ({ - justifyContent: 'space-between', - backgroundColor: surfaceColor, - margin: 1, + justifyContent: 'space-between', + backgroundColor: surfaceColor, + margin: 1, }), }); -export default ExpansionSection; \ No newline at end of file +export default ExpansionSection; diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index e33d2f9a3..ad369fbff 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -1,153 +1,183 @@ -import React, { useState, useMemo, useEffect } from "react"; -import { View, StyleSheet, SafeAreaView, Modal } from "react-native"; -import { useTheme, Text, Appbar, IconButton } from "react-native-paper"; -import { getAngularService } from "../angular-react-helper"; -import { useTranslation } from "react-i18next"; +import React, { useState, useMemo, useEffect } from 'react'; +import { View, StyleSheet, SafeAreaView, Modal } from 'react-native'; +import { useTheme, Text, Appbar, IconButton } from 'react-native-paper'; +import { getAngularService } from '../angular-react-helper'; +import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; -import moment from "moment"; -import AlertBar from "./AlertBar"; - -type loadStats = { currentStart: number, gotMaxIndex: boolean, reachedEnd: boolean }; - -const LogPage = ({pageVis, setPageVis}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); - - const [ loadStats, setLoadStats ] = useState(); - const [ entries, setEntries ] = useState([]); - const [ maxErrorVis, setMaxErrorVis ] = useState(false); - const [ logErrorVis, setLogErrorVis ] = useState(false); - const [ maxMessage, setMaxMessage ] = useState(""); - const [ logMessage, setLogMessage ] = useState(""); - const [ isFetching, setIsFetching ] = useState(false); - - var RETRIEVE_COUNT = 100; - - //when opening the modal, load the entries - useEffect(() => { - refreshEntries(); - }, [pageVis]); - - async function refreshEntries() { - try { - let maxIndex = await window.Logger.getMaxIndex(); - console.log("maxIndex = "+maxIndex); - let tempStats = {} as loadStats; - tempStats.currentStart = maxIndex; - tempStats.gotMaxIndex = true; - tempStats.reachedEnd = false; - setLoadStats(tempStats); - setEntries([]); - } catch(error) { - let errorString = t('errors.while-max-index')+JSON.stringify(error, null, 2); - console.log(errorString); - setMaxMessage(errorString); - setMaxErrorVis(true); - } finally { - addEntries(); - } +import moment from 'moment'; +import AlertBar from './AlertBar'; + +type loadStats = { currentStart: number; gotMaxIndex: boolean; reachedEnd: boolean }; + +const LogPage = ({ pageVis, setPageVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const EmailHelper = getAngularService('EmailHelper'); + + const [loadStats, setLoadStats] = useState(); + const [entries, setEntries] = useState([]); + const [maxErrorVis, setMaxErrorVis] = useState(false); + const [logErrorVis, setLogErrorVis] = useState(false); + const [maxMessage, setMaxMessage] = useState(''); + const [logMessage, setLogMessage] = useState(''); + const [isFetching, setIsFetching] = useState(false); + + var RETRIEVE_COUNT = 100; + + //when opening the modal, load the entries + useEffect(() => { + refreshEntries(); + }, [pageVis]); + + async function refreshEntries() { + try { + let maxIndex = await window.Logger.getMaxIndex(); + console.log('maxIndex = ' + maxIndex); + let tempStats = {} as loadStats; + tempStats.currentStart = maxIndex; + tempStats.gotMaxIndex = true; + tempStats.reachedEnd = false; + setLoadStats(tempStats); + setEntries([]); + } catch (error) { + let errorString = t('errors.while-max-index') + JSON.stringify(error, null, 2); + console.log(errorString); + setMaxMessage(errorString); + setMaxErrorVis(true); + } finally { + addEntries(); } - - const moreDataCanBeLoaded = useMemo(() => { - return loadStats?.gotMaxIndex && !loadStats?.reachedEnd; - }, [loadStats]) - - const clear = function() { - window?.Logger.clearAll(); - window?.Logger.log(window.Logger.LEVEL_INFO, "Finished clearing entries from unified log"); - refreshEntries(); + } + + const moreDataCanBeLoaded = useMemo(() => { + return loadStats?.gotMaxIndex && !loadStats?.reachedEnd; + }, [loadStats]); + + const clear = function () { + window?.Logger.clearAll(); + window?.Logger.log(window.Logger.LEVEL_INFO, 'Finished clearing entries from unified log'); + refreshEntries(); + }; + + async function addEntries() { + console.log('calling addEntries'); + setIsFetching(true); + let start = loadStats.currentStart ? loadStats.currentStart : 0; //set a default start to prevent initial fetch error + try { + let entryList = await window.Logger.getMessagesFromIndex(start, RETRIEVE_COUNT); + processEntries(entryList); + console.log('entry list size = ' + entries.length); + setIsFetching(false); + } catch (error) { + let errStr = t('errors.while-log-messages') + JSON.stringify(error, null, 2); + console.log(errStr); + setLogMessage(errStr); + setLogErrorVis(true); + setIsFetching(false); } - - async function addEntries() { - console.log("calling addEntries"); - setIsFetching(true); - let start = loadStats.currentStart ? loadStats.currentStart : 0; //set a default start to prevent initial fetch error - try { - let entryList = await window.Logger.getMessagesFromIndex(start, RETRIEVE_COUNT); - processEntries(entryList); - console.log("entry list size = "+ entries.length); - setIsFetching(false); - } catch(error) { - let errStr = t('errors.while-log-messages')+JSON.stringify(error, null, 2); - console.log(errStr); - setLogMessage(errStr); - setLogErrorVis(true); - setIsFetching(false); - } - } - - const processEntries = function(entryList) { - let tempEntries = []; - let tempLoadStats = {...loadStats}; - entryList.forEach(e => { - e.fmt_time = moment.unix(e.ts).format("llll"); - tempEntries.push(e); - }); - if (entryList.length == 0) { - console.log("Reached the end of the scrolling"); - tempLoadStats.reachedEnd = true; - } else { - tempLoadStats.currentStart = entryList[entryList.length-1].ID; - console.log("new start index = "+loadStats.currentStart); - } - setEntries([...entries].concat(tempEntries)); //push the new entries onto the list - setLoadStats(tempLoadStats); + } + + const processEntries = function (entryList) { + let tempEntries = []; + let tempLoadStats = { ...loadStats }; + entryList.forEach((e) => { + e.fmt_time = moment.unix(e.ts).format('llll'); + tempEntries.push(e); + }); + if (entryList.length == 0) { + console.log('Reached the end of the scrolling'); + tempLoadStats.reachedEnd = true; + } else { + tempLoadStats.currentStart = entryList[entryList.length - 1].ID; + console.log('new start index = ' + loadStats.currentStart); } - - const emailLog = function () { - EmailHelper.sendEmail("loggerDB"); - } - - const separator = () => - const logItem = ({item: logItem}) => ( - {logItem.fmt_time} - {logItem.ID + "|" + logItem.level + "|" + logItem.message} - ); - - return ( - setPageVis(false)}> - - - {setPageVis(false)}}/> - - - - - refreshEntries()}/> - clear()}/> - emailLog()}/> - - - item.ID} - ItemSeparatorComponent={separator} - onEndReachedThreshold={0.5} - refreshing={isFetching} - onRefresh={() => {if(moreDataCanBeLoaded){addEntries()}}} - onEndReached={() => {if(moreDataCanBeLoaded){addEntries()}}} - /> - - - - - - ); + setEntries([...entries].concat(tempEntries)); //push the new entries onto the list + setLoadStats(tempLoadStats); + }; + + const emailLog = function () { + EmailHelper.sendEmail('loggerDB'); + }; + + const separator = () => ; + const logItem = ({ item: logItem }) => ( + + + {logItem.fmt_time} + + + {logItem.ID + '|' + logItem.level + '|' + logItem.message} + + + ); + + return ( + setPageVis(false)}> + + + { + setPageVis(false); + }} + /> + + + + + refreshEntries()} /> + clear()} /> + emailLog()} /> + + + item.ID} + ItemSeparatorComponent={separator} + onEndReachedThreshold={0.5} + refreshing={isFetching} + onRefresh={() => { + if (moreDataCanBeLoaded) { + addEntries(); + } + }} + onEndReached={() => { + if (moreDataCanBeLoaded) { + addEntries(); + } + }} + /> + + + + + + ); }; const styles = StyleSheet.create({ - date: (surfaceColor) => ({ - backgroundColor: surfaceColor, - }), - details: { - fontFamily: "monospace", - }, - entry: (surfaceColor) => ({ - backgroundColor: surfaceColor, - marginLeft: 5, - }), - }); - + date: (surfaceColor) => ({ + backgroundColor: surfaceColor, + }), + details: { + fontFamily: 'monospace', + }, + entry: (surfaceColor) => ({ + backgroundColor: surfaceColor, + marginLeft: 5, + }), +}); + export default LogPage; diff --git a/www/js/control/PopOpCode.jsx b/www/js/control/PopOpCode.jsx index 21ce227c0..510ee84fd 100644 --- a/www/js/control/PopOpCode.jsx +++ b/www/js/control/PopOpCode.jsx @@ -1,79 +1,92 @@ -import React, { useState } from "react"; +import React, { useState } from 'react'; import { Modal, StyleSheet } from 'react-native'; import { Button, Text, IconButton, Dialog, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import QrCode from "../components/QrCode"; -import AlertBar from "./AlertBar"; -import { settingStyles } from "./ProfileSettings"; +import { useTranslation } from 'react-i18next'; +import QrCode from '../components/QrCode'; +import AlertBar from './AlertBar'; +import { settingStyles } from './ProfileSettings'; -const PopOpCode = ({visibilityValue, tokenURL, action, setVis}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); +const PopOpCode = ({ visibilityValue, tokenURL, action, setVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); - const opcodeList = tokenURL.split("="); - const opcode = opcodeList[opcodeList.length - 1]; - - const [copyAlertVis, setCopyAlertVis] = useState(false); + const opcodeList = tokenURL.split('='); + const opcode = opcodeList[opcodeList.length - 1]; - const copyText = function(textToCopy){ - navigator.clipboard.writeText(textToCopy).then(() => { - setCopyAlertvis(true); - }) - } + const [copyAlertVis, setCopyAlertVis] = useState(false); - let copyButton; - if (window.cordova.platformId == "ios"){ - copyButton = {copyText(opcode); setCopyAlertVis(true)}} style={styles.button}/> - } + const copyText = function (textToCopy) { + navigator.clipboard.writeText(textToCopy).then(() => { + setCopyAlertvis(true); + }); + }; - return ( - <> - setVis(false)} - transparent={true}> - setVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t("general-settings.qrcode")} - - {t("general-settings.qrcode-share-title")} - - {opcode} - - - action()} style={styles.button}/> - {copyButton} - - - - + let copyButton; + if (window.cordova.platformId == 'ios') { + copyButton = ( + { + copyText(opcode); + setCopyAlertVis(true); + }} + style={styles.button} + /> + ); + } - - - ) -} + return ( + <> + setVis(false)} transparent={true}> + setVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.qrcode')} + + {t('general-settings.qrcode-share-title')} + + {opcode} + + + action()} style={styles.button} /> + {copyButton} + + + + + + + + ); +}; const styles = StyleSheet.create({ - title: - { - alignItems: 'center', - justifyContent: 'center', - }, - content: { - alignItems: 'center', - justifyContent: 'center', - margin: 5 - }, - button: { - margin: 'auto', - }, - opcode: { - fontFamily: "monospace", - wordBreak: "break-word", - marginTop: 5 - }, - text : { - fontWeight: 'bold', - marginBottom: 5 - } - }); + title: { + alignItems: 'center', + justifyContent: 'center', + }, + content: { + alignItems: 'center', + justifyContent: 'center', + margin: 5, + }, + button: { + margin: 'auto', + }, + opcode: { + fontFamily: 'monospace', + wordBreak: 'break-word', + marginTop: 5, + }, + text: { + fontWeight: 'bold', + marginBottom: 5, + }, +}); -export default PopOpCode; \ No newline at end of file +export default PopOpCode; diff --git a/www/js/control/PrivacyPolicyModal.tsx b/www/js/control/PrivacyPolicyModal.tsx index 7a67426ac..27cb907dd 100644 --- a/www/js/control/PrivacyPolicyModal.tsx +++ b/www/js/control/PrivacyPolicyModal.tsx @@ -1,35 +1,34 @@ -import React from "react"; -import { Modal, useWindowDimensions, ScrollView } from "react-native"; +import React from 'react'; +import { Modal, useWindowDimensions, ScrollView } from 'react-native'; import { Dialog, Button, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import PrivacyPolicy from "../onboarding/PrivacyPolicy"; -import { settingStyles } from "./ProfileSettings"; +import { useTranslation } from 'react-i18next'; +import PrivacyPolicy from '../onboarding/PrivacyPolicy'; +import { settingStyles } from './ProfileSettings'; const PrivacyPolicyModal = ({ privacyVis, setPrivacyVis }) => { - const { height: windowHeight } = useWindowDimensions(); - const { t } = useTranslation(); - const { colors } = useTheme(); + const { height: windowHeight } = useWindowDimensions(); + const { t } = useTranslation(); + const { colors } = useTheme(); - return ( - <> - setPrivacyVis(false)} transparent={true}> - setPrivacyVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - - - - - - - - - - - - ) -} + return ( + <> + setPrivacyVis(false)} transparent={true}> + setPrivacyVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + + + + + + + + + + + + ); +}; export default PrivacyPolicyModal; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index e2e6d04fd..b081e642a 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,544 +1,696 @@ -import React, { useState, useEffect, useContext, useRef } from "react"; -import { Modal, StyleSheet, ScrollView } from "react-native"; -import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from "react-native-paper"; -import { getAngularService } from "../angular-react-helper"; -import { useTranslation } from "react-i18next"; -import ExpansionSection from "./ExpandMenu"; -import SettingRow from "./SettingRow"; -import ControlDataTable from "./ControlDataTable"; -import DemographicsSettingRow from "./DemographicsSettingRow"; -import PopOpCode from "./PopOpCode"; -import ReminderTime from "./ReminderTime" -import useAppConfig from "../useAppConfig"; -import AlertBar from "./AlertBar"; -import DataDatePicker from "./DataDatePicker"; -import PrivacyPolicyModal from "./PrivacyPolicyModal"; - -import {uploadFile} from "./uploadService"; -import ActionMenu from "../components/ActionMenu"; -import SensedPage from "./SensedPage" -import LogPage from "./LogPage"; -import ControlSyncHelper, {ForceSyncRow, getHelperSyncSettings} from "./ControlSyncHelper"; -import ControlCollectionHelper, {getHelperCollectionSettings, getState, isMediumAccuracy, helperToggleLowAccuracy, forceTransition} from "./ControlCollectionHelper"; -import { resetDataAndRefresh } from "../config/dynamicConfig"; -import { AppContext } from "../App"; -import { shareQR } from "../components/QrCode"; -import { storageClear } from "../plugin/storage"; -import { getAppVersion } from "../plugin/clientStats"; -import { getConsentDocument } from "../splash/startprefs"; -import { logDebug } from "../plugin/logger"; +import React, { useState, useEffect, useContext, useRef } from 'react'; +import { Modal, StyleSheet, ScrollView } from 'react-native'; +import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from 'react-native-paper'; +import { getAngularService } from '../angular-react-helper'; +import { useTranslation } from 'react-i18next'; +import ExpansionSection from './ExpandMenu'; +import SettingRow from './SettingRow'; +import ControlDataTable from './ControlDataTable'; +import DemographicsSettingRow from './DemographicsSettingRow'; +import PopOpCode from './PopOpCode'; +import ReminderTime from './ReminderTime'; +import useAppConfig from '../useAppConfig'; +import AlertBar from './AlertBar'; +import DataDatePicker from './DataDatePicker'; +import PrivacyPolicyModal from './PrivacyPolicyModal'; + +import { uploadFile } from './uploadService'; +import ActionMenu from '../components/ActionMenu'; +import SensedPage from './SensedPage'; +import LogPage from './LogPage'; +import ControlSyncHelper, { ForceSyncRow, getHelperSyncSettings } from './ControlSyncHelper'; +import ControlCollectionHelper, { + getHelperCollectionSettings, + getState, + isMediumAccuracy, + helperToggleLowAccuracy, + forceTransition, +} from './ControlCollectionHelper'; +import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { AppContext } from '../App'; +import { shareQR } from '../components/QrCode'; +import { storageClear } from '../plugin/storage'; +import { getAppVersion } from '../plugin/clientStats'; +import { getConsentDocument } from '../splash/startprefs'; +import { logDebug } from '../plugin/logger'; //any pure functions can go outside const ProfileSettings = () => { - // anything that mutates must go in --- depend on props or state... - const { t } = useTranslation(); - const appConfig = useAppConfig(); - const { colors } = useTheme(); - const { setPermissionsPopupVis } = useContext(AppContext); - - //angular services needed - const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); - const EmailHelper = getAngularService('EmailHelper'); - const NotificationScheduler = getAngularService('NotificationScheduler'); - const ControlHelper = getAngularService('ControlHelper'); - - //functions that come directly from an Angular service - const editCollectionConfig = () => setEditCollectionVis(true); - const editSyncConfig = () => setEditSync(true); - - //states and variables used to control/create the settings - const [opCodeVis, setOpCodeVis] = useState(false); - const [nukeSetVis, setNukeVis] = useState(false); - const [carbonDataVis, setCarbonDataVis] = useState(false); - const [forceStateVis, setForceStateVis] = useState(false); - const [logoutVis, setLogoutVis] = useState(false); - const [invalidateSuccessVis, setInvalidateSuccessVis] = useState(false); - const [noConsentVis, setNoConsentVis] = useState(false); - const [noConsentMessageVis, setNoConsentMessageVis] = useState(false); - const [consentVis, setConsentVis] = useState(false); - const [dateDumpVis, setDateDumpVis] = useState(false); - const [privacyVis, setPrivacyVis] = useState(false); - const [uploadVis, setUploadVis] = useState(false); - const [showingSensed, setShowingSensed] = useState(false); - const [showingLog, setShowingLog] = useState(false); - const [editSync, setEditSync] = useState(false); - const [editCollectionVis, setEditCollectionVis] = useState(false); - - // const [collectConfig, setCollectConfig] = useState({}); - const [collectSettings, setCollectSettings] = useState({}); - const [notificationSettings, setNotificationSettings] = useState({}); - const [authSettings, setAuthSettings] = useState({}); - const [syncSettings, setSyncSettings] = useState({}); - const [cacheResult, setCacheResult] = useState(""); - const [connectSettings, setConnectSettings] = useState({}); - const [uiConfig, setUiConfig] = useState({}); - const [consentDoc, setConsentDoc] = useState({}); - const [dumpDate, setDumpDate] = useState(new Date()); - const [uploadReason, setUploadReason] = useState(""); - const appVersion = useRef(); - - let carbonDatasetString = t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); - const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); - const stateActions = [{text: "Initialize", transition: "INITIALIZE"}, - {text: 'Start trip', transition: "EXITED_GEOFENCE"}, - {text: 'End trip', transition: "STOPPED_MOVING"}, - {text: 'Visit ended', transition: "VISIT_ENDED"}, - {text: 'Visit started', transition: "VISIT_STARTED"}, - {text: 'Remote push', transition: "RECEIVED_SILENT_PUSH"}] - - useEffect(() => { - //added appConfig.name needed to be defined because appConfig was defined but empty - if (appConfig && (appConfig.name)) { - whenReady(appConfig); - } - }, [appConfig]); - - const refreshScreen = function() { - refreshCollectSettings(); - refreshNotificationSettings(); - getOPCode(); - getSyncSettings(); - getConnectURL(); - getAppVersion().then((version) => { - appVersion.current = version; - }); - } - - //previously not loaded on regular refresh, this ensures it stays caught up - useEffect(() => { - refreshNotificationSettings(); - }, [uiConfig]) - - const whenReady = function(newAppConfig){ - var tempUiConfig = newAppConfig; - - // backwards compat hack to fill in the raw_data_use for programs that don't have it - const default_raw_data_use = { - "en": `to monitor the ${tempUiConfig.intro.program_or_study}, send personalized surveys or provide recommendations to participants`, - "es": `para monitorear el ${tempUiConfig.intro.program_or_study}, enviar encuestas personalizadas o proporcionar recomendaciones a los participantes` - } - Object.entries(tempUiConfig.intro.translated_text).forEach(([lang, val]) => { - val.raw_data_use = val.raw_data_use || default_raw_data_use[lang]; - }); - - // Backwards compat hack to fill in the `app_required` based on the - // old-style "program_or_study" - // remove this at the end of 2023 when all programs have been migrated over - if (tempUiConfig.intro.app_required == undefined) { - tempUiConfig.intro.app_required = tempUiConfig?.intro.program_or_study == 'program'; - } - tempUiConfig.opcode = tempUiConfig.opcode || {}; - if (tempUiConfig.opcode.autogen == undefined) { - tempUiConfig.opcode.autogen = tempUiConfig?.intro.program_or_study == 'study'; - } - - // setTemplateText(tempUiConfig.intro.translated_text); - // console.log("translated text is??", templateText); - setUiConfig(tempUiConfig); - refreshScreen(); - } - - async function refreshCollectSettings() { - console.debug('about to refreshCollectSettings, collectSettings = ', collectSettings); - const newCollectSettings = {}; - - // // refresh collect plugin configuration - const collectionPluginConfig = await getHelperCollectionSettings(); - newCollectSettings.config = collectionPluginConfig; - - const collectionPluginState = await getState(); - newCollectSettings.state = collectionPluginState; - newCollectSettings.trackingOn = collectionPluginState != "local.state.tracking_stopped" - && collectionPluginState != "STATE_TRACKING_STOPPED"; - - const isLowAccuracy = await isMediumAccuracy(); - if (typeof isLowAccuracy != 'undefined') { - newCollectSettings.lowAccuracy = isLowAccuracy; - } - - setCollectSettings(newCollectSettings); + // anything that mutates must go in --- depend on props or state... + const { t } = useTranslation(); + const appConfig = useAppConfig(); + const { colors } = useTheme(); + const { setPermissionsPopupVis } = useContext(AppContext); + + //angular services needed + const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); + const EmailHelper = getAngularService('EmailHelper'); + const NotificationScheduler = getAngularService('NotificationScheduler'); + const ControlHelper = getAngularService('ControlHelper'); + + //functions that come directly from an Angular service + const editCollectionConfig = () => setEditCollectionVis(true); + const editSyncConfig = () => setEditSync(true); + + //states and variables used to control/create the settings + const [opCodeVis, setOpCodeVis] = useState(false); + const [nukeSetVis, setNukeVis] = useState(false); + const [carbonDataVis, setCarbonDataVis] = useState(false); + const [forceStateVis, setForceStateVis] = useState(false); + const [logoutVis, setLogoutVis] = useState(false); + const [invalidateSuccessVis, setInvalidateSuccessVis] = useState(false); + const [noConsentVis, setNoConsentVis] = useState(false); + const [noConsentMessageVis, setNoConsentMessageVis] = useState(false); + const [consentVis, setConsentVis] = useState(false); + const [dateDumpVis, setDateDumpVis] = useState(false); + const [privacyVis, setPrivacyVis] = useState(false); + const [uploadVis, setUploadVis] = useState(false); + const [showingSensed, setShowingSensed] = useState(false); + const [showingLog, setShowingLog] = useState(false); + const [editSync, setEditSync] = useState(false); + const [editCollectionVis, setEditCollectionVis] = useState(false); + + // const [collectConfig, setCollectConfig] = useState({}); + const [collectSettings, setCollectSettings] = useState({}); + const [notificationSettings, setNotificationSettings] = useState({}); + const [authSettings, setAuthSettings] = useState({}); + const [syncSettings, setSyncSettings] = useState({}); + const [cacheResult, setCacheResult] = useState(''); + const [connectSettings, setConnectSettings] = useState({}); + const [uiConfig, setUiConfig] = useState({}); + const [consentDoc, setConsentDoc] = useState({}); + const [dumpDate, setDumpDate] = useState(new Date()); + const [uploadReason, setUploadReason] = useState(''); + const appVersion = useRef(); + + let carbonDatasetString = + t('general-settings.carbon-dataset') + ': ' + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); + const stateActions = [ + { text: 'Initialize', transition: 'INITIALIZE' }, + { text: 'Start trip', transition: 'EXITED_GEOFENCE' }, + { text: 'End trip', transition: 'STOPPED_MOVING' }, + { text: 'Visit ended', transition: 'VISIT_ENDED' }, + { text: 'Visit started', transition: 'VISIT_STARTED' }, + { text: 'Remote push', transition: 'RECEIVED_SILENT_PUSH' }, + ]; + + useEffect(() => { + //added appConfig.name needed to be defined because appConfig was defined but empty + if (appConfig && appConfig.name) { + whenReady(appConfig); } - - //ensure ui table updated when editor closes - useEffect(() => { - if(editCollectionVis == false) { - setTimeout(function() { - console.log("closed editor, time to refresh collect"); - refreshCollectSettings(); - }, 1000); - } - }, [editCollectionVis]) - - async function refreshNotificationSettings() { - console.debug('about to refreshNotificationSettings, notificationSettings = ', notificationSettings); - const newNotificationSettings ={}; - - if (uiConfig?.reminderSchemes) { - const prefs = await NotificationScheduler.getReminderPrefs(); - const m = moment(prefs.reminder_time_of_day, 'HH:mm'); - newNotificationSettings.prefReminderTimeVal = m.toDate(); - const n = moment(newNotificationSettings.prefReminderTimeVal); - newNotificationSettings.prefReminderTime = n.format('LT'); - newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; - newNotificationSettings.scheduledNotifs = await NotificationScheduler.getScheduledNotifs(); - updatePrefReminderTime(false); - } - - console.log("notification settings before and after", notificationSettings, newNotificationSettings); - setNotificationSettings(newNotificationSettings); - } - - async function getSyncSettings() { - console.log("getting sync settings"); - var newSyncSettings = {}; - getHelperSyncSettings().then(function(showConfig) { - newSyncSettings.show_config = showConfig; - setSyncSettings(newSyncSettings); - console.log("sync settings are ", syncSettings); - }); + }, [appConfig]); + + const refreshScreen = function () { + refreshCollectSettings(); + refreshNotificationSettings(); + getOPCode(); + getSyncSettings(); + getConnectURL(); + getAppVersion().then((version) => { + appVersion.current = version; + }); + }; + + //previously not loaded on regular refresh, this ensures it stays caught up + useEffect(() => { + refreshNotificationSettings(); + }, [uiConfig]); + + const whenReady = function (newAppConfig) { + var tempUiConfig = newAppConfig; + + // backwards compat hack to fill in the raw_data_use for programs that don't have it + const default_raw_data_use = { + en: `to monitor the ${tempUiConfig.intro.program_or_study}, send personalized surveys or provide recommendations to participants`, + es: `para monitorear el ${tempUiConfig.intro.program_or_study}, enviar encuestas personalizadas o proporcionar recomendaciones a los participantes`, }; - - //update sync settings in the table when close editor - useEffect(() => { - getSyncSettings(); - }, [editSync]); - - async function getConnectURL() { - ControlHelper.getSettings().then(function(response) { - var newConnectSettings ={} - newConnectSettings.url = response.connectUrl; - console.log(response); - setConnectSettings(newConnectSettings); - }, function(error) { - Logger.displayError("While getting connect url", error); - }); + Object.entries(tempUiConfig.intro.translated_text).forEach(([lang, val]) => { + val.raw_data_use = val.raw_data_use || default_raw_data_use[lang]; + }); + + // Backwards compat hack to fill in the `app_required` based on the + // old-style "program_or_study" + // remove this at the end of 2023 when all programs have been migrated over + if (tempUiConfig.intro.app_required == undefined) { + tempUiConfig.intro.app_required = tempUiConfig?.intro.program_or_study == 'program'; } - - async function getOPCode() { - const newAuthSettings = {}; - const opcode = await ControlHelper.getOPCode(); - if(opcode == null){ - newAuthSettings.opcode = "Not logged in"; - } else { - newAuthSettings.opcode = opcode; - } - setAuthSettings(newAuthSettings); - }; - - //methods that control the settings - const uploadLog = function () { - if(uploadReason != "") { - let reason = uploadReason; - uploadFile("loggerDB", reason); - setUploadVis(false); - } + tempUiConfig.opcode = tempUiConfig.opcode || {}; + if (tempUiConfig.opcode.autogen == undefined) { + tempUiConfig.opcode.autogen = tempUiConfig?.intro.program_or_study == 'study'; } - const emailLog = function () { - // Passing true, we want to send logs - EmailHelper.sendEmail("loggerDB") - }; - - async function updatePrefReminderTime(storeNewVal=true, newTime){ - console.log(newTime); - if(storeNewVal){ - const m = moment(newTime); - // store in HH:mm - NotificationScheduler.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }).then(() => { - refreshNotificationSettings(); - }); - } + // setTemplateText(tempUiConfig.intro.translated_text); + // console.log("translated text is??", templateText); + setUiConfig(tempUiConfig); + refreshScreen(); + }; + + async function refreshCollectSettings() { + console.debug('about to refreshCollectSettings, collectSettings = ', collectSettings); + const newCollectSettings = {}; + + // // refresh collect plugin configuration + const collectionPluginConfig = await getHelperCollectionSettings(); + newCollectSettings.config = collectionPluginConfig; + + const collectionPluginState = await getState(); + newCollectSettings.state = collectionPluginState; + newCollectSettings.trackingOn = + collectionPluginState != 'local.state.tracking_stopped' && + collectionPluginState != 'STATE_TRACKING_STOPPED'; + + const isLowAccuracy = await isMediumAccuracy(); + if (typeof isLowAccuracy != 'undefined') { + newCollectSettings.lowAccuracy = isLowAccuracy; } - function dummyNotification() { - cordova.plugins.notification.local.addActions('dummy-actions', [ - { id: 'action', title: 'Yes' }, - { id: 'cancel', title: 'No' } - ]); - cordova.plugins.notification.local.schedule({ - id: new Date().getTime(), - title: 'Dummy Title', - text: 'Dummy text', - actions: 'dummy-actions', - trigger: {at: new Date(new Date().getTime() + 5000)}, - }); - } + setCollectSettings(newCollectSettings); + } - async function userStartStopTracking() { - const transitionToForce = collectSettings.trackingOn ? 'STOP_TRACKING' : 'START_TRACKING'; - await forceTransition(transitionToForce); + //ensure ui table updated when editor closes + useEffect(() => { + if (editCollectionVis == false) { + setTimeout(function () { + console.log('closed editor, time to refresh collect'); refreshCollectSettings(); + }, 1000); } + }, [editCollectionVis]); - async function toggleLowAccuracy() { - let toggle = await helperToggleLowAccuracy(); - setTimeout(function() { - refreshCollectSettings(); - }, 1500); - } - - const viewQRCode = function(e) { - setOpCodeVis(true); - } - - const clearNotifications = function() { - window.cordova.plugins.notification.local.clearAll(); - } - - //Platform.OS returns "web" now, but could be used once it's fully a Native app - //for now, use window.cordova.platformId - - const parseState = function(state) { - console.log("state in parse state is", state); - if (state) { - console.log("state in parse state exists", window.cordova.platformId); - if(window.cordova.platformId == 'android') { - console.log("ANDROID state in parse state is", state.substring(12)); - return state.substring(12); - } - else if(window.cordova.platformId == 'ios') { - console.log("IOS state in parse state is", state.substring(6)); - return state.substring(6); - } - } - } - - async function invalidateCache() { - window.cordova.plugins.BEMUserCache.invalidateAllCache().then(function(result) { - console.log("invalidate result", result); - setCacheResult(result); - setInvalidateSuccessVis(true); - }, function(error) { - Logger.displayError("while invalidating cache, error->", error); - }); - } - - //in ProfileSettings in DevZone (above two functions are helpers) - async function checkConsent() { - getConsentDocument().then(function(resultDoc){ - setConsentDoc(resultDoc); - logDebug("In profile settings, consent doc found", resultDoc); - if (resultDoc == null) { - setNoConsentVis(true); - } else { - setConsentVis(true); - } - }, function(error) { - Logger.displayError("Error reading consent document from cache", error) - }); + async function refreshNotificationSettings() { + console.debug( + 'about to refreshNotificationSettings, notificationSettings = ', + notificationSettings, + ); + const newNotificationSettings = {}; + + if (uiConfig?.reminderSchemes) { + const prefs = await NotificationScheduler.getReminderPrefs(); + const m = moment(prefs.reminder_time_of_day, 'HH:mm'); + newNotificationSettings.prefReminderTimeVal = m.toDate(); + const n = moment(newNotificationSettings.prefReminderTimeVal); + newNotificationSettings.prefReminderTime = n.format('LT'); + newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; + newNotificationSettings.scheduledNotifs = await NotificationScheduler.getScheduledNotifs(); + updatePrefReminderTime(false); } - const onSelectState = function(stateObject) { - forceTransition(stateObject.transition); + console.log( + 'notification settings before and after', + notificationSettings, + newNotificationSettings, + ); + setNotificationSettings(newNotificationSettings); + } + + async function getSyncSettings() { + console.log('getting sync settings'); + var newSyncSettings = {}; + getHelperSyncSettings().then(function (showConfig) { + newSyncSettings.show_config = showConfig; + setSyncSettings(newSyncSettings); + console.log('sync settings are ', syncSettings); + }); + } + + //update sync settings in the table when close editor + useEffect(() => { + getSyncSettings(); + }, [editSync]); + + async function getConnectURL() { + ControlHelper.getSettings().then( + function (response) { + var newConnectSettings = {}; + newConnectSettings.url = response.connectUrl; + console.log(response); + setConnectSettings(newConnectSettings); + }, + function (error) { + Logger.displayError('While getting connect url', error); + }, + ); + } + + async function getOPCode() { + const newAuthSettings = {}; + const opcode = await ControlHelper.getOPCode(); + if (opcode == null) { + newAuthSettings.opcode = 'Not logged in'; + } else { + newAuthSettings.opcode = opcode; } - - const onSelectCarbon = function(carbonObject) { - console.log("changeCarbonDataset(): chose locale " + carbonObject.value); - CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here - //Unhandled Promise Rejection: While logging, error -[NSNull UTF8String]: unrecognized selector sent to instance 0x7fff8a625fb0 - carbonDatasetString = i18next.t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + setAuthSettings(newAuthSettings); + } + + //methods that control the settings + const uploadLog = function () { + if (uploadReason != '') { + let reason = uploadReason; + uploadFile('loggerDB', reason); + setUploadVis(false); } - - //conditional creation of setting sections - - let logUploadSection; - console.debug("appConfg: support_upload:", appConfig?.profile_controls?.support_upload); - if (appConfig?.profile_controls?.support_upload) { - logUploadSection = setUploadVis(true)}>; + }; + + const emailLog = function () { + // Passing true, we want to send logs + EmailHelper.sendEmail('loggerDB'); + }; + + async function updatePrefReminderTime(storeNewVal = true, newTime) { + console.log(newTime); + if (storeNewVal) { + const m = moment(newTime); + // store in HH:mm + NotificationScheduler.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }).then( + () => { + refreshNotificationSettings(); + }, + ); } - - let timePicker; - let notifSchedule; - if (appConfig?.reminderSchemes) - { - timePicker = ; - notifSchedule = <>console.log("")}> - + } + + function dummyNotification() { + cordova.plugins.notification.local.addActions('dummy-actions', [ + { id: 'action', title: 'Yes' }, + { id: 'cancel', title: 'No' }, + ]); + cordova.plugins.notification.local.schedule({ + id: new Date().getTime(), + title: 'Dummy Title', + text: 'Dummy text', + actions: 'dummy-actions', + trigger: { at: new Date(new Date().getTime() + 5000) }, + }); + } + + async function userStartStopTracking() { + const transitionToForce = collectSettings.trackingOn ? 'STOP_TRACKING' : 'START_TRACKING'; + await forceTransition(transitionToForce); + refreshCollectSettings(); + } + + async function toggleLowAccuracy() { + let toggle = await helperToggleLowAccuracy(); + setTimeout(function () { + refreshCollectSettings(); + }, 1500); + } + + const viewQRCode = function (e) { + setOpCodeVis(true); + }; + + const clearNotifications = function () { + window.cordova.plugins.notification.local.clearAll(); + }; + + //Platform.OS returns "web" now, but could be used once it's fully a Native app + //for now, use window.cordova.platformId + + const parseState = function (state) { + console.log('state in parse state is', state); + if (state) { + console.log('state in parse state exists', window.cordova.platformId); + if (window.cordova.platformId == 'android') { + console.log('ANDROID state in parse state is', state.substring(12)); + return state.substring(12); + } else if (window.cordova.platformId == 'ios') { + console.log('IOS state in parse state is', state.substring(6)); + return state.substring(6); + } } - - return ( - <> - - - {t('control.log-out')} - setLogoutVis(true)}> - - - - - - setPrivacyVis(true)}> - {timePicker} - - setPermissionsPopupVis(true)}> - - setCarbonDataVis(true)}> - setDateDumpVis(true)}> - {logUploadSection} - - - - - - - - {notifSchedule} - - setNukeVis(true)}> - setForceStateVis(true)}> - setShowingLog(true)}> - setShowingSensed(true)}> - - - - - - console.log("")} desc={appVersion.current}> - - - {/* menu for "nuke data" */} - setNukeVis(false)} - transparent={true}> - setNukeVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.clear-data')} - - - - - - - - - - - - {/* menu for "set carbon dataset - only somewhat working" */} - clearNotifications()}> - - {/* force state sheet */} - {}}> - - {/* upload reason input */} - setUploadVis(false)} - transparent={true}> - setUploadVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('upload-service.upload-database', {db: "loggerDB"})} - - setUploadReason(uploadReason)} - placeholder={t('upload-service.please-fill-in-what-is-wrong')}> - - - - - - - - - - {/* opcode viewing popup */} - shareQR(authSettings.opcode)}> - - {/* {view privacy} */} - - - {/* logout menu */} - setLogoutVis(false)} transparent={true}> - setLogoutVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.are-you-sure')} - - {t('general-settings.log-out-warning')} - - - - - - - - - {/* handle no consent */} - setNoConsentVis(false)} transparent={true}> - setNoConsentVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.consent-not-found')} - - {t('general-settings.no-consent-logout')} - - - - - - - - {/* handle consent */} - setConsentVis(false)} transparent={true}> - setConsentVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.consented-to', {approval_date: consentDoc.approval_date})} - - - - - - - - - - - - - - - - - - - + }; + + async function invalidateCache() { + window.cordova.plugins.BEMUserCache.invalidateAllCache().then( + function (result) { + console.log('invalidate result', result); + setCacheResult(result); + setInvalidateSuccessVis(true); + }, + function (error) { + Logger.displayError('while invalidating cache, error->', error); + }, ); + } + + //in ProfileSettings in DevZone (above two functions are helpers) + async function checkConsent() { + getConsentDocument().then( + function (resultDoc) { + setConsentDoc(resultDoc); + logDebug('In profile settings, consent doc found', resultDoc); + if (resultDoc == null) { + setNoConsentVis(true); + } else { + setConsentVis(true); + } + }, + function (error) { + Logger.displayError('Error reading consent document from cache', error); + }, + ); + } + + const onSelectState = function (stateObject) { + forceTransition(stateObject.transition); + }; + + const onSelectCarbon = function (carbonObject) { + console.log('changeCarbonDataset(): chose locale ' + carbonObject.value); + CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here + //Unhandled Promise Rejection: While logging, error -[NSNull UTF8String]: unrecognized selector sent to instance 0x7fff8a625fb0 + carbonDatasetString = + i18next.t('general-settings.carbon-dataset') + + ': ' + + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + }; + + //conditional creation of setting sections + + let logUploadSection; + console.debug('appConfg: support_upload:', appConfig?.profile_controls?.support_upload); + if (appConfig?.profile_controls?.support_upload) { + logUploadSection = ( + setUploadVis(true)}> + ); + } + + let timePicker; + let notifSchedule; + if (appConfig?.reminderSchemes) { + timePicker = ( + + ); + notifSchedule = ( + <> + console.log('')}> + + + ); + } + + return ( + <> + + + {t('control.log-out')} + setLogoutVis(true)}> + + + + + + setPrivacyVis(true)}> + {timePicker} + + setPermissionsPopupVis(true)}> + + setCarbonDataVis(true)}> + setDateDumpVis(true)}> + {logUploadSection} + + + + + + + + {notifSchedule} + + setNukeVis(true)}> + setForceStateVis(true)}> + setShowingLog(true)}> + setShowingSensed(true)}> + + + + + + console.log('')} + desc={appVersion.current}> + + + {/* menu for "nuke data" */} + setNukeVis(false)} transparent={true}> + setNukeVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.clear-data')} + + + + + + + + + + + + {/* menu for "set carbon dataset - only somewhat working" */} + clearNotifications()}> + + {/* force state sheet */} + {}}> + + {/* upload reason input */} + setUploadVis(false)} transparent={true}> + setUploadVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('upload-service.upload-database', { db: 'loggerDB' })} + + setUploadReason(uploadReason)} + placeholder={t('upload-service.please-fill-in-what-is-wrong')}> + + + + + + + + + {/* opcode viewing popup */} + shareQR(authSettings.opcode)}> + + {/* {view privacy} */} + + + {/* logout menu */} + setLogoutVis(false)} transparent={true}> + setLogoutVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.are-you-sure')} + + {t('general-settings.log-out-warning')} + + + + + + + + + {/* handle no consent */} + setNoConsentVis(false)} transparent={true}> + setNoConsentVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.consent-not-found')} + + {t('general-settings.no-consent-logout')} + + + + + + + + {/* handle consent */} + setConsentVis(false)} transparent={true}> + setConsentVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + + {t('general-settings.consented-to', { approval_date: consentDoc.approval_date })} + + + + + + + + + + + + + + + + + + + ); }; export const settingStyles = StyleSheet.create({ - dialog: (surfaceColor) => ({ - backgroundColor: surfaceColor, - margin: 5, - marginLeft: 25, - marginRight: 25 - }), - monoDesc: { - fontSize: 12, - fontFamily: "monospace", - } - }); - - export default ProfileSettings; + dialog: (surfaceColor) => ({ + backgroundColor: surfaceColor, + margin: 5, + marginLeft: 25, + marginRight: 25, + }), + monoDesc: { + fontSize: 12, + fontFamily: 'monospace', + }, +}); + +export default ProfileSettings; diff --git a/www/js/control/ReminderTime.tsx b/www/js/control/ReminderTime.tsx index 40e8485ee..b603758b0 100644 --- a/www/js/control/ReminderTime.tsx +++ b/www/js/control/ReminderTime.tsx @@ -1,69 +1,70 @@ -import React, { useState } from "react"; +import React, { useState } from 'react'; import { Modal, StyleSheet } from 'react-native'; import { List, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; import { TimePickerModal } from 'react-native-paper-dates'; import { styles as rowStyles } from './SettingRow'; const TimeSelect = ({ visible, setVisible, defaultTime, updateFunc }) => { + const onDismiss = React.useCallback(() => { + setVisible(false); + }, [setVisible]); - const onDismiss = React.useCallback(() => { - setVisible(false) - }, [setVisible]) + const onConfirm = React.useCallback( + ({ hours, minutes }) => { + setVisible(false); + const d = new Date(); + d.setHours(hours, minutes); + updateFunc(true, d); + }, + [setVisible, updateFunc], + ); - const onConfirm = React.useCallback( - ({ hours, minutes }) => { - setVisible(false); - const d = new Date(); - d.setHours(hours, minutes); - updateFunc(true, d); - }, - [setVisible, updateFunc] - ); - - return ( - setVisible(false)} - transparent={true}> - - - ) -} + return ( + setVisible(false)} transparent={true}> + + + ); +}; const ReminderTime = ({ rowText, timeVar, defaultTime, updateFunc }) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const [pickTimeVis, setPickTimeVis] = useState(false); + const { t } = useTranslation(); + const { colors } = useTheme(); + const [pickTimeVis, setPickTimeVis] = useState(false); - let rightComponent = ; + let rightComponent = ; - return ( - <> - + setPickTimeVis(true)} right={() => rightComponent} - /> - - + /> - - ); + + + ); }; const styles = StyleSheet.create({ - item: (surfaceColor) => ({ - justifyContent: 'space-between', - alignContent: 'center', - backgroundColor: surfaceColor, - margin: 1, - }), - }); + item: (surfaceColor) => ({ + justifyContent: 'space-between', + alignContent: 'center', + backgroundColor: surfaceColor, + margin: 1, + }), +}); -export default ReminderTime; \ No newline at end of file +export default ReminderTime; diff --git a/www/js/control/SensedPage.tsx b/www/js/control/SensedPage.tsx index b746dfc8d..82fa60581 100644 --- a/www/js/control/SensedPage.tsx +++ b/www/js/control/SensedPage.tsx @@ -1,91 +1,101 @@ -import React, { useState, useEffect } from "react"; -import { View, StyleSheet, SafeAreaView, Modal } from "react-native"; -import { useTheme, Appbar, IconButton, Text } from "react-native-paper"; -import { getAngularService } from "../angular-react-helper"; -import { useTranslation } from "react-i18next"; +import React, { useState, useEffect } from 'react'; +import { View, StyleSheet, SafeAreaView, Modal } from 'react-native'; +import { useTheme, Appbar, IconButton, Text } from 'react-native-paper'; +import { getAngularService } from '../angular-react-helper'; +import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; -import moment from "moment"; +import moment from 'moment'; -const SensedPage = ({pageVis, setPageVis}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); +const SensedPage = ({ pageVis, setPageVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const EmailHelper = getAngularService('EmailHelper'); - /* Let's keep a reference to the database for convenience */ - const [ DB, setDB ]= useState(); - const [ entries, setEntries ] = useState([]); + /* Let's keep a reference to the database for convenience */ + const [DB, setDB] = useState(); + const [entries, setEntries] = useState([]); - const emailCache = function() { - EmailHelper.sendEmail("userCacheDB"); - } + const emailCache = function () { + EmailHelper.sendEmail('userCacheDB'); + }; - async function updateEntries() { - //hardcoded function and keys after eliminating bit-rotted options - setDB(window.cordova.plugins.BEMUserCache); - let userCacheFn = DB.getAllMessages; - let userCacheKey = "statemachine/transition"; - try { - let entryList = await userCacheFn(userCacheKey, true); - let tempEntries = []; - entryList.forEach(entry => { - entry.metadata.write_fmt_time = moment.unix(entry.metadata.write_ts) - .tz(entry.metadata.time_zone) - .format("llll"); - entry.data = JSON.stringify(entry.data, null, 2); - tempEntries.push(entry); - }); - setEntries(tempEntries); - } - catch(error) { - window.Logger.log(window.Logger.LEVEL_ERROR, "Error updating entries"+ error); - } + async function updateEntries() { + //hardcoded function and keys after eliminating bit-rotted options + setDB(window.cordova.plugins.BEMUserCache); + let userCacheFn = DB.getAllMessages; + let userCacheKey = 'statemachine/transition'; + try { + let entryList = await userCacheFn(userCacheKey, true); + let tempEntries = []; + entryList.forEach((entry) => { + entry.metadata.write_fmt_time = moment + .unix(entry.metadata.write_ts) + .tz(entry.metadata.time_zone) + .format('llll'); + entry.data = JSON.stringify(entry.data, null, 2); + tempEntries.push(entry); + }); + setEntries(tempEntries); + } catch (error) { + window.Logger.log(window.Logger.LEVEL_ERROR, 'Error updating entries' + error); } + } + + useEffect(() => { + updateEntries(); + }, [pageVis]); - useEffect(() => { - updateEntries(); - }, [pageVis]); + const separator = () => ; + const cacheItem = ({ item: cacheItem }) => ( + + + {cacheItem.metadata.write_fmt_time} + + + {cacheItem.data} + + + ); - const separator = () => - const cacheItem = ({item: cacheItem}) => ( - {cacheItem.metadata.write_fmt_time} - {cacheItem.data} - ); + return ( + setPageVis(false)}> + + + setPageVis(false)} /> + + - return ( - setPageVis(false)}> - - - setPageVis(false)}/> - - + + updateEntries()} /> + emailCache()} /> + - - updateEntries()}/> - emailCache()}/> - - - item.metadata.write_ts} - ItemSeparatorComponent={separator} - /> - - - ); + item.metadata.write_ts} + ItemSeparatorComponent={separator} + /> + + + ); }; const styles = StyleSheet.create({ - date: (surfaceColor) => ({ - backgroundColor: surfaceColor, - }), - details: { - fontFamily: "monospace", - }, - entry: (surfaceColor) => ({ - backgroundColor: surfaceColor, - marginLeft: 5, - }), - }); + date: (surfaceColor) => ({ + backgroundColor: surfaceColor, + }), + details: { + fontFamily: 'monospace', + }, + entry: (surfaceColor) => ({ + backgroundColor: surfaceColor, + marginLeft: 5, + }), +}); export default SensedPage; diff --git a/www/js/control/SettingRow.jsx b/www/js/control/SettingRow.jsx index 473a45d7f..b55b3c804 100644 --- a/www/js/control/SettingRow.jsx +++ b/www/js/control/SettingRow.jsx @@ -1,52 +1,59 @@ -import React from "react"; +import React from 'react'; import { StyleSheet } from 'react-native'; import { List, Switch, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; -const SettingRow = ({textKey, iconName=undefined, action, desc=undefined, switchValue=undefined, descStyle=undefined}) => { - const { t } = useTranslation(); //this accesses the translations - const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors +const SettingRow = ({ + textKey, + iconName = undefined, + action, + desc = undefined, + switchValue = undefined, + descStyle = undefined, +}) => { + const { t } = useTranslation(); //this accesses the translations + const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors - let rightComponent; - if (iconName) { - rightComponent = ; - } else { - rightComponent = ; - } - let descriptionText; - if(desc) { - descriptionText = {desc}; - } else { - descriptionText = ""; - } + let rightComponent; + if (iconName) { + rightComponent = ; + } else { + rightComponent = ; + } + let descriptionText; + if (desc) { + descriptionText = { desc }; + } else { + descriptionText = ''; + } - return ( - action(e)} - right={() => rightComponent} - /> - ); + return ( + action(e)} + right={() => rightComponent} + /> + ); }; export const styles = StyleSheet.create({ - item: (surfaceColor) => ({ - justifyContent: 'space-between', - alignContent: 'center', - backgroundColor: surfaceColor, - margin: 1, - }), - title: { - fontSize: 14, - marginVertical: 2, - }, - description: { - fontSize: 12, - }, - }); + item: (surfaceColor) => ({ + justifyContent: 'space-between', + alignContent: 'center', + backgroundColor: surfaceColor, + margin: 1, + }), + title: { + fontSize: 14, + marginVertical: 2, + }, + description: { + fontSize: 12, + }, +}); export default SettingRow; diff --git a/www/js/control/emailService.js b/www/js/control/emailService.js index 0374adf5a..8eeaf39bb 100644 --- a/www/js/control/emailService.js +++ b/www/js/control/emailService.js @@ -2,96 +2,113 @@ import angular from 'angular'; -angular.module('emission.services.email', ['emission.plugin.logger']) +angular + .module('emission.services.email', ['emission.plugin.logger']) - .service('EmailHelper', function ($window, $http, Logger) { + .service('EmailHelper', function ($window, $http, Logger) { + const getEmailConfig = function () { + return new Promise(function (resolve, reject) { + window.Logger.log(window.Logger.LEVEL_INFO, 'About to get email config'); + var address = []; + $http + .get('json/emailConfig.json') + .then(function (emailConfig) { + window.Logger.log( + window.Logger.LEVEL_DEBUG, + 'emailConfigString = ' + JSON.stringify(emailConfig.data), + ); + address.push(emailConfig.data.address); + resolve(address); + }) + .catch(function (err) { + $http + .get('json/emailConfig.json.sample') + .then(function (emailConfig) { + window.Logger.log( + window.Logger.LEVEL_DEBUG, + 'default emailConfigString = ' + JSON.stringify(emailConfig.data), + ); + address.push(emailConfig.data.address); + resolve(address); + }) + .catch(function (err) { + window.Logger.log( + window.Logger.LEVEL_ERROR, + 'Error while reading default email config' + err, + ); + reject(err); + }); + }); + }); + }; - const getEmailConfig = function () { - return new Promise(function (resolve, reject) { - window.Logger.log(window.Logger.LEVEL_INFO, "About to get email config"); - var address = []; - $http.get("json/emailConfig.json").then(function (emailConfig) { - window.Logger.log(window.Logger.LEVEL_DEBUG, "emailConfigString = " + JSON.stringify(emailConfig.data)); - address.push(emailConfig.data.address) - resolve(address); - }).catch(function (err) { - $http.get("json/emailConfig.json.sample").then(function (emailConfig) { - window.Logger.log(window.Logger.LEVEL_DEBUG, "default emailConfigString = " + JSON.stringify(emailConfig.data)); - address.push(emailConfig.data.address) - resolve(address); - }).catch(function (err) { - window.Logger.log(window.Logger.LEVEL_ERROR, "Error while reading default email config" + err); - reject(err); - }); - }); - }); - } - - const hasAccount = function() { - return new Promise(function(resolve, reject) { - $window.cordova.plugins.email.hasAccount(function (hasAct) { - resolve(hasAct); - }); - }); - } + const hasAccount = function () { + return new Promise(function (resolve, reject) { + $window.cordova.plugins.email.hasAccount(function (hasAct) { + resolve(hasAct); + }); + }); + }; - this.sendEmail = function (database) { - Promise.all([getEmailConfig(), hasAccount()]).then(function([address, hasAct]) { - var parentDir = "unknown"; + this.sendEmail = function (database) { + Promise.all([getEmailConfig(), hasAccount()]).then(function ([address, hasAct]) { + var parentDir = 'unknown'; - // Check this only for ios, since for android, the check always fails unless - // the user grants the "GET_ACCOUNTS" dynamic permission - // without the permission, we only see the e-mission account which is not valid - // - // https://developer.android.com/reference/android/accounts/AccountManager#getAccounts() - // - // Caller targeting API level below Build.VERSION_CODES.O that - // have not been granted the Manifest.permission.GET_ACCOUNTS - // permission, will only see those accounts managed by - // AbstractAccountAuthenticators whose signature matches the - // client. - // and on android, if the account is not configured, the gmail app will be launched anyway - // on iOS, nothing will happen. So we perform the check only on iOS so that we can - // generate a reasonably relevant error message + // Check this only for ios, since for android, the check always fails unless + // the user grants the "GET_ACCOUNTS" dynamic permission + // without the permission, we only see the e-mission account which is not valid + // + // https://developer.android.com/reference/android/accounts/AccountManager#getAccounts() + // + // Caller targeting API level below Build.VERSION_CODES.O that + // have not been granted the Manifest.permission.GET_ACCOUNTS + // permission, will only see those accounts managed by + // AbstractAccountAuthenticators whose signature matches the + // client. + // and on android, if the account is not configured, the gmail app will be launched anyway + // on iOS, nothing will happen. So we perform the check only on iOS so that we can + // generate a reasonably relevant error message - if (ionic.Platform.isIOS() && !hasAct) { - alert(i18next.t('email-service.email-account-not-configured')); - return; - } + if (ionic.Platform.isIOS() && !hasAct) { + alert(i18next.t('email-service.email-account-not-configured')); + return; + } - if (ionic.Platform.isAndroid()) { - parentDir = "app://databases"; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - parentDir = cordova.file.dataDirectory + "../LocalDatabase"; - } + if (ionic.Platform.isAndroid()) { + parentDir = 'app://databases'; + } + if (ionic.Platform.isIOS()) { + alert(i18next.t('email-service.email-account-mail-app')); + parentDir = cordova.file.dataDirectory + '../LocalDatabase'; + } - if (parentDir == "unknown") { - alert("parentDir unexpectedly = " + parentDir + "!") - } + if (parentDir == 'unknown') { + alert('parentDir unexpectedly = ' + parentDir + '!'); + } - window.Logger.log(window.Logger.LEVEL_INFO, "Going to email " + database); - parentDir = parentDir + "/" + database; - /* + window.Logger.log(window.Logger.LEVEL_INFO, 'Going to email ' + database); + parentDir = parentDir + '/' + database; + /* window.Logger.log(window.Logger.LEVEL_INFO, "Going to export logs to "+parentDir); */ - alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); - var email = { - to: address, - attachments: [ - parentDir - ], - subject: i18next.t('email-service.email-log.subject-logs'), - body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong') - } - - $window.cordova.plugins.email.open(email, function () { - Logger.log("email app closed while sending, "+JSON.stringify(email)+" not sure if we should do anything"); - // alert(i18next.t('email-service.no-email-address-configured') + err); - return; - }); - }); + alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); + var email = { + to: address, + attachments: [parentDir], + subject: i18next.t('email-service.email-log.subject-logs'), + body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong'), }; -}); + + $window.cordova.plugins.email.open(email, function () { + Logger.log( + 'email app closed while sending, ' + + JSON.stringify(email) + + ' not sure if we should do anything', + ); + // alert(i18next.t('email-service.no-email-address-configured') + err); + return; + }); + }); + }; + }); diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index 038bd8efc..2b7520edb 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,129 +1,135 @@ -import { logDebug, logInfo, displayError } from "../plugin/logger"; -import i18next from "i18next"; +import { logDebug, logInfo, displayError } from '../plugin/logger'; +import i18next from 'i18next'; /** * @returns A promise that resolves with an upload URL or rejects with an error */ async function getUploadConfig() { - return new Promise(async function (resolve, reject) { - logInfo( "About to get email config"); - let url = []; - try { - let response = await fetch("json/uploadConfig.json"); - let uploadConfig = await response.json(); - logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['url'])); - url.push(uploadConfig["url"]); - resolve(url); - } catch (err) { - try{ - let response = await fetch("json/uploadConfig.json.sample"); - let uploadConfig = await response.json(); - logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['url'])); - console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['url'])) - url.push(uploadConfig["url"]); - resolve(url); - } catch (err) { - displayError(err, "Error while reading default upload config"); - reject(err); - } - } - }) + return new Promise(async function (resolve, reject) { + logInfo('About to get email config'); + let url = []; + try { + let response = await fetch('json/uploadConfig.json'); + let uploadConfig = await response.json(); + logDebug('uploadConfigString = ' + JSON.stringify(uploadConfig['url'])); + url.push(uploadConfig['url']); + resolve(url); + } catch (err) { + try { + let response = await fetch('json/uploadConfig.json.sample'); + let uploadConfig = await response.json(); + logDebug('default uploadConfigString = ' + JSON.stringify(uploadConfig['url'])); + console.log('default uploadConfigString = ' + JSON.stringify(uploadConfig['url'])); + url.push(uploadConfig['url']); + resolve(url); + } catch (err) { + displayError(err, 'Error while reading default upload config'); + reject(err); + } + } + }); } function onReadError(err) { - displayError(err, "Error while reading log"); + displayError(err, 'Error while reading log'); } function onUploadError(err) { - displayError(err, "Error while uploading log"); + displayError(err, 'Error while uploading log'); } function readDBFile(parentDir, database, callbackFn) { - return new Promise(function(resolve, reject) { - window['resolveLocalFileSystemURL'](parentDir, function(fs) { - console.log("resolving file system as ", fs); - fs.filesystem.root.getFile(fs.fullPath+database, null, (fileEntry) => { - console.log(fileEntry); - fileEntry.file(function(file) { - console.log(file); - var reader = new FileReader(); + return new Promise(function (resolve, reject) { + window['resolveLocalFileSystemURL'](parentDir, function (fs) { + console.log('resolving file system as ', fs); + fs.filesystem.root.getFile( + fs.fullPath + database, + null, + (fileEntry) => { + console.log(fileEntry); + fileEntry.file(function (file) { + console.log(file); + var reader = new FileReader(); - reader.onprogress = function(report) { - console.log("Current progress is "+JSON.stringify(report)); - if (callbackFn != undefined) { - callbackFn(report.loaded * 100 / report.total); - } - } + reader.onprogress = function (report) { + console.log('Current progress is ' + JSON.stringify(report)); + if (callbackFn != undefined) { + callbackFn((report.loaded * 100) / report.total); + } + }; - reader.onerror = function(error) { - console.log(this.error); - reject({"error": {"message": this.error}}); - } + reader.onerror = function (error) { + console.log(this.error); + reject({ error: { message: this.error } }); + }; - reader.onload = function() { - console.log("Successful file read with " + this.result['byteLength'] +" characters"); - resolve(new DataView(this.result as ArrayBuffer)); - } + reader.onload = function () { + console.log('Successful file read with ' + this.result['byteLength'] + ' characters'); + resolve(new DataView(this.result as ArrayBuffer)); + }; - reader.readAsArrayBuffer(file); - }, reject); - }, reject); - }); + reader.readAsArrayBuffer(file); + }, reject); + }, + reject, + ); }); + }); } const sendToServer = function upload(url, binArray, params) { - //use url encoding to pass additional params in the post - const urlParams = "?reason=" + params.reason + "&tz=" + params.tz; - return fetch(url+urlParams, { - method: 'POST', - headers: {'Content-Type': undefined }, - body: binArray - } ) -} + //use url encoding to pass additional params in the post + const urlParams = '?reason=' + params.reason + '&tz=' + params.tz; + return fetch(url + urlParams, { + method: 'POST', + headers: { 'Content-Type': undefined }, + body: binArray, + }); +}; //only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") export async function uploadFile(database, reason) { - try { - let uploadConfig = await getUploadConfig(); - var parentDir = "unknown"; + try { + let uploadConfig = await getUploadConfig(); + var parentDir = 'unknown'; - if (window['cordova'].platformId.toLowerCase() == "android") { - parentDir = window['cordova'].file.applicationStorageDirectory+"/databases"; - } - else if (window['cordova'].platformId.toLowerCase() == "ios") { - parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; - } else { - alert("parentDir unexpectedly = " + parentDir + "!") - } + if (window['cordova'].platformId.toLowerCase() == 'android') { + parentDir = window['cordova'].file.applicationStorageDirectory + '/databases'; + } else if (window['cordova'].platformId.toLowerCase() == 'ios') { + parentDir = window['cordova'].file.dataDirectory + '../LocalDatabase'; + } else { + alert('parentDir unexpectedly = ' + parentDir + '!'); + } - logInfo("Going to upload " + database); - try { - let binString = await readDBFile(parentDir, database, undefined); - console.log("Uploading file of size "+binString['byteLength']); - const params = { - reason: reason, - tz: Intl.DateTimeFormat().resolvedOptions().timeZone - } - uploadConfig.forEach(async (url) => { - //have alert for starting upload, but not progress - window.alert(i18next.t("upload-service.upload-database", {db: database})); + logInfo('Going to upload ' + database); + try { + let binString = await readDBFile(parentDir, database, undefined); + console.log('Uploading file of size ' + binString['byteLength']); + const params = { + reason: reason, + tz: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; + uploadConfig.forEach(async (url) => { + //have alert for starting upload, but not progress + window.alert(i18next.t('upload-service.upload-database', { db: database })); - try { - let response = await sendToServer(url, binString, params); - window.alert(i18next.t("upload-service.upload-details", - {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) - + i18next.t("upload-service.upload-success")); - return response; - } catch (error) { - onUploadError(error); - } - }); - } - catch (error){ - onReadError(error); + try { + let response = await sendToServer(url, binString, params); + window.alert( + i18next.t('upload-service.upload-details', { + filesizemb: binString['byteLength'] / (1000 * 1000), + serverURL: url, + }) + i18next.t('upload-service.upload-success'), + ); + return response; + } catch (error) { + onUploadError(error); } + }); } catch (error) { - onReadError(error); + onReadError(error); } -}; \ No newline at end of file + } catch (error) { + onReadError(error); + } +} diff --git a/www/js/controllers.js b/www/js/controllers.js index 5a4de0cb4..abf5916c5 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -4,85 +4,112 @@ import angular from 'angular'; import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; import { getPendingOnboardingState } from './onboarding/onboardingHelper'; -angular.module('emission.controllers', ['emission.splash.pushnotify', - 'emission.splash.storedevicesettings', - 'emission.splash.localnotify', - 'emission.splash.remotenotify']) +angular + .module('emission.controllers', [ + 'emission.splash.pushnotify', + 'emission.splash.storedevicesettings', + 'emission.splash.localnotify', + 'emission.splash.remotenotify', + ]) -.controller('RootCtrl', function($scope) {}) + .controller('RootCtrl', function ($scope) {}) -.controller('DashCtrl', function($scope) {}) + .controller('DashCtrl', function ($scope) {}) -.controller('SplashCtrl', function($scope, $state, $interval, $rootScope, - PushNotify, StoreDeviceSettings, - LocalNotify, RemoteNotify) { - console.log('SplashCtrl invoked'); - // alert("attach debugger!"); - // PushNotify.startupInit(); + .controller( + 'SplashCtrl', + function ( + $scope, + $state, + $interval, + $rootScope, + PushNotify, + StoreDeviceSettings, + LocalNotify, + RemoteNotify, + ) { + console.log('SplashCtrl invoked'); + // alert("attach debugger!"); + // PushNotify.startupInit(); - $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams, fromState, fromParams){ - console.log("Finished changing state from "+JSON.stringify(fromState) - + " to "+JSON.stringify(toState)); - addStatReading(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name); - }); - $rootScope.$on('$stateChangeError', function(event, toState, toParams, fromState, fromParams, error){ - console.log("Error "+error+" while changing state from "+JSON.stringify(fromState) - +" to "+JSON.stringify(toState)); - addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name+ "_" + error); - }); - $rootScope.$on('$stateNotFound', - function(event, unfoundState, fromState, fromParams){ - console.log("unfoundState.to = "+unfoundState.to); // "lazy.state" - console.log("unfoundState.toParams = " + unfoundState.toParams); // {a:1, b:2} - console.log("unfoundState.options = " + unfoundState.options); // {inherit:false} + default options - addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + unfoundState.name); - }); - - var isInList = function(element, list) { - return list.indexOf(element) != -1 - } + $rootScope.$on( + '$stateChangeSuccess', + function (event, toState, toParams, fromState, fromParams) { + console.log( + 'Finished changing state from ' + + JSON.stringify(fromState) + + ' to ' + + JSON.stringify(toState), + ); + addStatReading(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name); + }, + ); + $rootScope.$on( + '$stateChangeError', + function (event, toState, toParams, fromState, fromParams, error) { + console.log( + 'Error ' + + error + + ' while changing state from ' + + JSON.stringify(fromState) + + ' to ' + + JSON.stringify(toState), + ); + addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name + '_' + error); + }, + ); + $rootScope.$on('$stateNotFound', function (event, unfoundState, fromState, fromParams) { + console.log('unfoundState.to = ' + unfoundState.to); // "lazy.state" + console.log('unfoundState.toParams = ' + unfoundState.toParams); // {a:1, b:2} + console.log('unfoundState.options = ' + unfoundState.options); // {inherit:false} + default options + addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + unfoundState.name); + }); - $rootScope.$on('$stateChangeStart', - function(event, toState, toParams, fromState, fromParams, options){ - var personalTabs = ['root.main.common.map', - 'root.main.control', - 'root.main.metrics'] - if (isInList(toState.name, personalTabs)) { - // toState is in the personalTabs list - getPendingOnboardingState().then(function(result) { - if (result != null) { - event.preventDefault(); - $state.go(result); - }; - // else, will do default behavior, which is to go to the tab - }); - } - }) - console.log('SplashCtrl invoke finished'); -}) + var isInList = function (element, list) { + return list.indexOf(element) != -1; + }; + $rootScope.$on( + '$stateChangeStart', + function (event, toState, toParams, fromState, fromParams, options) { + var personalTabs = ['root.main.common.map', 'root.main.control', 'root.main.metrics']; + if (isInList(toState.name, personalTabs)) { + // toState is in the personalTabs list + getPendingOnboardingState().then(function (result) { + if (result != null) { + event.preventDefault(); + $state.go(result); + } + // else, will do default behavior, which is to go to the tab + }); + } + }, + ); + console.log('SplashCtrl invoke finished'); + }, + ) -.controller('ChatsCtrl', function($scope, Chats) { - // With the new view caching in Ionic, Controllers are only called - // when they are recreated or on app start, instead of every page change. - // To listen for when this page is active (for example, to refresh data), - // listen for the $ionicView.enter event: - // - //$scope.$on('$ionicView.enter', function(e) { - //}); + .controller('ChatsCtrl', function ($scope, Chats) { + // With the new view caching in Ionic, Controllers are only called + // when they are recreated or on app start, instead of every page change. + // To listen for when this page is active (for example, to refresh data), + // listen for the $ionicView.enter event: + // + //$scope.$on('$ionicView.enter', function(e) { + //}); - $scope.chats = Chats.all(); - $scope.remove = function(chat) { - Chats.remove(chat); - }; -}) + $scope.chats = Chats.all(); + $scope.remove = function (chat) { + Chats.remove(chat); + }; + }) -.controller('ChatDetailCtrl', function($scope, $stateParams, Chats) { - $scope.chat = Chats.get($stateParams.chatId); -}) + .controller('ChatDetailCtrl', function ($scope, $stateParams, Chats) { + $scope.chat = Chats.get($stateParams.chatId); + }) -.controller('AccountCtrl', function($scope) { - $scope.settings = { - enableFriends: true - }; -}); + .controller('AccountCtrl', function ($scope) { + $scope.settings = { + enableFriends: true, + }; + }); diff --git a/www/js/diary.js b/www/js/diary.js index c0b7bce35..7c8294005 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -1,20 +1,22 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; -angular.module('emission.main.diary',['emission.main.diary.services', - 'emission.survey.multilabel.buttons', - 'emission.survey.enketo.add-note-button', - 'emission.survey.enketo.trip.button', - 'emission.plugin.logger']) +angular + .module('emission.main.diary', [ + 'emission.main.diary.services', + 'emission.survey.multilabel.buttons', + 'emission.survey.enketo.add-note-button', + 'emission.survey.enketo.trip.button', + 'emission.plugin.logger', + ]) -.config(function($stateProvider) { - $stateProvider - .state('root.main.inf_scroll', { - url: "/inf_scroll", + .config(function ($stateProvider) { + $stateProvider.state('root.main.inf_scroll', { + url: '/inf_scroll', views: { 'main-inf-scroll': { - template: "", + template: '', }, - } - }) -}); + }, + }); + }); diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f4677766d..8b6e65d52 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -6,23 +6,28 @@ share the data that has been loaded and interacted with. */ -import React, { useEffect, useState, useRef } from "react"; -import { getAngularService } from "../angular-react-helper"; -import useAppConfig from "../useAppConfig"; -import { useTranslation } from "react-i18next"; -import { invalidateMaps } from "../components/LeafletView"; -import moment from "moment"; -import LabelListScreen from "./list/LabelListScreen"; -import { createStackNavigator } from "@react-navigation/stack"; -import LabelScreenDetails from "./details/LabelDetailsScreen"; -import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; -import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { SurveyOptions } from "../survey/survey"; -import { getLabelOptions } from "../survey/multilabel/confirmHelper"; -import { displayError } from "../plugin/logger"; -import { useTheme } from "react-native-paper"; -import { getPipelineRangeTs } from "../commHelper"; +import React, { useEffect, useState, useRef } from 'react'; +import { getAngularService } from '../angular-react-helper'; +import useAppConfig from '../useAppConfig'; +import { useTranslation } from 'react-i18next'; +import { invalidateMaps } from '../components/LeafletView'; +import moment from 'moment'; +import LabelListScreen from './list/LabelListScreen'; +import { createStackNavigator } from '@react-navigation/stack'; +import LabelScreenDetails from './details/LabelDetailsScreen'; +import { NavigationContainer } from '@react-navigation/native'; +import { + compositeTrips2TimelineMap, + getAllUnprocessedInputs, + getLocalUnprocessedInputs, + populateCompositeTrips, +} from './timelineHelper'; +import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; +import { SurveyOptions } from '../survey/survey'; +import { getLabelOptions } from '../survey/multilabel/confirmHelper'; +import { displayError } from '../plugin/logger'; +import { useTheme } from 'react-native-paper'; +import { getPipelineRangeTs } from '../commHelper'; let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds @@ -42,7 +47,7 @@ const LabelTab = () => { const [timelineMap, setTimelineMap] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); - const [isLoading, setIsLoading] = useState('replace'); + const [isLoading, setIsLoading] = useState('replace'); const $rootScope = getAngularService('$rootScope'); const $state = getAngularService('$state'); @@ -70,7 +75,8 @@ const LabelTab = () => { // initalize filters const tripFilter = surveyOpt.filter; const allFalseFilters = tripFilter.map((f, i) => ({ - ...f, state: (i == 0 ? true : false) // only the first filter will have state true on init + ...f, + state: i == 0 ? true : false, // only the first filter will have state true on init })); setFilterInputs(allFalseFilters); } @@ -86,7 +92,7 @@ const LabelTab = () => { let entriesToDisplay = allEntries; if (activeFilter) { const entriesAfterFilter = allEntries.filter( - t => t.justRepopulated || activeFilter?.filter(t) + (t) => t.justRepopulated || activeFilter?.filter(t), ); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ @@ -106,12 +112,20 @@ const LabelTab = () => { async function loadTimelineEntries() { try { const pipelineRange = await getPipelineRangeTs(); - [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); - Logger.log("After reading unprocessedInputs, labelsResultMap =" + JSON.stringify(labelsResultMap) - + "; notesResultMap = " + JSON.stringify(notesResultMap)); + [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs( + pipelineRange, + labelPopulateFactory, + enbs, + ); + Logger.log( + 'After reading unprocessedInputs, labelsResultMap =' + + JSON.stringify(labelsResultMap) + + '; notesResultMap = ' + + JSON.stringify(notesResultMap), + ); setPipelineRange(pipelineRange); } catch (error) { - Logger.displayError("Error while loading pipeline range", error); + Logger.displayError('Error while loading pipeline range', error); setIsLoading(false); } } @@ -131,34 +145,39 @@ const LabelTab = () => { setRefreshTime(new Date()); } - async function loadAnotherWeek(when: 'past'|'future') { + async function loadAnotherWeek(when: 'past' | 'future') { try { - const reachedPipelineStart = queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts; - const reachedPipelineEnd = queriedRange?.end_ts && queriedRange.end_ts >= pipelineRange.end_ts; + const reachedPipelineStart = + queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts; + const reachedPipelineEnd = + queriedRange?.end_ts && queriedRange.end_ts >= pipelineRange.end_ts; if (!queriedRange) { // first time loading - if(!isLoading) setIsLoading('replace'); + if (!isLoading) setIsLoading('replace'); const nowTs = new Date().getTime() / 1000; const [ctList, utList] = await fetchTripsInRange(pipelineRange.end_ts - ONE_WEEK, nowTs); handleFetchedTrips(ctList, utList, 'replace'); - setQueriedRange({start_ts: pipelineRange.end_ts - ONE_WEEK, end_ts: nowTs}); + setQueriedRange({ start_ts: pipelineRange.end_ts - ONE_WEEK, end_ts: nowTs }); } else if (when == 'past' && !reachedPipelineStart) { - if(!isLoading) setIsLoading('prepend'); + if (!isLoading) setIsLoading('prepend'); const fetchStartTs = Math.max(queriedRange.start_ts - ONE_WEEK, pipelineRange.start_ts); - const [ctList, utList] = await fetchTripsInRange(queriedRange.start_ts - ONE_WEEK, queriedRange.start_ts - 1); + const [ctList, utList] = await fetchTripsInRange( + queriedRange.start_ts - ONE_WEEK, + queriedRange.start_ts - 1, + ); handleFetchedTrips(ctList, utList, 'prepend'); - setQueriedRange({start_ts: fetchStartTs, end_ts: queriedRange.end_ts}) + setQueriedRange({ start_ts: fetchStartTs, end_ts: queriedRange.end_ts }); } else if (when == 'future' && !reachedPipelineEnd) { - if(!isLoading) setIsLoading('append'); + if (!isLoading) setIsLoading('append'); const fetchEndTs = Math.min(queriedRange.end_ts + ONE_WEEK, pipelineRange.end_ts); const [ctList, utList] = await fetchTripsInRange(queriedRange.end_ts + 1, fetchEndTs); handleFetchedTrips(ctList, utList, 'append'); - setQueriedRange({start_ts: queriedRange.start_ts, end_ts: fetchEndTs}) + setQueriedRange({ start_ts: queriedRange.start_ts, end_ts: fetchEndTs }); } } catch (e) { setIsLoading(false); - displayError(e, t('errors.while-loading-another-week', {when: when})); + displayError(e, t('errors.while-loading-another-week', { when: when })); } } @@ -170,20 +189,30 @@ const LabelTab = () => { const threeDaysAfter = moment(day).add(3, 'days').unix(); const [ctList, utList] = await fetchTripsInRange(threeDaysBefore, threeDaysAfter); handleFetchedTrips(ctList, utList, 'replace'); - setQueriedRange({start_ts: threeDaysBefore, end_ts: threeDaysAfter}); + setQueriedRange({ start_ts: threeDaysBefore, end_ts: threeDaysAfter }); } catch (e) { setIsLoading(false); - displayError(e, t('errors.while-loading-specific-week', {day: day})); + displayError(e, t('errors.while-loading-specific-week', { day: day })); } } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { const tripsRead = ctList.concat(utList); - populateCompositeTrips(tripsRead, showPlaces, labelPopulateFactory, labelsResultMap, enbs, notesResultMap); + populateCompositeTrips( + tripsRead, + showPlaces, + labelPopulateFactory, + labelsResultMap, + enbs, + notesResultMap, + ); // Fill place names on a reversed copy of the list so we fill from the bottom up - tripsRead.slice().reverse().forEach(function (trip, index) { - fillLocationNamesOfTrip(trip); - }); + tripsRead + .slice() + .reverse() + .forEach(function (trip, index) { + fillLocationNamesOfTrip(trip); + }); const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); if (mode == 'append') { setTimelineMap(new Map([...timelineMap, ...readTimelineMap])); @@ -192,13 +221,13 @@ const LabelTab = () => { } else if (mode == 'replace') { setTimelineMap(readTimelineMap); } else { - return console.error("Unknown insertion mode " + mode); + return console.error('Unknown insertion mode ' + mode); } } async function fetchTripsInRange(startTs: number, endTs: number) { if (!pipelineRange.start_ts) { - console.warn("trying to read data too early, early return"); + console.warn('trying to read data too early, early return'); return; } @@ -206,16 +235,22 @@ const LabelTab = () => { let readUnprocessedPromise; if (endTs >= pipelineRange.end_ts) { const nowTs = new Date().getTime() / 1000; - const lastProcessedTrip = timelineMap && [...timelineMap?.values()].reverse().find( - trip => trip.origin_key.includes('confirmed_trip') + const lastProcessedTrip = + timelineMap && + [...timelineMap?.values()] + .reverse() + .find((trip) => trip.origin_key.includes('confirmed_trip')); + readUnprocessedPromise = Timeline.readUnprocessedTrips( + pipelineRange.end_ts, + nowTs, + lastProcessedTrip, ); - readUnprocessedPromise = Timeline.readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); } else { readUnprocessedPromise = Promise.resolve([]); } const results = await Promise.all([readCompositePromise, readUnprocessedPromise]); return results; - }; + } useEffect(() => { if (!displayedEntries) return; @@ -225,10 +260,15 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { - if (!timelineMap.has(oid)) return console.error("Item with oid: " + oid + " not found in timeline"); - const [newLabels, newNotes] = await getLocalUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); + if (!timelineMap.has(oid)) + return console.error('Item with oid: ' + oid + ' not found in timeline'); + const [newLabels, newNotes] = await getLocalUnprocessedInputs( + pipelineRange, + labelPopulateFactory, + enbs, + ); const repopTime = new Date().getTime(); - const newEntry = {...timelineMap.get(oid), justRepopulated: repopTime}; + const newEntry = { ...timelineMap.get(oid), justRepopulated: repopTime }; labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); enbs.populateInputsAndInferences(newEntry, newNotes); const newTimelineMap = new Map(timelineMap).set(oid, newEntry); @@ -239,10 +279,13 @@ const LabelTab = () => { https://legacy.reactjs.org/docs/hooks-faq.html#why-am-i-seeing-stale-props-or-state-inside-my-function */ timelineMapRef.current = newTimelineMap; setTimeout(() => { - const entry = {...timelineMapRef.current.get(oid)}; + const entry = { ...timelineMapRef.current.get(oid) }; if (entry.justRepopulated != repopTime) - return console.log("Entry " + oid + " was repopulated again, skipping"); - const newTimelineMap = new Map(timelineMapRef.current).set(oid, {...entry, justRepopulated: false}); + return console.log('Entry ' + oid + ' was repopulated again, skipping'); + const newTimelineMap = new Map(timelineMapRef.current).set(oid, { + ...entry, + justRepopulated: false, + }); setTimelineMap(newTimelineMap); }, 30000); } @@ -261,24 +304,27 @@ const LabelTab = () => { loadSpecificWeek, refresh, repopulateTimelineEntry, - } + }; const Tab = createStackNavigator(); return ( - + - + options={{ detachPreviousScreen: false }} + /> ); -} +}; export default LabelTab; diff --git a/www/js/diary/addressNamesHelper.ts b/www/js/diary/addressNamesHelper.ts index e7f198fbe..f0e17921a 100644 --- a/www/js/diary/addressNamesHelper.ts +++ b/www/js/diary/addressNamesHelper.ts @@ -29,9 +29,7 @@ export default function createObserver< }, publish: (entryKey: KeyType, event: EventType) => { if (!listeners[entryKey]) listeners[entryKey] = []; - listeners[entryKey].forEach((listener: Listener) => - listener(event), - ); + listeners[entryKey].forEach((listener: Listener) => listener(event)); }, }; } @@ -41,7 +39,6 @@ export const LocalStorageObserver = createObserver(); export const { subscribe, publish } = LocalStorageObserver; export function useLocalStorage(key: string, initialValue: T) { - const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); @@ -63,8 +60,7 @@ export function useLocalStorage(key: string, initialValue: T) { const setValue = (value: T) => { try { - const valueToStore = - value instanceof Function ? value(storedValue) : value; + const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); LocalStorageObserver.publish(key, valueToStore); if (typeof window !== 'undefined') { @@ -77,11 +73,8 @@ export function useLocalStorage(key: string, initialValue: T) { return [storedValue, setValue]; } - - - -import Bottleneck from "bottleneck"; -import { getAngularService } from "../angular-react-helper"; +import Bottleneck from 'bottleneck'; +import { getAngularService } from '../angular-react-helper'; let nominatimLimiter = new Bottleneck({ maxConcurrent: 2, minTime: 500 }); export const resetNominatimLimiter = () => { @@ -93,15 +86,19 @@ export const resetNominatimLimiter = () => { // accepts a nominatim response object and returns an address-like string // e.g. "Main St, San Francisco" function toAddressName(data) { - const address = data?.["address"]; + const address = data?.['address']; if (address) { /* Sometimes, the street name ('road') isn't found and is undefined. If so, fallback to 'pedestrian' or 'suburb' or 'neighbourhood' */ - const placeName = address['road'] || address['pedestrian'] || - address['suburb'] || address['neighbourhood'] || ''; + const placeName = + address['road'] || + address['pedestrian'] || + address['suburb'] || + address['neighbourhood'] || + ''; /* This could be either a city or town. If neither, fallback to 'county' */ const municipalityName = address['city'] || address['town'] || address['county'] || ''; - return `${placeName}, ${municipalityName}` + return `${placeName}, ${municipalityName}`; } return '...'; } @@ -115,31 +112,42 @@ async function fetchNominatimLocName(loc_geojson) { const coordsStr = loc_geojson.coordinates.toString(); const cachedResponse = localStorage.getItem(coordsStr); if (cachedResponse) { - console.log('fetchNominatimLocName: found cached response for ', coordsStr, cachedResponse, 'skipping fetch'); + console.log( + 'fetchNominatimLocName: found cached response for ', + coordsStr, + cachedResponse, + 'skipping fetch', + ); return; } - console.log("Getting location name for ", coordsStr); - const url = "https://nominatim.openstreetmap.org/reverse?format=json&lat=" + loc_geojson.coordinates[1] + "&lon=" + loc_geojson.coordinates[0]; + console.log('Getting location name for ', coordsStr); + const url = + 'https://nominatim.openstreetmap.org/reverse?format=json&lat=' + + loc_geojson.coordinates[1] + + '&lon=' + + loc_geojson.coordinates[0]; try { const response = await fetch(url); const data = await response.json(); - Logger.log(`while reading data from nominatim, status = ${response.status} data = ${JSON.stringify(data)}`); + Logger.log( + `while reading data from nominatim, status = ${response.status} data = ${JSON.stringify( + data, + )}`, + ); localStorage.setItem(coordsStr, JSON.stringify(data)); publish(coordsStr, data); } catch (error) { if (!nominatimError) { nominatimError = error; - Logger.displayError("while reading address data ", error); + Logger.displayError('while reading address data ', error); } } -}; +} // Schedules nominatim fetches for the start and end locations of a trip export function fillLocationNamesOfTrip(trip) { - nominatimLimiter.schedule(() => - fetchNominatimLocName(trip.end_loc)); - nominatimLimiter.schedule(() => - fetchNominatimLocName(trip.start_loc)); + nominatimLimiter.schedule(() => fetchNominatimLocName(trip.end_loc)); + nominatimLimiter.schedule(() => fetchNominatimLocName(trip.start_loc)); } // a React hook that takes a trip or place and returns an array of its address names diff --git a/www/js/diary/cards/DiaryCard.tsx b/www/js/diary/cards/DiaryCard.tsx index f97a38e46..f6e845983 100644 --- a/www/js/diary/cards/DiaryCard.tsx +++ b/www/js/diary/cards/DiaryCard.tsx @@ -7,35 +7,53 @@ (see appTheme.ts for more info on theme flavors) */ -import React from "react"; +import React from 'react'; import { View, useWindowDimensions, StyleSheet } from 'react-native'; import { Card, PaperProvider, useTheme } from 'react-native-paper'; -import TimestampBadge from "./TimestampBadge"; -import useDerivedProperties from "../useDerivedProperties"; +import TimestampBadge from './TimestampBadge'; +import useDerivedProperties from '../useDerivedProperties'; export const DiaryCard = ({ timelineEntry, children, flavoredTheme, ...otherProps }) => { const { width: windowWidth } = useWindowDimensions(); - const { displayStartTime, displayEndTime, - displayStartDateAbbr, displayEndDateAbbr } = useDerivedProperties(timelineEntry); + const { displayStartTime, displayEndTime, displayStartDateAbbr, displayEndDateAbbr } = + useDerivedProperties(timelineEntry); const theme = flavoredTheme || useTheme(); return ( - - - + + + {children} - - + + ); -} +}; // common styles, used for DiaryCard export const cardStyles = StyleSheet.create({ diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index 5211f7ed4..37788a789 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; -import color from "color"; +import color from 'color'; import { LabelTabContext } from '../LabelTab'; import { logDebug } from '../../plugin/logger'; import { getBaseModeOfLabeledTrip } from '../diaryHelper'; @@ -8,14 +8,13 @@ import { Icon } from '../../components/Icon'; import { Text, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; -const ModesIndicator = ({ trip, detectedModes, }) => { - +const ModesIndicator = ({ trip, detectedModes }) => { const { t } = useTranslation(); const { labelOptions } = useContext(LabelTabContext); const { colors } = useTheme(); - const indicatorBackgroundColor = color(colors.onPrimary).alpha(.8).rgb().string(); - let indicatorBorderColor = color('black').alpha(.5).rgb().string(); + const indicatorBackgroundColor = color(colors.onPrimary).alpha(0.8).rgb().string(); + let indicatorBorderColor = color('black').alpha(0.5).rgb().string(); let modeViews; if (trip.userInput.MODE) { @@ -25,35 +24,56 @@ const ModesIndicator = ({ trip, detectedModes, }) => { modeViews = ( - + {trip.userInput.MODE.text} ); - } else if (detectedModes?.length > 1 || detectedModes?.length == 1 && detectedModes[0].mode != 'UNKNOWN') { + } else if ( + detectedModes?.length > 1 || + (detectedModes?.length == 1 && detectedModes[0].mode != 'UNKNOWN') + ) { // show detected modes if there are more than one, or if there is only one and it's not UNKNOWN - modeViews = (<> - {t('diary.detected')} - {detectedModes?.map?.((pct, i) => ( - - - {/* show percents if there are more than one detected modes */} - {detectedModes?.length > 1 && - {pct.pct}% - } - - ))} - ); + modeViews = ( + <> + {t('diary.detected')} + {detectedModes?.map?.((pct, i) => ( + + + {/* show percents if there are more than one detected modes */} + {detectedModes?.length > 1 && ( + + {pct.pct}% + + )} + + ))} + + ); } - return modeViews && ( - - - {modeViews} + return ( + modeViews && ( + + + {modeViews} + - - ) + ) + ); }; const s = StyleSheet.create({ diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index cd1d9c10e..a351f696f 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -6,45 +6,52 @@ PlaceCards use the blueish 'place' theme flavor. */ -import React from "react"; +import React from 'react'; import { View, StyleSheet } from 'react-native'; import { Text } from 'react-native-paper'; -import useAppConfig from "../../useAppConfig"; -import AddNoteButton from "../../survey/enketo/AddNoteButton"; -import AddedNotesList from "../../survey/enketo/AddedNotesList"; -import { getTheme } from "../../appTheme"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useAddressNames } from "../addressNamesHelper"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; +import useAppConfig from '../../useAppConfig'; +import AddNoteButton from '../../survey/enketo/AddNoteButton'; +import AddedNotesList from '../../survey/enketo/AddedNotesList'; +import { getTheme } from '../../appTheme'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useAddressNames } from '../addressNamesHelper'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; -type Props = { place: {[key: string]: any} }; +type Props = { place: { [key: string]: any } }; const PlaceCard = ({ place }: Props) => { - const appConfig = useAppConfig(); const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(place); - let [ placeDisplayName ] = useAddressNames(place); + let [placeDisplayName] = useAddressNames(place); const flavoredTheme = getTheme('place'); return ( - - {/* date and distance */} + + + {/* date and distance */} - {displayDate} + + {displayDate} + - {/* place name */} - + + {/* place name */} + - {/* add note button */} + + {/* add note button */} - + storeKey={'manual/place_addition_input'} + /> diff --git a/www/js/diary/cards/TimestampBadge.tsx b/www/js/diary/cards/TimestampBadge.tsx index 0e8903ec5..10a97e6ee 100644 --- a/www/js/diary/cards/TimestampBadge.tsx +++ b/www/js/diary/cards/TimestampBadge.tsx @@ -1,14 +1,14 @@ /* A presentational component that accepts a time (and optional date) and displays them in a badge Used in the label screen, on the trip, place, and/or untracked cards */ -import React from "react"; -import { View, StyleSheet } from "react-native"; -import { Text, useTheme } from "react-native-paper"; +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Text, useTheme } from 'react-native-paper'; type Props = { - lightBg: boolean, - time: string, - date?: string, + lightBg: boolean; + time: string; + date?: string; }; const TimestampBadge = ({ lightBg, time, date, ...otherProps }: Props) => { const { colors } = useTheme(); @@ -16,14 +16,18 @@ const TimestampBadge = ({ lightBg, time, date, ...otherProps }: Props) => { const textColor = lightBg ? 'black' : 'white'; return ( - - - {time} - - {/* if date is not passed as prop, it will not be shown */ - date && - {`\xa0(${date})` /* date shown in parentheses with space before */} - } + + {time} + { + /* if date is not passed as prop, it will not be shown */ + date && ( + + {`\xa0(${date})` /* date shown in parentheses with space before */} + + ) + } ); }; diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 08e02bca4..78ef42fe1 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -4,35 +4,41 @@ will used the greyish 'draft' theme flavor. */ -import React, { useContext } from "react"; +import React, { useContext } from 'react'; import { View, useWindowDimensions, StyleSheet } from 'react-native'; import { Text, IconButton } from 'react-native-paper'; -import LeafletView from "../../components/LeafletView"; -import { useTranslation } from "react-i18next"; -import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; -import UserInputButton from "../../survey/enketo/UserInputButton"; -import useAppConfig from "../../useAppConfig"; -import AddNoteButton from "../../survey/enketo/AddNoteButton"; -import AddedNotesList from "../../survey/enketo/AddedNotesList"; -import { getTheme } from "../../appTheme"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useNavigation } from "@react-navigation/native"; -import { useAddressNames } from "../addressNamesHelper"; -import { LabelTabContext } from "../LabelTab"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; -import ModesIndicator from "./ModesIndicator"; -import { useGeojsonForTrip } from "../timelineHelper"; +import LeafletView from '../../components/LeafletView'; +import { useTranslation } from 'react-i18next'; +import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; +import UserInputButton from '../../survey/enketo/UserInputButton'; +import useAppConfig from '../../useAppConfig'; +import AddNoteButton from '../../survey/enketo/AddNoteButton'; +import AddedNotesList from '../../survey/enketo/AddedNotesList'; +import { getTheme } from '../../appTheme'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useNavigation } from '@react-navigation/native'; +import { useAddressNames } from '../addressNamesHelper'; +import { LabelTabContext } from '../LabelTab'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +import ModesIndicator from './ModesIndicator'; +import { useGeojsonForTrip } from '../timelineHelper'; -type Props = { trip: {[key: string]: any}}; +type Props = { trip: { [key: string]: any } }; const TripCard = ({ trip }: Props) => { - const { t } = useTranslation(); const { width: windowWidth } = useWindowDimensions(); const appConfig = useAppConfig(); - const { displayStartTime, displayEndTime, displayDate, formattedDistance, - distanceSuffix, displayTime, detectedModes } = useDerivedProperties(trip); - let [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); + const { + displayStartTime, + displayEndTime, + displayDate, + formattedDistance, + distanceSuffix, + displayTime, + detectedModes, + } = useDerivedProperties(trip); + let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); const navigation = useNavigation(); const { surveyOpt, labelOptions } = useContext(LabelTabContext); const tripGeojson = useGeojsonForTrip(trip, labelOptions, trip?.userInput?.MODE?.value); @@ -42,7 +48,7 @@ const TripCard = ({ trip }: Props) => { function showDetail() { const tripId = trip._id.$oid; - navigation.navigate("label.details", { tripId, flavoredTheme }); + navigation.navigate('label.details', { tripId, flavoredTheme }); } const mapOpts = { zoomControl: false, dragging: false }; @@ -50,52 +56,82 @@ const TripCard = ({ trip }: Props) => { const mapStyle = showAddNoteButton ? s.shortenedMap : s.fullHeightMap; return ( showDetail()}> - - showDetail()} - style={{position: 'absolute', right: 0, top: 0, height: 16, width: 32, - justifyContent: 'center', margin: 4}} /> - {/* right panel */} - {/* date and distance */} - - {displayDate} + showDetail()} + style={{ + position: 'absolute', + right: 0, + top: 0, + height: 16, + width: 32, + justifyContent: 'center', + margin: 4, + }} + /> + + {/* right panel */} + + {/* date and distance */} + + + {displayDate} + - - {t('diary.distance-in-time', {distance: formattedDistance, distsuffix: distanceSuffix, time: displayTime})} + + {t('diary.distance-in-time', { + distance: formattedDistance, + distsuffix: distanceSuffix, + time: displayTime, + })} - {/* start and end locations */} - + + {/* start and end locations */} + - {/* mode and purpose buttons / survey button */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } + + {/* mode and purpose buttons / survey button */} + {surveyOpt?.elementTag == 'multilabel' && } + {surveyOpt?.elementTag == 'enketo-trip-button' && ( + + )} - {/* left panel */} - + {/* left panel */} + + style={[{ minHeight: windowWidth / 2 }, mapStyle]} + /> - {showAddNoteButton && + {showAddNoteButton && ( - + - } + )} - {trip.additionsList?.length != 0 && + {trip.additionsList?.length != 0 && ( - } + )} ); }; diff --git a/www/js/diary/cards/UntrackedTimeCard.tsx b/www/js/diary/cards/UntrackedTimeCard.tsx index 855c50ed4..07b5caf71 100644 --- a/www/js/diary/cards/UntrackedTimeCard.tsx +++ b/www/js/diary/cards/UntrackedTimeCard.tsx @@ -7,42 +7,57 @@ UntrackedTimeCards use the reddish 'untracked' theme flavor. */ -import React from "react"; +import React from 'react'; import { View, StyleSheet } from 'react-native'; import { Text } from 'react-native-paper'; -import { getTheme } from "../../appTheme"; -import { useTranslation } from "react-i18next"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useAddressNames } from "../addressNamesHelper"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; +import { getTheme } from '../../appTheme'; +import { useTranslation } from 'react-i18next'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useAddressNames } from '../addressNamesHelper'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; -type Props = { triplike: {[key: string]: any}}; +type Props = { triplike: { [key: string]: any } }; const UntrackedTimeCard = ({ triplike }: Props) => { const { t } = useTranslation(); const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(triplike); - const [ triplikeStartDisplayName, triplikeEndDisplayName ] = useAddressNames(triplike); + const [triplikeStartDisplayName, triplikeEndDisplayName] = useAddressNames(triplike); const flavoredTheme = getTheme('untracked'); return ( - - {/* date and distance */} + + + {/* date and distance */} - {displayDate} + + {displayDate} + - - + + {t('diary.untracked-time-range', { start: displayStartTime, end: displayEndTime })} - {/* start and end locations */} - + {/* start and end locations */} + + displayEndName={triplikeEndDisplayName} + /> @@ -54,7 +69,7 @@ const s = StyleSheet.create({ borderRadius: 5, paddingVertical: 1, paddingHorizontal: 8, - fontSize: 13 + fontSize: 13, }, locationText: { fontSize: 12, diff --git a/www/js/diary/components/StartEndLocations.tsx b/www/js/diary/components/StartEndLocations.tsx index 8d1096fab..b25facc57 100644 --- a/www/js/diary/components/StartEndLocations.tsx +++ b/www/js/diary/components/StartEndLocations.tsx @@ -4,67 +4,70 @@ import { Icon } from '../../components/Icon'; import { Text, Divider, useTheme } from 'react-native-paper'; type Props = { - displayStartTime?: string, displayStartName: string, - displayEndTime?: string, displayEndName?: string, - centered?: boolean, - fontSize?: number, + displayStartTime?: string; + displayStartName: string; + displayEndTime?: string; + displayEndName?: string; + centered?: boolean; + fontSize?: number; }; const StartEndLocations = (props: Props) => { - const { colors } = useTheme(); const fontSize = props.fontSize || 12; - return (<> - - {props.displayStartTime && - - {props.displayStartTime} - - } - - - - - {props.displayStartName} - - - {(props.displayEndName != undefined) && <> - + return ( + <> - {props.displayEndTime && - - {props.displayEndTime} - - } - - + {props.displayStartTime && ( + {props.displayStartTime} + )} + + - - {props.displayEndName} + + {props.displayStartName} - } - ); -} + {props.displayEndName != undefined && ( + <> + + + {props.displayEndTime && ( + {props.displayEndTime} + )} + + + + + {props.displayEndName} + + + + )} + + ); +}; const s = { - location: (centered) => ({ - flexDirection: 'row', - alignItems: 'center', - justifyContent: centered ? 'center' : 'flex-start', - } as ViewProps), - locationIcon: (colors, iconSize, filled?) => ({ - border: `2px solid ${colors.primary}`, - borderRadius: 50, - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - width: iconSize * 1.5, - height: iconSize * 1.5, - backgroundColor: filled ? colors.primary : colors.onPrimary, - marginRight: 6, - } as ViewProps) -} + location: (centered) => + ({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: centered ? 'center' : 'flex-start', + }) as ViewProps, + locationIcon: (colors, iconSize, filled?) => + ({ + border: `2px solid ${colors.primary}`, + borderRadius: 50, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: iconSize * 1.5, + height: iconSize * 1.5, + backgroundColor: filled ? colors.primary : colors.onPrimary, + marginRight: 6, + }) as ViewProps, +}; export default StartEndLocations; diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index ffed9a300..ed48f89c9 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -2,25 +2,32 @@ listed sections of the trip, and a graph of speed during the trip. Navigated to from the main LabelListScreen by clicking a trip card. */ -import React, { useContext, useState } from "react"; -import { View, Modal, ScrollView, useWindowDimensions } from "react-native"; -import { PaperProvider, Appbar, SegmentedButtons, Button, Surface, Text, useTheme } from "react-native-paper"; -import { LabelTabContext } from "../LabelTab"; -import LeafletView from "../../components/LeafletView"; -import { useTranslation } from "react-i18next"; -import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; -import UserInputButton from "../../survey/enketo/UserInputButton"; -import { useAddressNames } from "../addressNamesHelper"; -import { SafeAreaView } from "react-native-safe-area-context"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; -import { useGeojsonForTrip } from "../timelineHelper"; -import TripSectionsDescriptives from "./TripSectionsDescriptives"; -import OverallTripDescriptives from "./OverallTripDescriptives"; -import ToggleSwitch from "../../components/ToggleSwitch"; +import React, { useContext, useState } from 'react'; +import { View, Modal, ScrollView, useWindowDimensions } from 'react-native'; +import { + PaperProvider, + Appbar, + SegmentedButtons, + Button, + Surface, + Text, + useTheme, +} from 'react-native-paper'; +import { LabelTabContext } from '../LabelTab'; +import LeafletView from '../../components/LeafletView'; +import { useTranslation } from 'react-i18next'; +import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; +import UserInputButton from '../../survey/enketo/UserInputButton'; +import { useAddressNames } from '../addressNamesHelper'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +import { useGeojsonForTrip } from '../timelineHelper'; +import TripSectionsDescriptives from './TripSectionsDescriptives'; +import OverallTripDescriptives from './OverallTripDescriptives'; +import ToggleSwitch from '../../components/ToggleSwitch'; const LabelScreenDetails = ({ route, navigation }) => { - const { surveyOpt, timelineMap, labelOptions } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); @@ -28,58 +35,91 @@ const LabelScreenDetails = ({ route, navigation }) => { const trip = timelineMap.get(tripId); const { colors } = flavoredTheme || useTheme(); const { displayDate, displayStartTime, displayEndTime } = useDerivedProperties(trip); - const [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); + const [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); - const [ modesShown, setModesShown ] = useState<'labeled'|'detected'>('labeled'); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && trip?.userInput?.MODE?.value); - const mapOpts = {minZoom: 3, maxZoom: 17}; + const [modesShown, setModesShown] = useState<'labeled' | 'detected'>('labeled'); + const tripGeojson = useGeojsonForTrip( + trip, + labelOptions, + modesShown == 'labeled' && trip?.userInput?.MODE?.value, + ); + const mapOpts = { minZoom: 3, maxZoom: 17 }; const modal = ( - - - { navigation.goBack() }} /> - + + + { + navigation.goBack(); + }} + /> + - - + + - + {/* MultiLabel or UserInput button, inline on one row */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } + {surveyOpt?.elementTag == 'multilabel' && ( + + )} + {surveyOpt?.elementTag == 'enketo-trip-button' && ( + + )} {/* Full-size Leaflet map, with zoom controls */} - + {/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes" otherwise, just show "Detected" */} - {trip?.userInput?.MODE?.value ? - setModesShown(v)} value={modesShown} density='medium' - buttons={[{label: t('diary.labeled-mode'), value: 'labeled'}, {label: t('diary.detected-modes'), value: 'detected'}]} /> - : - - } + )} {/* section-by-section breakdown of duration, distance, and mode */} - + {/* Overall trip duration, distance, and modes. Only show this when multiple sections are shown, and we are showing detected modes. If we just showed the labeled mode or a single section, this would be redundant. */} - { modesShown == 'detected' && trip?.sections?.length > 1 && + {modesShown == 'detected' && trip?.sections?.length > 1 && ( - } + )} {/* TODO: show speed graph here */} @@ -87,13 +127,9 @@ const LabelScreenDetails = ({ route, navigation }) => { ); if (route.params.flavoredTheme) { - return ( - - {modal} - - ); + return {modal}; } return modal; -} +}; export default LabelScreenDetails; diff --git a/www/js/diary/details/OverallTripDescriptives.tsx b/www/js/diary/details/OverallTripDescriptives.tsx index 3902c8afe..8030842df 100644 --- a/www/js/diary/details/OverallTripDescriptives.tsx +++ b/www/js/diary/details/OverallTripDescriptives.tsx @@ -1,42 +1,45 @@ import React from 'react'; import { View } from 'react-native'; -import { Text } from 'react-native-paper' +import { Text } from 'react-native-paper'; import useDerivedProperties from '../useDerivedProperties'; import { Icon } from '../../components/Icon'; import { useTranslation } from 'react-i18next'; const OverallTripDescriptives = ({ trip }) => { - const { t } = useTranslation(); - const { displayStartTime, displayEndTime, displayTime, - formattedDistance, distanceSuffix, detectedModes } = useDerivedProperties(trip); + const { + displayStartTime, + displayEndTime, + displayTime, + formattedDistance, + distanceSuffix, + detectedModes, + } = useDerivedProperties(trip); return ( - Overall + + Overall + - {displayTime} - {`${displayStartTime} - ${displayEndTime}`} + {displayTime} + {`${displayStartTime} - ${displayEndTime}`} - - {`${formattedDistance} ${distanceSuffix}`} - + {`${formattedDistance} ${distanceSuffix}`} {detectedModes?.map?.((pct, i) => ( - - {pct.pct}% - + {pct.pct}% ))} ); -} +}; export default OverallTripDescriptives; diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index 6d172fed4..5bd30fdd5 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -1,65 +1,82 @@ import React, { useContext } from 'react'; import { View } from 'react-native'; -import { Text, useTheme } from 'react-native-paper' +import { Text, useTheme } from 'react-native-paper'; import { Icon } from '../../components/Icon'; import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from '../diaryHelper'; import { LabelTabContext } from '../LabelTab'; -const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { - +const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { const { labelOptions } = useContext(LabelTabContext); - const { displayStartTime, displayTime, formattedDistance, - distanceSuffix, formattedSectionProperties } = useDerivedProperties(trip); + const { + displayStartTime, + displayTime, + formattedDistance, + distanceSuffix, + formattedSectionProperties, + } = useDerivedProperties(trip); const { colors } = useTheme(); let sections = formattedSectionProperties; /* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip), we treat this as unimodal and use trip-level attributes to construct a single section */ - if (showLabeledMode && trip?.userInput?.MODE || !trip.sections?.length) { + if ((showLabeledMode && trip?.userInput?.MODE) || !trip.sections?.length) { let baseMode; if (showLabeledMode && trip?.userInput?.MODE) { baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); } else { baseMode = getBaseModeByKey('UNPROCESSED'); } - sections = [{ - startTime: displayStartTime, - duration: displayTime, - distance: formattedDistance, - color: baseMode.color, - icon: baseMode.icon, - text: showLabeledMode && trip.userInput?.MODE?.text, // label text only shown for labeled trips - }]; + sections = [ + { + startTime: displayStartTime, + duration: displayTime, + distance: formattedDistance, + color: baseMode.color, + icon: baseMode.icon, + text: showLabeledMode && trip.userInput?.MODE?.text, // label text only shown for labeled trips + }, + ]; } return ( {sections.map((section, i) => ( - + - {section.duration} - {section.startTime} + {section.duration} + {section.startTime} - - {`${section.distance} ${distanceSuffix}`} - + {`${section.distance} ${distanceSuffix}`} - - - {section.text && - + + + {section.text && ( + {section.text} - } + )} ))} ); -} +}; export default TripSectionsDescriptives; diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 0b834a485..48f40322d 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,57 +1,67 @@ // here we have some helper functions used throughout the label tab // these functions are being gradually migrated out of services.js -import moment from "moment"; -import { DateTime } from "luxon"; -import { LabelOptions, readableLabelToKey } from "../survey/multilabel/confirmHelper"; +import moment from 'moment'; +import { DateTime } from 'luxon'; +import { LabelOptions, readableLabelToKey } from '../survey/multilabel/confirmHelper'; export const modeColors = { - pink: '#c32e85', // oklch(56% 0.2 350) // e-car - red: '#c21725', // oklch(52% 0.2 25) // car - orange: '#bf5900', // oklch(58% 0.16 50) // air, hsr - green: '#008148', // oklch(53% 0.14 155) // bike, e-bike, moped - blue: '#0074b7', // oklch(54% 0.14 245) // walk - periwinkle: '#6356bf', // oklch(52% 0.16 285) // light rail, train, tram, subway - magenta: '#9240a4', // oklch(52% 0.17 320) // bus - grey: '#555555', // oklch(45% 0 0) // unprocessed / unknown - taupe: '#7d585a', // oklch(50% 0.05 15) // ferry, trolleybus, user-defined modes -} + pink: '#c32e85', // oklch(56% 0.2 350) // e-car + red: '#c21725', // oklch(52% 0.2 25) // car + orange: '#bf5900', // oklch(58% 0.16 50) // air, hsr + green: '#008148', // oklch(53% 0.14 155) // bike, e-bike, moped + blue: '#0074b7', // oklch(54% 0.14 245) // walk + periwinkle: '#6356bf', // oklch(52% 0.16 285) // light rail, train, tram, subway + magenta: '#9240a4', // oklch(52% 0.17 320) // bus + grey: '#555555', // oklch(45% 0 0) // unprocessed / unknown + taupe: '#7d585a', // oklch(50% 0.05 15) // ferry, trolleybus, user-defined modes +}; type BaseMode = { - name: string, - icon: string, - color: string -} + name: string; + icon: string; + color: string; +}; // parallels the server-side MotionTypes enum: https://github.com/e-mission/e-mission-server/blob/94e7478e627fa8c171323662f951c611c0993031/emission/core/wrapper/motionactivity.py#L12 -type MotionTypeKey = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' - | 'WALKING' | 'RUNNING' | 'NONE' | 'STOPPED_WHILE_IN_VEHICLE' | 'AIR_OR_HSR'; - -const BaseModes: {[k: string]: BaseMode} = { +type MotionTypeKey = + | 'IN_VEHICLE' + | 'BICYCLING' + | 'ON_FOOT' + | 'STILL' + | 'UNKNOWN' + | 'TILTING' + | 'WALKING' + | 'RUNNING' + | 'NONE' + | 'STOPPED_WHILE_IN_VEHICLE' + | 'AIR_OR_HSR'; + +const BaseModes: { [k: string]: BaseMode } = { // BEGIN MotionTypes - IN_VEHICLE: { name: "IN_VEHICLE", icon: "speedometer", color: modeColors.red }, - BICYCLING: { name: "BICYCLING", icon: "bike", color: modeColors.green }, - ON_FOOT: { name: "ON_FOOT", icon: "walk", color: modeColors.blue }, - UNKNOWN: { name: "UNKNOWN", icon: "help", color: modeColors.grey }, - WALKING: { name: "WALKING", icon: "walk", color: modeColors.blue }, - AIR_OR_HSR: { name: "AIR_OR_HSR", icon: "airplane", color: modeColors.orange }, + IN_VEHICLE: { name: 'IN_VEHICLE', icon: 'speedometer', color: modeColors.red }, + BICYCLING: { name: 'BICYCLING', icon: 'bike', color: modeColors.green }, + ON_FOOT: { name: 'ON_FOOT', icon: 'walk', color: modeColors.blue }, + UNKNOWN: { name: 'UNKNOWN', icon: 'help', color: modeColors.grey }, + WALKING: { name: 'WALKING', icon: 'walk', color: modeColors.blue }, + AIR_OR_HSR: { name: 'AIR_OR_HSR', icon: 'airplane', color: modeColors.orange }, // END MotionTypes - CAR: { name: "CAR", icon: "car", color: modeColors.red }, - E_CAR: { name: "E_CAR", icon: "car-electric", color: modeColors.pink }, - E_BIKE: { name: "E_BIKE", icon: "bicycle-electric", color: modeColors.green }, - E_SCOOTER: { name: "E_SCOOTER", icon: "scooter-electric", color: modeColors.periwinkle }, - MOPED: { name: "MOPED", icon: "moped", color: modeColors.green }, - TAXI: { name: "TAXI", icon: "taxi", color: modeColors.red }, - BUS: { name: "BUS", icon: "bus-side", color: modeColors.magenta }, - AIR: { name: "AIR", icon: "airplane", color: modeColors.orange }, - LIGHT_RAIL: { name: "LIGHT_RAIL", icon: "train-car-passenger", color: modeColors.periwinkle }, - TRAIN: { name: "TRAIN", icon: "train-car-passenger", color: modeColors.periwinkle }, - TRAM: { name: "TRAM", icon: "fas fa-tram", color: modeColors.periwinkle }, - SUBWAY: { name: "SUBWAY", icon: "subway-variant", color: modeColors.periwinkle }, - FERRY: { name: "FERRY", icon: "ferry", color: modeColors.taupe }, - TROLLEYBUS: { name: "TROLLEYBUS", icon: "bus-side", color: modeColors.taupe }, - UNPROCESSED: { name: "UNPROCESSED", icon: "help", color: modeColors.grey }, - OTHER: { name: "OTHER", icon: "pencil-circle", color: modeColors.taupe }, + CAR: { name: 'CAR', icon: 'car', color: modeColors.red }, + E_CAR: { name: 'E_CAR', icon: 'car-electric', color: modeColors.pink }, + E_BIKE: { name: 'E_BIKE', icon: 'bicycle-electric', color: modeColors.green }, + E_SCOOTER: { name: 'E_SCOOTER', icon: 'scooter-electric', color: modeColors.periwinkle }, + MOPED: { name: 'MOPED', icon: 'moped', color: modeColors.green }, + TAXI: { name: 'TAXI', icon: 'taxi', color: modeColors.red }, + BUS: { name: 'BUS', icon: 'bus-side', color: modeColors.magenta }, + AIR: { name: 'AIR', icon: 'airplane', color: modeColors.orange }, + LIGHT_RAIL: { name: 'LIGHT_RAIL', icon: 'train-car-passenger', color: modeColors.periwinkle }, + TRAIN: { name: 'TRAIN', icon: 'train-car-passenger', color: modeColors.periwinkle }, + TRAM: { name: 'TRAM', icon: 'fas fa-tram', color: modeColors.periwinkle }, + SUBWAY: { name: 'SUBWAY', icon: 'subway-variant', color: modeColors.periwinkle }, + FERRY: { name: 'FERRY', icon: 'ferry', color: modeColors.taupe }, + TROLLEYBUS: { name: 'TROLLEYBUS', icon: 'bus-side', color: modeColors.taupe }, + UNPROCESSED: { name: 'UNPROCESSED', icon: 'help', color: modeColors.grey }, + OTHER: { name: 'OTHER', icon: 'pencil-circle', color: modeColors.taupe }, }; type BaseModeKey = keyof typeof BaseModes; @@ -59,27 +69,29 @@ type BaseModeKey = keyof typeof BaseModes; * @param motionName A string like "WALKING" or "MotionTypes.WALKING" * @returns A BaseMode object containing the name, icon, and color of the motion type */ -export function getBaseModeByKey(motionName: BaseModeKey | MotionTypeKey | `MotionTypes.${MotionTypeKey}`) { +export function getBaseModeByKey( + motionName: BaseModeKey | MotionTypeKey | `MotionTypes.${MotionTypeKey}`, +) { let key = ('' + motionName).toUpperCase(); - key = key.split(".").pop(); // if "MotionTypes.WALKING", then just take "WALKING" + key = key.split('.').pop(); // if "MotionTypes.WALKING", then just take "WALKING" return BaseModes[key] || BaseModes.UNKNOWN; } export function getBaseModeOfLabeledTrip(trip, labelOptions) { const modeKey = trip?.userInput?.MODE?.value; if (!modeKey) return null; // trip has no MODE label - const modeOption = labelOptions?.MODE?.find(opt => opt.value == modeKey); - return getBaseModeByKey(modeOption?.baseMode || "OTHER"); + const modeOption = labelOptions?.MODE?.find((opt) => opt.value == modeKey); + return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } export function getBaseModeByValue(value, labelOptions: LabelOptions) { - const modeOption = labelOptions?.MODE?.find(opt => opt.value == value); - return getBaseModeByKey(modeOption?.baseMode || "OTHER"); + const modeOption = labelOptions?.MODE?.find((opt) => opt.value == value); + return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } export function getBaseModeByText(text, labelOptions: LabelOptions) { - const modeOption = labelOptions?.MODE?.find(opt => opt.text == text); - return getBaseModeByKey(modeOption?.baseMode || "OTHER"); + const modeOption = labelOptions?.MODE?.find((opt) => opt.text == text); + return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } /** @@ -90,7 +102,10 @@ export function getBaseModeByText(text, labelOptions: LabelOptions) { */ export function isMultiDay(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return false; - return moment.parseZone(beginFmtTime).format('YYYYMMDD') != moment.parseZone(endFmtTime).format('YYYYMMDD'); + return ( + moment.parseZone(beginFmtTime).format('YYYYMMDD') != + moment.parseZone(endFmtTime).format('YYYYMMDD') + ); } /** @@ -138,11 +153,10 @@ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) const beginMoment = moment.parseZone(beginFmtTime); const endMoment = moment.parseZone(endFmtTime); return endMoment.to(beginMoment, true); -}; +} // Temporary function to avoid repear in getDetectedModes ret val. -const filterRunning = (mode) => - (mode == 'MotionTypes.RUNNING') ? 'MotionTypes.WALKING' : mode; +const filterRunning = (mode) => (mode == 'MotionTypes.RUNNING' ? 'MotionTypes.WALKING' : mode); export function getDetectedModes(trip) { if (!trip.sections?.length) return []; @@ -157,14 +171,16 @@ export function getDetectedModes(trip) { }); // sort modes by the distance traveled (descending) - const sortedKeys = Object.entries(dists).sort((a, b) => b[1] - a[1]).map(e => e[0]); + const sortedKeys = Object.entries(dists) + .sort((a, b) => b[1] - a[1]) + .map((e) => e[0]); let sectionPcts = sortedKeys.map(function (mode) { const fract = dists[mode] / totalDist; return { mode: mode, icon: getBaseModeByKey(mode)?.icon, color: getBaseModeByKey(mode)?.color || 'black', - pct: Math.round(fract * 100) || '<1' // if rounds to 0%, show <1% + pct: Math.round(fract * 100) || '<1', // if rounds to 0%, show <1% }; }); @@ -178,7 +194,7 @@ export function getFormattedSectionProperties(trip, ImperialConfig) { distance: ImperialConfig.getFormattedDistance(s.distance), distanceSuffix: ImperialConfig.distanceSuffix, icon: getBaseModeByKey(s.sensed_mode_str)?.icon, - color: getBaseModeByKey(s.sensed_mode_str)?.color || "#333", + color: getBaseModeByKey(s.sensed_mode_str)?.color || '#333', })); } @@ -186,6 +202,6 @@ export function getLocalTimeString(dt) { if (!dt) return; /* correcting the date of the processed trips knowing that local_dt months are from 1 -> 12 and for the moment function they need to be between 0 -> 11 */ - const mdt = { ...dt, month: dt.month-1 }; - return moment(mdt).format("LT"); + const mdt = { ...dt, month: dt.month - 1 }; + return moment(mdt).format('LT'); } diff --git a/www/js/diary/diaryTypes.ts b/www/js/diary/diaryTypes.ts index bcaeb83ae..5755c91ab 100644 --- a/www/js/diary/diaryTypes.ts +++ b/www/js/diary/diaryTypes.ts @@ -10,63 +10,63 @@ type ConfirmedPlace = any; // TODO /* These are the properties received from the server (basically matches Python code) This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { - _id: {$oid: string}, - additions: any[], // TODO - cleaned_section_summary: any, // TODO - cleaned_trip: {$oid: string}, - confidence_threshold: number, - confirmed_trip: {$oid: string}, - distance: number, - duration: number, - end_confirmed_place: ConfirmedPlace, - end_fmt_time: string, - end_loc: {type: string, coordinates: number[]}, - end_local_dt: any, // TODO - end_place: {$oid: string}, - end_ts: number, - expectation: any, // TODO "{to_label: boolean}" - expected_trip: {$oid: string}, - inferred_labels: any[], // TODO - inferred_section_summary: any, // TODO - inferred_trip: {$oid: string}, - key: string, - locations: any[], // TODO - origin_key: string, - raw_trip: {$oid: string}, - sections: any[], // TODO - source: string, - start_confirmed_place: ConfirmedPlace, - start_fmt_time: string, - start_loc: {type: string, coordinates: number[]}, - start_local_dt: any, // TODO - start_place: {$oid: string}, - start_ts: number, - user_input: any, // TODO -} + _id: { $oid: string }; + additions: any[]; // TODO + cleaned_section_summary: any; // TODO + cleaned_trip: { $oid: string }; + confidence_threshold: number; + confirmed_trip: { $oid: string }; + distance: number; + duration: number; + end_confirmed_place: ConfirmedPlace; + end_fmt_time: string; + end_loc: { type: string; coordinates: number[] }; + end_local_dt: any; // TODO + end_place: { $oid: string }; + end_ts: number; + expectation: any; // TODO "{to_label: boolean}" + expected_trip: { $oid: string }; + inferred_labels: any[]; // TODO + inferred_section_summary: any; // TODO + inferred_trip: { $oid: string }; + key: string; + locations: any[]; // TODO + origin_key: string; + raw_trip: { $oid: string }; + sections: any[]; // TODO + source: string; + start_confirmed_place: ConfirmedPlace; + start_fmt_time: string; + start_loc: { type: string; coordinates: number[] }; + start_local_dt: any; // TODO + start_place: { $oid: string }; + start_ts: number; + user_input: any; // TODO +}; /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ export type DerivedProperties = { - displayDate: string, - displayStartTime: string, - displayEndTime: string, - displayTime: string, - displayStartDateAbbr: string, - displayEndDateAbbr: string, - formattedDistance: string, - formattedSectionProperties: any[], // TODO - distanceSuffix: string, - detectedModes: { mode: string, icon: string, color: string, pct: number|string }[], -} + displayDate: string; + displayStartTime: string; + displayEndTime: string; + displayTime: string; + displayStartDateAbbr: string; + displayEndDateAbbr: string; + formattedDistance: string; + formattedSectionProperties: any[]; // TODO + distanceSuffix: string; + detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; +}; /* These are the properties that are still filled in by some kind of 'populate' mechanism. It would simplify the codebase to just compute them where they're needed (using memoization when apt so performance is not impacted). */ export type PopulatedTrip = CompositeTrip & { - additionsList?: any[], // TODO - finalInference?: any, // TODO - geojson?: any, // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: any, // TODO - verifiability?: string, -} + additionsList?: any[]; // TODO + finalInference?: any; // TODO + geojson?: any; // TODO + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; + userInput?: any; // TODO + verifiability?: string; +}; diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 1c28cdc2c..515553851 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -6,18 +6,17 @@ and allows the user to select a date. */ -import React, { useEffect, useState, useMemo, useContext } from "react"; -import { StyleSheet } from "react-native"; -import moment from "moment"; -import { LabelTabContext } from "../LabelTab"; -import { DatePickerModal } from "react-native-paper-dates"; -import { Text, Divider, useTheme } from "react-native-paper"; -import i18next from "i18next"; -import { useTranslation } from "react-i18next"; -import NavBarButton from "../../components/NavBarButton"; +import React, { useEffect, useState, useMemo, useContext } from 'react'; +import { StyleSheet } from 'react-native'; +import moment from 'moment'; +import { LabelTabContext } from '../LabelTab'; +import { DatePickerModal } from 'react-native-paper-dates'; +import { Text, Divider, useTheme } from 'react-native-paper'; +import i18next from 'i18next'; +import { useTranslation } from 'react-i18next'; +import NavBarButton from '../../components/NavBarButton'; const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { - const { pipelineRange } = useContext(LabelTabContext); const { t } = useTranslation(); const { colors } = useTheme(); @@ -57,36 +56,48 @@ const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { loadSpecificWeekFn(params.date); setOpen(false); }, - [setOpen, loadSpecificWeekFn] + [setOpen, loadSpecificWeekFn], ); const dateRangeEnd = dateRange[1] || t('diary.today'); - return (<> - setOpen(true)}> - {dateRange[0] && (<> - {dateRange[0]} - - )} - {dateRangeEnd} - - - ); + return ( + <> + setOpen(true)}> + {dateRange[0] && ( + <> + {dateRange[0]} + + + )} + {dateRangeEnd} + + + + ); }; export const s = StyleSheet.create({ divider: { width: 25, marginHorizontal: 'auto', - } + }, }); export default DateSelect; diff --git a/www/js/diary/list/FilterSelect.tsx b/www/js/diary/list/FilterSelect.tsx index d1906f462..0018c1bc5 100644 --- a/www/js/diary/list/FilterSelect.tsx +++ b/www/js/diary/list/FilterSelect.tsx @@ -7,36 +7,36 @@ shows the available filters and allows the user to select one. */ -import React, { useState, useMemo } from "react"; -import { Modal } from "react-native"; -import { useTranslation } from "react-i18next"; -import NavBarButton from "../../components/NavBarButton"; -import { RadioButton, Text, Dialog } from "react-native-paper"; +import React, { useState, useMemo } from 'react'; +import { Modal } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import NavBarButton from '../../components/NavBarButton'; +import { RadioButton, Text, Dialog } from 'react-native-paper'; const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }) => { - const { t } = useTranslation(); const [modalVisible, setModalVisible] = useState(false); - const selectedFilter = useMemo(() => filters?.find(f => f.state)?.key || 'show-all', [filters]); + const selectedFilter = useMemo(() => filters?.find((f) => f.state)?.key || 'show-all', [filters]); const labelDisplayText = useMemo(() => { - if (!filters) - return '...'; - const selectedFilterObj = filters?.find(f => f.state); - if (!selectedFilterObj) return t('diary.show-all') + ` (${numListTotal||0})`; - return selectedFilterObj.text + ` (${numListDisplayed||0}/${numListTotal||0})`; + if (!filters) return '...'; + const selectedFilterObj = filters?.find((f) => f.state); + if (!selectedFilterObj) return t('diary.show-all') + ` (${numListTotal || 0})`; + return selectedFilterObj.text + ` (${numListDisplayed || 0}/${numListTotal || 0})`; }, [filters, numListDisplayed, numListTotal]); function chooseFilter(filterKey) { if (filterKey == 'show-all') { - setFilters(filters.map(f => ({ ...f, state: false }))); + setFilters(filters.map((f) => ({ ...f, state: false }))); } else { - setFilters(filters.map(f => { - if (f.key === filterKey) { - return { ...f, state: true }; - } else { - return { ...f, state: false }; - } - })); + setFilters( + filters.map((f) => { + if (f.key === filterKey) { + return { ...f, state: true }; + } else { + return { ...f, state: false }; + } + }), + ); } /* We must wait to close the modal until this function is done running, else the click event might leak to the content behind the modal */ @@ -44,28 +44,32 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }) = the next event loop cycle */ } - return (<> - setModalVisible(true)}> - - {labelDisplayText} - - - setModalVisible(false)}> - setModalVisible(false)}> - {/* TODO - add title */} - {/* {t('diary.filter-travel')} */} - - chooseFilter(k)} value={selectedFilter}> - {filters.map(f => ( - - ))} - - - - - - ); + return ( + <> + setModalVisible(true)}> + {labelDisplayText} + + setModalVisible(false)}> + setModalVisible(false)}> + {/* TODO - add title */} + {/* {t('diary.filter-travel')} */} + + chooseFilter(k)} value={selectedFilter}> + {filters.map((f) => ( + + ))} + + + + + + + ); }; export default FilterSelect; diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 4fb1702b2..217115938 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -1,38 +1,61 @@ -import React, { useContext } from "react"; -import { View } from "react-native"; -import { Appbar, useTheme } from "react-native-paper"; -import DateSelect from "./DateSelect"; -import FilterSelect from "./FilterSelect"; -import TimelineScrollList from "./TimelineScrollList"; -import { LabelTabContext } from "../LabelTab"; +import React, { useContext } from 'react'; +import { View } from 'react-native'; +import { Appbar, useTheme } from 'react-native-paper'; +import DateSelect from './DateSelect'; +import FilterSelect from './FilterSelect'; +import TimelineScrollList from './TimelineScrollList'; +import { LabelTabContext } from '../LabelTab'; const LabelListScreen = () => { - - const { filterInputs, setFilterInputs, timelineMap, displayedEntries, - queriedRange, loadSpecificWeek, refresh, pipelineRange, - loadAnotherWeek, isLoading } = useContext(LabelTabContext); + const { + filterInputs, + setFilterInputs, + timelineMap, + displayedEntries, + queriedRange, + loadSpecificWeek, + refresh, + pipelineRange, + loadAnotherWeek, + isLoading, + } = useContext(LabelTabContext); const { colors } = useTheme(); - return (<> - - - - refresh()} accessibilityLabel="Refresh" - style={{marginLeft: 'auto'}} /> - - - - - ) -} + return ( + <> + + + + refresh()} + accessibilityLabel="Refresh" + style={{ marginLeft: 'auto' }} + /> + + + + + + ); +}; export default LabelListScreen; diff --git a/www/js/diary/list/LoadMoreButton.tsx b/www/js/diary/list/LoadMoreButton.tsx index f3d6db082..dfc49a9e2 100644 --- a/www/js/diary/list/LoadMoreButton.tsx +++ b/www/js/diary/list/LoadMoreButton.tsx @@ -1,18 +1,24 @@ -import React from "react"; -import { StyleSheet, View } from "react-native"; -import { Button, useTheme } from "react-native-paper"; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Button, useTheme } from 'react-native-paper'; const LoadMoreButton = ({ children, onPressFn, ...otherProps }) => { const { colors } = useTheme(); return ( - ); -} +}; const s = StyleSheet.create({ container: { @@ -21,8 +27,8 @@ const s = StyleSheet.create({ }, btn: { maxHeight: 30, - justifyContent: 'center' - } + justifyContent: 'center', + }, }); export default LoadMoreButton; diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx index 6dfd1e736..954a90db9 100644 --- a/www/js/diary/list/TimelineScrollList.tsx +++ b/www/js/diary/list/TimelineScrollList.tsx @@ -11,51 +11,58 @@ import { Icon } from '../../components/Icon'; const renderCard = ({ item: listEntry }) => { if (listEntry.origin_key.includes('trip')) { - return + return ; } else if (listEntry.origin_key.includes('place')) { - return + return ; } else if (listEntry.origin_key.includes('untracked')) { - return + return ; } }; -const separator = () => -const bigSpinner = -const smallSpinner = +const separator = () => ; +const bigSpinner = ; +const smallSpinner = ; type Props = { - listEntries: any[], - queriedRange: any, - pipelineRange: any, - loadMoreFn: (direction: string) => void, - isLoading: boolean | string -} -const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMoreFn, isLoading }: Props) => { - + listEntries: any[]; + queriedRange: any; + pipelineRange: any; + loadMoreFn: (direction: string) => void; + isLoading: boolean | string; +}; +const TimelineScrollList = ({ + listEntries, + queriedRange, + pipelineRange, + loadMoreFn, + isLoading, +}: Props) => { const { t } = useTranslation(); // The way that FlashList inverts the scroll view means we have to reverse the order of items too const reversedListEntries = listEntries ? [...listEntries].reverse() : []; - const reachedPipelineStart = (queriedRange?.start_ts <= pipelineRange?.start_ts); - const footer = loadMoreFn('past')} - disabled={reachedPipelineStart}> - { reachedPipelineStart ? t('diary.no-more-travel') : t('diary.show-older-travel')} - ; - - const reachedPipelineEnd = (queriedRange?.end_ts >= pipelineRange?.end_ts); - const header = loadMoreFn('future')} - disabled={reachedPipelineEnd}> - { reachedPipelineEnd ? t('diary.no-more-travel') : t('diary.show-more-travel')} - ; + const reachedPipelineStart = queriedRange?.start_ts <= pipelineRange?.start_ts; + const footer = ( + loadMoreFn('past')} disabled={reachedPipelineStart}> + {reachedPipelineStart ? t('diary.no-more-travel') : t('diary.show-older-travel')} + + ); + + const reachedPipelineEnd = queriedRange?.end_ts >= pipelineRange?.end_ts; + const header = ( + loadMoreFn('future')} disabled={reachedPipelineEnd}> + {reachedPipelineEnd ? t('diary.no-more-travel') : t('diary.show-more-travel')} + + ); const noTravelBanner = ( - - }> + }> - {t('diary.no-travel')} - {t('diary.no-travel-hint')} + {t('diary.no-travel')} + {t('diary.no-travel-hint')} ); @@ -64,7 +71,7 @@ const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMore /* Condition: pipelineRange has been fetched but has no defined end, meaning nothing has been processed for this OPCode yet, and there are no unprocessed trips either. Show 'no travel'. */ return noTravelBanner; - } else if (isLoading=='replace') { + } else if (isLoading == 'replace') { /* Condition: we're loading an entirely new batch of trips, so show a big spinner */ return bigSpinner; } else if (listEntries && listEntries.length == 0) { @@ -73,7 +80,8 @@ const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMore } else if (listEntries) { /* Condition: we've successfully loaded and set `listEntries`, so show the list */ return ( - console.debug(e.nativeEvent.contentOffset.y)} - ListHeaderComponent={isLoading == 'append' ? smallSpinner : (!reachedPipelineEnd && header)} + ListHeaderComponent={isLoading == 'append' ? smallSpinner : !reachedPipelineEnd && header} ListFooterComponent={isLoading == 'prepend' ? smallSpinner : footer} - ItemSeparatorComponent={separator} /> + ItemSeparatorComponent={separator} + /> ); } -} +}; export default TimelineScrollList; diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 774273fa2..92d322f04 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -6,47 +6,56 @@ import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../commHelper'; -angular.module('emission.main.diary.services', ['emission.plugin.logger', - 'emission.services']) -.factory('Timeline', function($http, $ionicLoading, $ionicPlatform, $window, - $rootScope, UnifiedDataLoader, Logger, $injector) { - var timeline = {}; - // corresponds to the old $scope.data. Contains all state for the current - // day, including the indication of the current day - timeline.data = {}; - timeline.data.unifiedConfirmsResults = null; - timeline.UPDATE_DONE = "TIMELINE_UPDATE_DONE"; - - let manualInputFactory; - $ionicPlatform.ready(function () { - getConfig().then((configObj) => { - const surveyOptKey = configObj.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - console.log('surveyOpt in services.js is', surveyOpt); - manualInputFactory = $injector.get(surveyOpt.service); +angular + .module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) + .factory( + 'Timeline', + function ( + $http, + $ionicLoading, + $ionicPlatform, + $window, + $rootScope, + UnifiedDataLoader, + Logger, + $injector, + ) { + var timeline = {}; + // corresponds to the old $scope.data. Contains all state for the current + // day, including the indication of the current day + timeline.data = {}; + timeline.data.unifiedConfirmsResults = null; + timeline.UPDATE_DONE = 'TIMELINE_UPDATE_DONE'; + + let manualInputFactory; + $ionicPlatform.ready(function () { + getConfig().then((configObj) => { + const surveyOptKey = configObj.survey_info['trip-labels']; + const surveyOpt = SurveyOptions[surveyOptKey]; + console.log('surveyOpt in services.js is', surveyOpt); + manualInputFactory = $injector.get(surveyOpt.service); + }); }); - }); - - // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. - // This function returns a shallow copy of the obj, which flattens the - // 'data' field into the top level, while also including '_id' and 'metadata.key' - const unpack = (obj) => ({ - ...obj.data, - _id: obj._id, - key: obj.metadata.key, - origin_key: obj.metadata.origin_key || obj.metadata.key, - }); - - timeline.readAllCompositeTrips = function(startTs, endTs) { - $ionicLoading.show({ - template: i18next.t('service.reading-server') + + // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. + // This function returns a shallow copy of the obj, which flattens the + // 'data' field into the top level, while also including '_id' and 'metadata.key' + const unpack = (obj) => ({ + ...obj.data, + _id: obj._id, + key: obj.metadata.key, + origin_key: obj.metadata.origin_key || obj.metadata.key, }); - const readPromises = [ - getRawEntries(["analysis/composite_trip"], - startTs, endTs, "data.end_ts"), - ]; - return Promise.all(readPromises) - .then(([ctList]) => { + + timeline.readAllCompositeTrips = function (startTs, endTs) { + $ionicLoading.show({ + template: i18next.t('service.reading-server'), + }); + const readPromises = [ + getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts'), + ]; + return Promise.all(readPromises) + .then(([ctList]) => { $ionicLoading.hide(); return ctList.phone_data.map((ct) => { const unpackedCt = unpack(ct); @@ -56,191 +65,222 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', end_confirmed_place: unpack(unpackedCt.end_confirmed_place), locations: unpackedCt.locations?.map(unpack), sections: unpackedCt.sections?.map(unpack), - } + }; }); - }) - .catch((err) => { - Logger.displayError("while reading confirmed trips", err); + }) + .catch((err) => { + Logger.displayError('while reading confirmed trips', err); $ionicLoading.hide(); return []; - }); - }; - - /* - * This is going to be a bit tricky. As we can see from - * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, - * when we read local transitions, they have a string for the transition - * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer - * (e.g. `2`). - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 - * - * Also, at least on iOS, it is possible for trip end to be detected way - * after the end of the trip, so the trip end transition of a processed - * trip may actually show up as an unprocessed transition. - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 - * - * Let's abstract this out into our own minor state machine. - */ - var transitions2Trips = function(transitionList) { + }); + }; + + /* + * This is going to be a bit tricky. As we can see from + * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, + * when we read local transitions, they have a string for the transition + * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer + * (e.g. `2`). + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 + * + * Also, at least on iOS, it is possible for trip end to be detected way + * after the end of the trip, so the trip end transition of a processed + * trip may actually show up as an unprocessed transition. + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 + * + * Let's abstract this out into our own minor state machine. + */ + var transitions2Trips = function (transitionList) { var inTrip = false; - var tripList = [] + var tripList = []; var currStartTransitionIndex = -1; var currEndTransitionIndex = -1; var processedUntil = 0; - - while(processedUntil < transitionList.length) { + + while (processedUntil < transitionList.length) { // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); - if(inTrip == false) { - var foundStartTransitionIndex = transitionList.slice(processedUntil).findIndex(isStartingTransition); - if (foundStartTransitionIndex == -1) { - Logger.log("No further unprocessed trips started, exiting loop"); - processedUntil = transitionList.length; - } else { - currStartTransitionIndex = processedUntil + foundStartTransitionIndex; - processedUntil = currStartTransitionIndex; - Logger.log("Unprocessed trip started at "+JSON.stringify(transitionList[currStartTransitionIndex])); - inTrip = true; - } + if (inTrip == false) { + var foundStartTransitionIndex = transitionList + .slice(processedUntil) + .findIndex(isStartingTransition); + if (foundStartTransitionIndex == -1) { + Logger.log('No further unprocessed trips started, exiting loop'); + processedUntil = transitionList.length; + } else { + currStartTransitionIndex = processedUntil + foundStartTransitionIndex; + processedUntil = currStartTransitionIndex; + Logger.log( + 'Unprocessed trip started at ' + + JSON.stringify(transitionList[currStartTransitionIndex]), + ); + inTrip = true; + } } else { - // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); - var foundEndTransitionIndex = transitionList.slice(processedUntil).findIndex(isEndingTransition); - if (foundEndTransitionIndex == -1) { - Logger.log("Can't find end for trip starting at "+JSON.stringify(transitionList[currStartTransitionIndex])+" dropping it"); - processedUntil = transitionList.length; - } else { - currEndTransitionIndex = processedUntil + foundEndTransitionIndex; - processedUntil = currEndTransitionIndex; - Logger.log("currEndTransitionIndex = "+currEndTransitionIndex); - Logger.log("Unprocessed trip starting at "+JSON.stringify(transitionList[currStartTransitionIndex])+" ends at "+JSON.stringify(transitionList[currEndTransitionIndex])); - tripList.push([transitionList[currStartTransitionIndex], - transitionList[currEndTransitionIndex]]) - inTrip = false; - } + // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); + var foundEndTransitionIndex = transitionList + .slice(processedUntil) + .findIndex(isEndingTransition); + if (foundEndTransitionIndex == -1) { + Logger.log( + "Can't find end for trip starting at " + + JSON.stringify(transitionList[currStartTransitionIndex]) + + ' dropping it', + ); + processedUntil = transitionList.length; + } else { + currEndTransitionIndex = processedUntil + foundEndTransitionIndex; + processedUntil = currEndTransitionIndex; + Logger.log('currEndTransitionIndex = ' + currEndTransitionIndex); + Logger.log( + 'Unprocessed trip starting at ' + + JSON.stringify(transitionList[currStartTransitionIndex]) + + ' ends at ' + + JSON.stringify(transitionList[currEndTransitionIndex]), + ); + tripList.push([ + transitionList[currStartTransitionIndex], + transitionList[currEndTransitionIndex], + ]); + inTrip = false; + } } } return tripList; - } + }; - var isStartingTransition = function(transWrapper) { + var isStartingTransition = function (transWrapper) { // Logger.log("isStartingTransition: transWrapper.data.transition = "+transWrapper.data.transition); - if(transWrapper.data.transition == 'local.transition.exited_geofence' || - transWrapper.data.transition == 'T_EXITED_GEOFENCE' || - transWrapper.data.transition == 1) { - // Logger.log("Returning true"); - return true; + if ( + transWrapper.data.transition == 'local.transition.exited_geofence' || + transWrapper.data.transition == 'T_EXITED_GEOFENCE' || + transWrapper.data.transition == 1 + ) { + // Logger.log("Returning true"); + return true; } // Logger.log("Returning false"); return false; - } + }; - var isEndingTransition = function(transWrapper) { + var isEndingTransition = function (transWrapper) { // Logger.log("isEndingTransition: transWrapper.data.transition = "+transWrapper.data.transition); - if(transWrapper.data.transition == 'T_TRIP_ENDED' || - transWrapper.data.transition == 'local.transition.stopped_moving' || - transWrapper.data.transition == 2) { - // Logger.log("Returning true"); - return true; + if ( + transWrapper.data.transition == 'T_TRIP_ENDED' || + transWrapper.data.transition == 'local.transition.stopped_moving' || + transWrapper.data.transition == 2 + ) { + // Logger.log("Returning true"); + return true; } // Logger.log("Returning false"); return false; - } + }; - /* - * Fill out place geojson after pulling trip location points. - * Place is only partially filled out because we haven't linked the timeline yet - */ + /* + * Fill out place geojson after pulling trip location points. + * Place is only partially filled out because we haven't linked the timeline yet + */ - var moment2localdate = function(currMoment, tz) { + var moment2localdate = function (currMoment, tz) { return { - timezone: tz, - year: currMoment.year(), - //the months of the draft trips match the one format needed for - //moment function however now that is modified we need to also - //modify the months value here - month: currMoment.month() + 1, - day: currMoment.date(), - weekday: currMoment.weekday(), - hour: currMoment.hour(), - minute: currMoment.minute(), - second: currMoment.second() + timezone: tz, + year: currMoment.year(), + //the months of the draft trips match the one format needed for + //moment function however now that is modified we need to also + //modify the months value here + month: currMoment.month() + 1, + day: currMoment.date(), + weekday: currMoment.weekday(), + hour: currMoment.hour(), + minute: currMoment.minute(), + second: currMoment.second(), }; - } - - var points2TripProps = function(locationPoints) { - var startPoint = locationPoints[0]; - var endPoint = locationPoints[locationPoints.length - 1]; - var tripAndSectionId = "unprocessed_"+startPoint.data.ts+"_"+endPoint.data.ts; - var startMoment = moment.unix(startPoint.data.ts).tz(startPoint.metadata.time_zone); - var endMoment = moment.unix(endPoint.data.ts).tz(endPoint.metadata.time_zone); - - const speeds = [], dists = []; - let loc, locLatLng; - locationPoints.forEach((pt) => { - const ptLatLng = L.latLng([pt.data.latitude, pt.data.longitude]); - if (loc) { - const dist = locLatLng.distanceTo(ptLatLng); - const timeDelta = pt.data.ts - loc.data.ts; - dists.push(dist); - speeds.push(dist / timeDelta); - } - loc = pt; - locLatLng = ptLatLng; - }); - - const locations = locationPoints.map((point, i) => ({ + }; + + var points2TripProps = function (locationPoints) { + var startPoint = locationPoints[0]; + var endPoint = locationPoints[locationPoints.length - 1]; + var tripAndSectionId = 'unprocessed_' + startPoint.data.ts + '_' + endPoint.data.ts; + var startMoment = moment.unix(startPoint.data.ts).tz(startPoint.metadata.time_zone); + var endMoment = moment.unix(endPoint.data.ts).tz(endPoint.metadata.time_zone); + + const speeds = [], + dists = []; + let loc, locLatLng; + locationPoints.forEach((pt) => { + const ptLatLng = L.latLng([pt.data.latitude, pt.data.longitude]); + if (loc) { + const dist = locLatLng.distanceTo(ptLatLng); + const timeDelta = pt.data.ts - loc.data.ts; + dists.push(dist); + speeds.push(dist / timeDelta); + } + loc = pt; + locLatLng = ptLatLng; + }); + + const locations = locationPoints.map((point, i) => ({ loc: { - coordinates: [point.data.longitude, point.data.latitude] + coordinates: [point.data.longitude, point.data.latitude], }, ts: point.data.ts, speed: speeds[i], - })); - - return { - _id: {$oid: tripAndSectionId}, - key: "UNPROCESSED_trip", - origin_key: "UNPROCESSED_trip", - additions: [], - confidence_threshold: 0, - distance: dists.reduce((a, b) => a + b, 0), - duration: endPoint.data.ts - startPoint.data.ts, - end_fmt_time: endMoment.format(), - end_local_dt: moment2localdate(endMoment, endPoint.metadata.time_zone), - end_ts: endPoint.data.ts, - expectation: {to_label: true}, - inferred_labels: [], - locations: locations, - source: "unprocessed", - start_fmt_time: startMoment.format(), - start_local_dt: moment2localdate(startMoment, startPoint.metadata.time_zone), - start_ts: startPoint.data.ts, - user_input: {}, - } - } - - var tsEntrySort = function(e1, e2) { - // compare timestamps - return e1.data.ts - e2.data.ts; - } - - var transitionTrip2TripObj = function(trip) { - var tripStartTransition = trip[0]; - var tripEndTransition = trip[1]; - var tq = {key: "write_ts", - startTs: tripStartTransition.data.ts, - endTs: tripEndTransition.data.ts - } - Logger.log("About to pull location data for range " - + moment.unix(tripStartTransition.data.ts).toString() + " -> " - + moment.unix(tripEndTransition.data.ts).toString()); - return UnifiedDataLoader.getUnifiedSensorDataForInterval("background/filtered_location", tq).then(function(locationList) { + })); + + return { + _id: { $oid: tripAndSectionId }, + key: 'UNPROCESSED_trip', + origin_key: 'UNPROCESSED_trip', + additions: [], + confidence_threshold: 0, + distance: dists.reduce((a, b) => a + b, 0), + duration: endPoint.data.ts - startPoint.data.ts, + end_fmt_time: endMoment.format(), + end_local_dt: moment2localdate(endMoment, endPoint.metadata.time_zone), + end_ts: endPoint.data.ts, + expectation: { to_label: true }, + inferred_labels: [], + locations: locations, + source: 'unprocessed', + start_fmt_time: startMoment.format(), + start_local_dt: moment2localdate(startMoment, startPoint.metadata.time_zone), + start_ts: startPoint.data.ts, + user_input: {}, + }; + }; + + var tsEntrySort = function (e1, e2) { + // compare timestamps + return e1.data.ts - e2.data.ts; + }; + + var transitionTrip2TripObj = function (trip) { + var tripStartTransition = trip[0]; + var tripEndTransition = trip[1]; + var tq = { + key: 'write_ts', + startTs: tripStartTransition.data.ts, + endTs: tripEndTransition.data.ts, + }; + Logger.log( + 'About to pull location data for range ' + + moment.unix(tripStartTransition.data.ts).toString() + + ' -> ' + + moment.unix(tripEndTransition.data.ts).toString(), + ); + return UnifiedDataLoader.getUnifiedSensorDataForInterval( + 'background/filtered_location', + tq, + ).then(function (locationList) { if (locationList.length == 0) { return undefined; } var sortedLocationList = locationList.sort(tsEntrySort); - var retainInRange = function(loc) { - return (tripStartTransition.data.ts <= loc.data.ts) && - (loc.data.ts <= tripEndTransition.data.ts) - } + var retainInRange = function (loc) { + return ( + tripStartTransition.data.ts <= loc.data.ts && loc.data.ts <= tripEndTransition.data.ts + ); + }; var filteredLocationList = sortedLocationList.filter(retainInRange); @@ -250,17 +290,26 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', } var tripStartPoint = filteredLocationList[0]; - var tripEndPoint = filteredLocationList[filteredLocationList.length-1]; - Logger.log("tripStartPoint = "+JSON.stringify(tripStartPoint)+"tripEndPoint = "+JSON.stringify(tripEndPoint)); + var tripEndPoint = filteredLocationList[filteredLocationList.length - 1]; + Logger.log( + 'tripStartPoint = ' + + JSON.stringify(tripStartPoint) + + 'tripEndPoint = ' + + JSON.stringify(tripEndPoint), + ); // if we get a list but our start and end are undefined // let's print out the complete original list to get a clue - // this should help with debugging + // this should help with debugging // https://github.com/e-mission/e-mission-docs/issues/417 // if it ever occurs again if (angular.isUndefined(tripStartPoint) || angular.isUndefined(tripEndPoint)) { - Logger.log("BUG 417 check: locationList = "+JSON.stringify(locationList)); - Logger.log("transitions: start = "+JSON.stringify(tripStartTransition.data) - + " end = "+JSON.stringify(tripEndTransition.data.ts)); + Logger.log('BUG 417 check: locationList = ' + JSON.stringify(locationList)); + Logger.log( + 'transitions: start = ' + + JSON.stringify(tripStartTransition.data) + + ' end = ' + + JSON.stringify(tripEndTransition.data.ts), + ); } const tripProps = points2TripProps(filteredLocationList); @@ -268,121 +317,130 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', return { ...tripProps, start_loc: { - type: "Point", - coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude] + type: 'Point', + coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude], }, end_loc: { - type: "Point", + type: 'Point', coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], }, - } + }; }); - } + }; - var linkTrips = function(trip1, trip2) { + var linkTrips = function (trip1, trip2) { // complete trip1 - trip1.starting_trip = {$oid: trip2.id}; + trip1.starting_trip = { $oid: trip2.id }; trip1.exit_fmt_time = trip2.enter_fmt_time; trip1.exit_local_dt = trip2.enter_local_dt; trip1.exit_ts = trip2.enter_ts; // start trip2 - trip2.ending_trip = {$oid: trip1.id}; + trip2.ending_trip = { $oid: trip1.id }; trip2.enter_fmt_time = trip1.exit_fmt_time; trip2.enter_local_dt = trip1.exit_local_dt; trip2.enter_ts = trip1.exit_ts; - } + }; - timeline.readUnprocessedTrips = function(startTs, endTs, lastProcessedTrip) { + timeline.readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) { $ionicLoading.show({ - template: i18next.t('service.reading-unprocessed-data') + template: i18next.t('service.reading-unprocessed-data'), }); - var tq = {key: "write_ts", - startTs, - endTs - } - Logger.log("about to query for unprocessed trips from " - +moment.unix(tq.startTs).toString()+" -> "+moment.unix(tq.endTs).toString()); - return UnifiedDataLoader.getUnifiedMessagesForInterval("statemachine/transition", tq) - .then(function(transitionList) { - if (transitionList.length == 0) { - Logger.log("No unprocessed trips. yay!"); - $ionicLoading.hide(); - return []; - } else { - Logger.log("Found "+transitionList.length+" transitions. yay!"); - var sortedTransitionList = transitionList.sort(tsEntrySort); - /* + var tq = { key: 'write_ts', startTs, endTs }; + Logger.log( + 'about to query for unprocessed trips from ' + + moment.unix(tq.startTs).toString() + + ' -> ' + + moment.unix(tq.endTs).toString(), + ); + return UnifiedDataLoader.getUnifiedMessagesForInterval('statemachine/transition', tq).then( + function (transitionList) { + if (transitionList.length == 0) { + Logger.log('No unprocessed trips. yay!'); + $ionicLoading.hide(); + return []; + } else { + Logger.log('Found ' + transitionList.length + ' transitions. yay!'); + var sortedTransitionList = transitionList.sort(tsEntrySort); + /* sortedTransitionList.forEach(function(transition) { console.log(moment(transition.data.ts * 1000).format()+":" + JSON.stringify(transition.data)); }); */ - var tripsList = transitions2Trips(transitionList); - Logger.log("Mapped into"+tripsList.length+" trips. yay!"); - tripsList.forEach(function(trip) { + var tripsList = transitions2Trips(transitionList); + Logger.log('Mapped into' + tripsList.length + ' trips. yay!'); + tripsList.forEach(function (trip) { console.log(JSON.stringify(trip)); - }); - var tripFillPromises = tripsList.map(transitionTrip2TripObj); - return Promise.all(tripFillPromises).then(function(raw_trip_gj_list) { + }); + var tripFillPromises = tripsList.map(transitionTrip2TripObj); + return Promise.all(tripFillPromises).then(function (raw_trip_gj_list) { // Now we need to link up the trips. linking unprocessed trips // to one another is fairly simple, but we need to link the // first unprocessed trip to the last processed trip. // This might be challenging if we don't have any processed - // trips for the day. I don't want to go back forever until + // trips for the day. I don't want to go back forever until // I find a trip. So if this is the first trip, we will start a // new chain for now, since this is with unprocessed data // anyway. - Logger.log("mapped trips to trip_gj_list of size "+raw_trip_gj_list.length); + Logger.log('mapped trips to trip_gj_list of size ' + raw_trip_gj_list.length); /* Filtering: we will keep trips that are 1) defined and 2) have a distance >= 100m or duration >= 5 minutes https://github.com/e-mission/e-mission-docs/issues/966#issuecomment-1709112578 */ - const trip_gj_list = raw_trip_gj_list.filter((trip) => - trip && (trip.distance >= 100 || trip.duration >= 300) + const trip_gj_list = raw_trip_gj_list.filter( + (trip) => trip && (trip.distance >= 100 || trip.duration >= 300), + ); + Logger.log( + 'after filtering undefined and distance < 100, trip_gj_list size = ' + + raw_trip_gj_list.length, ); - Logger.log("after filtering undefined and distance < 100, trip_gj_list size = "+raw_trip_gj_list.length); // Link 0th trip to first, first to second, ... - for (var i = 0; i < trip_gj_list.length-1; i++) { - linkTrips(trip_gj_list[i], trip_gj_list[i+1]); + for (var i = 0; i < trip_gj_list.length - 1; i++) { + linkTrips(trip_gj_list[i], trip_gj_list[i + 1]); } - Logger.log("finished linking trips for list of size "+trip_gj_list.length); + Logger.log('finished linking trips for list of size ' + trip_gj_list.length); if (lastProcessedTrip && trip_gj_list.length != 0) { - // Need to link the entire chain above to the processed data - Logger.log("linking unprocessed and processed trip chains"); - linkTrips(lastProcessedTrip, trip_gj_list[0]); + // Need to link the entire chain above to the processed data + Logger.log('linking unprocessed and processed trip chains'); + linkTrips(lastProcessedTrip, trip_gj_list[0]); } $ionicLoading.hide(); - Logger.log("Returning final list of size "+trip_gj_list.length); + Logger.log('Returning final list of size ' + trip_gj_list.length); return trip_gj_list; - }); - } - }); - } + }); + } + }, + ); + }; - var localCacheReadFn = timeline.updateFromDatabase; + var localCacheReadFn = timeline.updateFromDatabase; - timeline.getTrip = function(tripId) { - return angular.isDefined(timeline.data.tripMap)? timeline.data.tripMap[tripId] : undefined; + timeline.getTrip = function (tripId) { + return angular.isDefined(timeline.data.tripMap) ? timeline.data.tripMap[tripId] : undefined; }; - timeline.getTripWrapper = function(tripId) { - return angular.isDefined(timeline.data.tripWrapperMap)? timeline.data.tripWrapperMap[tripId] : undefined; + timeline.getTripWrapper = function (tripId) { + return angular.isDefined(timeline.data.tripWrapperMap) + ? timeline.data.tripWrapperMap[tripId] + : undefined; }; - timeline.getCompositeTrip = function(tripId) { - return angular.isDefined(timeline.data.infScrollCompositeTripMap)? timeline.data.infScrollCompositeTripMap[tripId] : undefined; + timeline.getCompositeTrip = function (tripId) { + return angular.isDefined(timeline.data.infScrollCompositeTripMap) + ? timeline.data.infScrollCompositeTripMap[tripId] + : undefined; }; - timeline.setInfScrollCompositeTripList = function(compositeTripList) { + timeline.setInfScrollCompositeTripList = function (compositeTripList) { timeline.data.infScrollCompositeTripList = compositeTripList; timeline.data.infScrollCompositeTripMap = {}; - timeline.data.infScrollCompositeTripList.forEach(function(trip, index, array) { + timeline.data.infScrollCompositeTripList.forEach(function (trip, index, array) { timeline.data.infScrollCompositeTripMap[trip._id.$oid] = trip; }); - } - - return timeline; - }) + }; + return timeline; + }, + ); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 579e3ac4f..be6ee1bb3 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,8 +1,8 @@ -import moment from "moment"; -import { getAngularService } from "../angular-react-helper"; -import { displayError, logDebug } from "../plugin/logger"; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; -import i18next from "i18next"; +import moment from 'moment'; +import { getAngularService } from '../angular-react-helper'; +import { displayError, logDebug } from '../plugin/logger'; +import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; +import i18next from 'i18next'; const cachedGeojsons = new Map(); /** @@ -15,29 +15,29 @@ export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { return cachedGeojsons.get(gjKey); } - let trajectoryColor: string|null; + let trajectoryColor: string | null; if (labeledMode) { trajectoryColor = getBaseModeOfLabeledTrip(trip, labelOptions)?.color; } - logDebug("Reading trip's " + trip.locations.length + " location points at " + (new Date())); + logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date()); var features = [ - location2GeojsonPoint(trip.start_loc, "start_place"), - location2GeojsonPoint(trip.end_loc, "end_place"), - ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor) + location2GeojsonPoint(trip.start_loc, 'start_place'), + location2GeojsonPoint(trip.end_loc, 'end_place'), + ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor), ]; const gj = { data: { id: gjKey, - type: "FeatureCollection", + type: 'FeatureCollection', features: features, properties: { start_ts: trip.start_ts, - end_ts: trip.end_ts - } - } - } + end_ts: trip.end_ts, + }, + }, + }; cachedGeojsons.set(gjKey, gj); return gj; } @@ -70,7 +70,14 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } -export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labelsResultMap, notesFactory, notesResultMap) { +export function populateCompositeTrips( + ctList, + showPlaces, + labelsFactory, + labelsResultMap, + notesFactory, + notesResultMap, +) { try { ctList.forEach((ct, i) => { if (showPlaces && ct.start_confirmed_place) { @@ -97,9 +104,9 @@ export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labels } const getUnprocessedInputQuery = (pipelineRange) => ({ - key: "write_ts", + key: 'write_ts', startTs: pipelineRange.end_ts - 10, - endTs: moment().unix() + 10 + endTs: moment().unix() + 10, }); function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises) { @@ -128,10 +135,10 @@ export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFac const BEMUserCache = window['cordova'].plugins.BEMUserCache; const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(labelsFactory.extractResult) + BEMUserCache.getMessagesForInterval(key, tq, true).then(labelsFactory.extractResult), ); const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + BEMUserCache.getMessagesForInterval(key, tq, true).then(notesFactory.extractResult), ); return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); } @@ -150,10 +157,12 @@ export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFacto const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(labelsFactory.extractResult) + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then( + labelsFactory.extractResult, + ), ); const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(notesFactory.extractResult), ); return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); } @@ -164,14 +173,14 @@ export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFacto * @returns a GeoJSON feature with type "Point", the given location's coordinates and the given feature type */ const location2GeojsonPoint = (locationPoint: any, featureType: string) => ({ - type: "Feature", + type: 'Feature', geometry: { - type: "Point", + type: 'Point', coordinates: locationPoint.coordinates, }, properties: { feature_type: featureType, - } + }, }); /** @@ -188,25 +197,23 @@ const locations2GeojsonTrajectory = (trip, locationList, trajectoryColor?) => { } else { // this is a multimodal trip so we sort the locations into sections by timestamp sectionsPoints = trip.sections.map((s) => - trip.locations.filter((l) => - l.ts >= s.start_ts && l.ts <= s.end_ts - ) + trip.locations.filter((l) => l.ts >= s.start_ts && l.ts <= s.end_ts), ); } return sectionsPoints.map((sectionPoints, i) => { const section = trip.sections?.[i]; return { - type: "Feature", + type: 'Feature', geometry: { - type: "LineString", + type: 'LineString', coordinates: sectionPoints.map((pt) => pt.loc.coordinates), }, style: { /* If a color was passed as arg, use it for the whole trajectory. Otherwise, use the color for the sensed mode of this section, and fall back to dark grey */ - color: trajectoryColor || getBaseModeByKey(section?.sensed_mode_str)?.color || "#333", + color: trajectoryColor || getBaseModeByKey(section?.sensed_mode_str)?.color || '#333', }, - } + }; }); -} +}; diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index 604fef227..fe324ee3f 100644 --- a/www/js/diary/useDerivedProperties.tsx +++ b/www/js/diary/useDerivedProperties.tsx @@ -1,9 +1,16 @@ -import { useMemo } from "react"; -import { useImperialConfig } from "../config/useImperialConfig"; -import { getFormattedDate, getFormattedDateAbbr, getFormattedSectionProperties, getFormattedTimeRange, getLocalTimeString, getDetectedModes, isMultiDay } from "./diaryHelper"; +import { useMemo } from 'react'; +import { useImperialConfig } from '../config/useImperialConfig'; +import { + getFormattedDate, + getFormattedDateAbbr, + getFormattedSectionProperties, + getFormattedTimeRange, + getLocalTimeString, + getDetectedModes, + isMultiDay, +} from './diaryHelper'; const useDerivedProperties = (tlEntry) => { - const imperialConfig = useImperialConfig(); return useMemo(() => { @@ -12,7 +19,7 @@ const useDerivedProperties = (tlEntry) => { const beginDt = tlEntry.start_local_dt || tlEntry.enter_local_dt; const endDt = tlEntry.end_local_dt || tlEntry.exit_local_dt; const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt); - + return { displayDate: getFormattedDate(beginFmt, endFmt), displayStartTime: getLocalTimeString(beginDt), @@ -24,8 +31,8 @@ const useDerivedProperties = (tlEntry) => { formattedSectionProperties: getFormattedSectionProperties(tlEntry, imperialConfig), distanceSuffix: imperialConfig.distanceSuffix, detectedModes: getDetectedModes(tlEntry), - } + }; }, [tlEntry, imperialConfig]); -} +}; export default useDerivedProperties; diff --git a/www/js/i18n-utils.js b/www/js/i18n-utils.js index 45cca7043..bcfb74391 100644 --- a/www/js/i18n-utils.js +++ b/www/js/i18n-utils.js @@ -2,39 +2,48 @@ import angular from 'angular'; -angular.module('emission.i18n.utils', []) -.factory("i18nUtils", function($http, Logger) { +angular.module('emission.i18n.utils', []).factory('i18nUtils', function ($http, Logger) { var iu = {}; // copy-pasted from ngCordova, and updated to promises - iu.checkFile = function(fn) { - return new Promise(function(resolve, reject) { - if ((/^\//.test(fn))) { - reject('directory cannot start with \/'); + iu.checkFile = function (fn) { + return new Promise(function (resolve, reject) { + if (/^\//.test(fn)) { + reject('directory cannot start with /'); } return $http.get(fn); }); - } + }; // The language comes in between the first and second part // the default path should end with a "/" iu.geti18nFileName = function (defaultPath, fpFirstPart, fpSecondPart) { const lang = i18next.resolvedLanguage; - const i18nPath = "i18n/"; + const i18nPath = 'i18n/'; var defaultVal = defaultPath + fpFirstPart + fpSecondPart; if (lang != 'en') { - var url = i18nPath + fpFirstPart + "-" + lang + fpSecondPart; - return $http.get(url).then( function(result){ - Logger.log(window.Logger.LEVEL_DEBUG, - "Successfully found the "+url+", result is " + JSON.stringify(result.data).substring(0,10)); - return url; - }).catch(function (err) { - Logger.log(window.Logger.LEVEL_DEBUG, - url+" file not found, loading english version, error is " + JSON.stringify(err)); - return Promise.resolve(defaultVal); - }); + var url = i18nPath + fpFirstPart + '-' + lang + fpSecondPart; + return $http + .get(url) + .then(function (result) { + Logger.log( + window.Logger.LEVEL_DEBUG, + 'Successfully found the ' + + url + + ', result is ' + + JSON.stringify(result.data).substring(0, 10), + ); + return url; + }) + .catch(function (err) { + Logger.log( + window.Logger.LEVEL_DEBUG, + url + ' file not found, loading english version, error is ' + JSON.stringify(err), + ); + return Promise.resolve(defaultVal); + }); } return Promise.resolve(defaultVal); - } + }; return iu; }); diff --git a/www/js/i18nextInit.ts b/www/js/i18nextInit.ts index a2688d66e..c2093c698 100644 --- a/www/js/i18nextInit.ts +++ b/www/js/i18nextInit.ts @@ -21,7 +21,7 @@ const mergeInTranslations = (lang, fallbackLang) => { console.warn(`Missing translation for key '${key}'`); if (__DEV__) { if (typeof value === 'string') { - lang[key] = `🌐${value}` + lang[key] = `🌐${value}`; } else if (typeof value === 'object' && typeof lang[key] === 'object') { lang[key] = {}; mergeInTranslations(lang[key], value); @@ -30,11 +30,11 @@ const mergeInTranslations = (lang, fallbackLang) => { lang[key] = value; } } else if (typeof value === 'object' && typeof lang[key] === 'object') { - mergeInTranslations(lang[key], fallbackLang[key]) + mergeInTranslations(lang[key], fallbackLang[key]); } }); return lang; -} +}; import enJson from '../i18n/en.json'; import esJson from '../../locales/es/i18n/es.json'; @@ -59,22 +59,24 @@ for (const locale of locales) { } } -i18next.use(initReactI18next) - .init({ - debug: true, - resources: langs, - lng: detectedLang, - fallbackLng: 'en' - }); +i18next.use(initReactI18next).init({ + debug: true, + resources: langs, + lng: detectedLang, + fallbackLng: 'en', +}); export default i18next; // Next, register the translations for react-native-paper-dates import { en, es, fr, it, registerTranslation } from 'react-native-paper-dates'; const rnpDatesLangs = { - en, es, fr, it, + en, + es, + fr, + it, lo: loJson['react-native-paper-dates'] /* Lao translations are not included in the library, - so we register them from 'lo.json' in /locales */ + so we register them from 'lo.json' in /locales */, }; for (const lang of Object.keys(rnpDatesLangs)) { registerTranslation(lang, rnpDatesLangs[lang]); diff --git a/www/js/main.js b/www/js/main.js index 91437a07a..2b351e2c4 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -2,29 +2,40 @@ import angular from 'angular'; -angular.module('emission.main', ['emission.main.diary', - 'emission.i18n.utils', - 'emission.splash.notifscheduler', - 'emission.main.metrics.factory', - 'emission.main.metrics.mappings', - 'emission.services']) +angular + .module('emission.main', [ + 'emission.main.diary', + 'emission.i18n.utils', + 'emission.splash.notifscheduler', + 'emission.main.metrics.factory', + 'emission.main.metrics.mappings', + 'emission.services', + ]) -.config(function($stateProvider) { - $stateProvider.state('root.main', { - url: '/main', - template: `` - }); -}) + .config(function ($stateProvider) { + $stateProvider.state('root.main', { + url: '/main', + template: ``, + }); + }) -.controller('appCtrl', function($scope, $ionicModal, $timeout) { - $scope.openNativeSettings = function() { - window.Logger.log(window.Logger.LEVEL_DEBUG, "about to open native settings"); - window.cordova.plugins.BEMLaunchNative.launch("NativeSettings", function(result) { - window.Logger.log(window.Logger.LEVEL_DEBUG, - "Successfully opened screen NativeSettings, result is "+result); - }, function(err) { - window.Logger.log(window.Logger.LEVEL_ERROR, - "Unable to open screen NativeSettings because of err "+err); - }); - } -}); + .controller('appCtrl', function ($scope, $ionicModal, $timeout) { + $scope.openNativeSettings = function () { + window.Logger.log(window.Logger.LEVEL_DEBUG, 'about to open native settings'); + window.cordova.plugins.BEMLaunchNative.launch( + 'NativeSettings', + function (result) { + window.Logger.log( + window.Logger.LEVEL_DEBUG, + 'Successfully opened screen NativeSettings, result is ' + result, + ); + }, + function (err) { + window.Logger.log( + window.Logger.LEVEL_ERROR, + 'Unable to open screen NativeSettings because of err ' + err, + ); + }, + ); + }; + }); diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js index b5ead9c82..28ef5ae9e 100644 --- a/www/js/metrics-factory.js +++ b/www/js/metrics-factory.js @@ -1,238 +1,271 @@ 'use strict'; import angular from 'angular'; -import { getBaseModeByValue } from './diary/diaryHelper' +import { getBaseModeByValue } from './diary/diaryHelper'; import { labelOptions } from './survey/multilabel/confirmHelper'; import { storageGet, storageRemove, storageSet } from './plugin/storage'; -angular.module('emission.main.metrics.factory', - ['emission.main.metrics.mappings']) +angular + .module('emission.main.metrics.factory', ['emission.main.metrics.mappings']) -.factory('FootprintHelper', function(CarbonDatasetHelper, CustomDatasetHelper) { - var fh = {}; - var highestFootprint = 0; + .factory('FootprintHelper', function (CarbonDatasetHelper, CustomDatasetHelper) { + var fh = {}; + var highestFootprint = 0; - var mtokm = function(v) { - return v / 1000; - } - fh.useCustom = false; + var mtokm = function (v) { + return v / 1000; + }; + fh.useCustom = false; - fh.setUseCustomFootprint = function () { - fh.useCustom = true; - } + fh.setUseCustomFootprint = function () { + fh.useCustom = true; + }; - fh.getFootprint = function() { - if (this.useCustom == true) { + fh.getFootprint = function () { + if (this.useCustom == true) { return CustomDatasetHelper.getCustomFootprint(); - } else { + } else { return CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); - } - } - - fh.readableFormat = function(v) { - return v > 999? Math.round(v / 1000) + 'k kg CO₂' : Math.round(v) + ' kg CO₂'; - } - fh.getFootprintForMetrics = function(userMetrics, defaultIfMissing=0) { - var footprint = fh.getFootprint(); - var result = 0; - for (var i in userMetrics) { - var mode = userMetrics[i].key; - if (mode == 'ON_FOOT') { - mode = 'WALKING'; } + }; - if (mode in footprint) { - result += footprint[mode] * mtokm(userMetrics[i].values); - } else if (mode == 'IN_VEHICLE') { - result += ((footprint['CAR'] + footprint['BUS'] + footprint["LIGHT_RAIL"] + footprint['TRAIN'] + footprint['TRAM'] + footprint['SUBWAY']) / 6) * mtokm(userMetrics[i].values); - } else { - console.warn('WARNING FootprintHelper.getFootprintFromMetrics() was requested for an unknown mode: ' + mode + " metrics JSON: " + JSON.stringify(userMetrics)); - result += defaultIfMissing * mtokm(userMetrics[i].values); - } - } - return result; - } - fh.getLowestFootprintForDistance = function(distance) { - var footprint = fh.getFootprint(); - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'WALKING' || mode == 'BICYCLING') { - // these modes aren't considered when determining the lowest carbon footprint + fh.readableFormat = function (v) { + return v > 999 ? Math.round(v / 1000) + 'k kg CO₂' : Math.round(v) + ' kg CO₂'; + }; + fh.getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { + var footprint = fh.getFootprint(); + var result = 0; + for (var i in userMetrics) { + var mode = userMetrics[i].key; + if (mode == 'ON_FOOT') { + mode = 'WALKING'; + } + + if (mode in footprint) { + result += footprint[mode] * mtokm(userMetrics[i].values); + } else if (mode == 'IN_VEHICLE') { + result += + ((footprint['CAR'] + + footprint['BUS'] + + footprint['LIGHT_RAIL'] + + footprint['TRAIN'] + + footprint['TRAM'] + + footprint['SUBWAY']) / + 6) * + mtokm(userMetrics[i].values); + } else { + console.warn( + 'WARNING FootprintHelper.getFootprintFromMetrics() was requested for an unknown mode: ' + + mode + + ' metrics JSON: ' + + JSON.stringify(userMetrics), + ); + result += defaultIfMissing * mtokm(userMetrics[i].values); + } } - else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); + return result; + }; + fh.getLowestFootprintForDistance = function (distance) { + var footprint = fh.getFootprint(); + var lowestFootprint = Number.MAX_SAFE_INTEGER; + for (var mode in footprint) { + if (mode == 'WALKING' || mode == 'BICYCLING') { + // these modes aren't considered when determining the lowest carbon footprint + } else { + lowestFootprint = Math.min(lowestFootprint, footprint[mode]); + } } - } - return lowestFootprint * mtokm(distance); - } + return lowestFootprint * mtokm(distance); + }; - fh.getHighestFootprint = function() { - if (!highestFootprint) { + fh.getHighestFootprint = function () { + if (!highestFootprint) { var footprint = fh.getFootprint(); let footprintList = []; for (var mode in footprint) { - footprintList.push(footprint[mode]); + footprintList.push(footprint[mode]); } highestFootprint = Math.max(...footprintList); - } - return highestFootprint; - } - - fh.getHighestFootprintForDistance = function(distance) { - return fh.getHighestFootprint() * mtokm(distance); - } - - var getLowestMotorizedNonAirFootprint = function(footprint, rlmCO2) { - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'AIR_OR_HSR' || mode == 'air') { - console.log("Air mode, ignoring"); } - else { - if (footprint[mode] == 0 || footprint[mode] <= rlmCO2) { - console.log("Non motorized mode or footprint <= range_limited_motorized", mode, footprint[mode], rlmCO2); + return highestFootprint; + }; + + fh.getHighestFootprintForDistance = function (distance) { + return fh.getHighestFootprint() * mtokm(distance); + }; + + var getLowestMotorizedNonAirFootprint = function (footprint, rlmCO2) { + var lowestFootprint = Number.MAX_SAFE_INTEGER; + for (var mode in footprint) { + if (mode == 'AIR_OR_HSR' || mode == 'air') { + console.log('Air mode, ignoring'); } else { + if (footprint[mode] == 0 || footprint[mode] <= rlmCO2) { + console.log( + 'Non motorized mode or footprint <= range_limited_motorized', + mode, + footprint[mode], + rlmCO2, + ); + } else { lowestFootprint = Math.min(lowestFootprint, footprint[mode]); + } } } - } - return lowestFootprint; - } - - fh.getOptimalDistanceRanges = function() { - const FIVE_KM = 5 * 1000; - const SIX_HUNDRED_KM = 600 * 1000; - if (!fh.useCustom) { + return lowestFootprint; + }; + + fh.getOptimalDistanceRanges = function () { + const FIVE_KM = 5 * 1000; + const SIX_HUNDRED_KM = 600 * 1000; + if (!fh.useCustom) { const defaultFootprint = CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(defaultFootprint); - const airFootprint = defaultFootprint["AIR_OR_HSR"]; + const airFootprint = defaultFootprint['AIR_OR_HSR']; return [ - {low: 0, high: FIVE_KM, optimal: 0}, - {low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir}, - {low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint}]; - } else { + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; + } else { // custom footprint, let's get the custom values const customFootprint = CustomDatasetHelper.getCustomFootprint(); - let airFootprint = customFootprint["air"] + let airFootprint = customFootprint['air']; if (!airFootprint) { - // 2341 BTU/PMT from - // https://tedb.ornl.gov/wp-content/uploads/2021/02/TEDB_Ed_39.pdf#page=68 - // 159.25 lb per million BTU from EIA - // https://www.eia.gov/environment/emissions/co2_vol_mass.php - // (2341 * (159.25/1000000))/(1.6*2.2) = 0.09975, rounded up a bit - console.log("No entry for air in ", customFootprint," using default"); - airFootprint = 0.1; + // 2341 BTU/PMT from + // https://tedb.ornl.gov/wp-content/uploads/2021/02/TEDB_Ed_39.pdf#page=68 + // 159.25 lb per million BTU from EIA + // https://www.eia.gov/environment/emissions/co2_vol_mass.php + // (2341 * (159.25/1000000))/(1.6*2.2) = 0.09975, rounded up a bit + console.log('No entry for air in ', customFootprint, ' using default'); + airFootprint = 0.1; } const rlm = CustomDatasetHelper.range_limited_motorized; if (!rlm) { - return [ - {low: 0, high: FIVE_KM, optimal: 0}, - {low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir}, - {low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint}]; + return [ + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; } else { - console.log("Found range_limited_motorized mode", rlm); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(customFootprint, rlm.kgCo2PerKm); - return [ - {low: 0, high: FIVE_KM, optimal: 0}, - {low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm}, - {low: rlm.range_limit_km * 1000, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir}, - {low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint}]; + console.log('Found range_limited_motorized mode', rlm); + const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint( + customFootprint, + rlm.kgCo2PerKm, + ); + return [ + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm }, + { + low: rlm.range_limit_km * 1000, + high: SIX_HUNDRED_KM, + optimal: lowestMotorizedNonAir, + }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; } - } - } - - return fh; -}) + } + }; -.factory('CalorieCal', function(METDatasetHelper, CustomDatasetHelper) { + return fh; + }) - var cc = {}; - var highestMET = 0; - var USER_DATA_KEY = "user-data"; - cc.useCustom = false; + .factory('CalorieCal', function (METDatasetHelper, CustomDatasetHelper) { + var cc = {}; + var highestMET = 0; + var USER_DATA_KEY = 'user-data'; + cc.useCustom = false; - cc.setUseCustomFootprint = function () { - cc.useCustom = true; - } + cc.setUseCustomFootprint = function () { + cc.useCustom = true; + }; - cc.getMETs = function() { - if (this.useCustom == true) { + cc.getMETs = function () { + if (this.useCustom == true) { return CustomDatasetHelper.getCustomMETs(); - } else { + } else { return METDatasetHelper.getStandardMETs(); - } - } - - cc.set = function(info) { - return storageSet(USER_DATA_KEY, info); - }; - cc.get = function() { - return storageGet(USER_DATA_KEY); - }; - cc.delete = function() { - return storageRemove(USER_DATA_KEY); - }; - Number.prototype.between = function (min, max) { - return this >= min && this <= max; - }; - cc.getHighestMET = function() { - if (!highestMET) { + } + }; + + cc.set = function (info) { + return storageSet(USER_DATA_KEY, info); + }; + cc.get = function () { + return storageGet(USER_DATA_KEY); + }; + cc.delete = function () { + return storageRemove(USER_DATA_KEY); + }; + Number.prototype.between = function (min, max) { + return this >= min && this <= max; + }; + cc.getHighestMET = function () { + if (!highestMET) { var met = cc.getMETs(); let metList = []; for (var mode in met) { - var rangeList = met[mode]; - for (var range in rangeList) { - metList.push(rangeList[range].mets); - } + var rangeList = met[mode]; + for (var range in rangeList) { + metList.push(rangeList[range].mets); + } } highestMET = Math.max(...metList); - } - return highestMET; - } - cc.getMet = function(mode, speed, defaultIfMissing) { - if (mode == 'ON_FOOT') { - console.log("CalorieCal.getMet() converted 'ON_FOOT' to 'WALKING'"); - mode = 'WALKING'; - } - let currentMETs = cc.getMETs(); - if (!currentMETs[mode]) { - console.warn("CalorieCal.getMet() Illegal mode: " + mode); - return defaultIfMissing; //So the calorie sum does not break with wrong return type - } - for (var i in currentMETs[mode]) { - if (mpstomph(speed).between(currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { - return currentMETs[mode][i].mets; - } else if (mpstomph(speed) < 0 ) { - console.log("CalorieCal.getMet() Negative speed: " + mpstomph(speed)); - return 0; } - } - } - var mpstomph = function(mps) { - return 2.23694 * mps; - } - var lbtokg = function(lb) { - return lb * 0.453592; - } - var fttocm = function(ft) { - return ft * 30.48; - } - cc.getCorrectedMet = function(met, gender, age, height, heightUnit, weight, weightUnit) { - var height = heightUnit == 0? fttocm(height) : height; - var weight = weightUnit == 0? lbtokg(weight) : weight; - if (gender == 1) { //male - var met = met*3.5/((66.4730+5.0033*height+13.7516*weight-6.7550*age)/ 1440 / 5 / weight * 1000); - return met; - } else if (gender == 0) { //female - var met = met*3.5/((655.0955+1.8496*height+9.5634*weight-4.6756*age)/ 1440 / 5 / weight * 1000); - return met; - } - } - cc.getuserCalories = function(durationInMin, met) { - return 65 * durationInMin * met; - } - cc.getCalories = function(weightInKg, durationInMin, met) { - return weightInKg * durationInMin * met; - } - return cc; -}); + return highestMET; + }; + cc.getMet = function (mode, speed, defaultIfMissing) { + if (mode == 'ON_FOOT') { + console.log("CalorieCal.getMet() converted 'ON_FOOT' to 'WALKING'"); + mode = 'WALKING'; + } + let currentMETs = cc.getMETs(); + if (!currentMETs[mode]) { + console.warn('CalorieCal.getMet() Illegal mode: ' + mode); + return defaultIfMissing; //So the calorie sum does not break with wrong return type + } + for (var i in currentMETs[mode]) { + if (mpstomph(speed).between(currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { + return currentMETs[mode][i].mets; + } else if (mpstomph(speed) < 0) { + console.log('CalorieCal.getMet() Negative speed: ' + mpstomph(speed)); + return 0; + } + } + }; + var mpstomph = function (mps) { + return 2.23694 * mps; + }; + var lbtokg = function (lb) { + return lb * 0.453592; + }; + var fttocm = function (ft) { + return ft * 30.48; + }; + cc.getCorrectedMet = function (met, gender, age, height, heightUnit, weight, weightUnit) { + var height = heightUnit == 0 ? fttocm(height) : height; + var weight = weightUnit == 0 ? lbtokg(weight) : weight; + if (gender == 1) { + //male + var met = + (met * 3.5) / + (((66.473 + 5.0033 * height + 13.7516 * weight - 6.755 * age) / 1440 / 5 / weight) * + 1000); + return met; + } else if (gender == 0) { + //female + var met = + (met * 3.5) / + (((655.0955 + 1.8496 * height + 9.5634 * weight - 4.6756 * age) / 1440 / 5 / weight) * + 1000); + return met; + } + }; + cc.getuserCalories = function (durationInMin, met) { + return 65 * durationInMin * met; + }; + cc.getCalories = function (weightInKg, durationInMin, met) { + return weightInKg * durationInMin * met; + }; + return cc; + }); diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js index 60068711d..38836a3a1 100644 --- a/www/js/metrics-mappings.js +++ b/www/js/metrics-mappings.js @@ -3,399 +3,423 @@ import { getLabelOptions } from './survey/multilabel/confirmHelper'; import { getConfig } from './config/dynamicConfig'; import { storageGet, storageSet } from './plugin/storage'; -angular.module('emission.main.metrics.mappings', ['emission.plugin.logger']) +angular + .module('emission.main.metrics.mappings', ['emission.plugin.logger']) -.service('CarbonDatasetHelper', function() { - var CARBON_DATASET_KEY = 'carbon_dataset_locale'; + .service('CarbonDatasetHelper', function () { + var CARBON_DATASET_KEY = 'carbon_dataset_locale'; - // Values are in Kg/PKm (kilograms per passenger-kilometer) - // Sources for EU values: - // - Tremod: 2017, CO2, CH4 and N2O in CO2-equivalent - // - HBEFA: 2020, CO2 (per country) - // German data uses Tremod. Other EU countries (and Switzerland) use HBEFA for car and bus, - // and Tremod for train and air (because HBEFA doesn't provide these). - // EU data is an average of the Tremod/HBEFA data for the countries listed; - // for this average the HBEFA data was used also in the German set (for car and bus). - var carbonDatasets = { - US: { - regionName: "United States", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 267/1609, - BUS: 278/1609, - LIGHT_RAIL: 120/1609, - SUBWAY: 74/1609, - TRAM: 90/1609, - TRAIN: 92/1609, - AIR_OR_HSR: 217/1609 - } - }, - EU: { // Plain average of values for the countries below (using HBEFA for car and bus, Tremod for others) - regionName: "European Union", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14515, - BUS: 0.04751, - LIGHT_RAIL: 0.064, - SUBWAY: 0.064, - TRAM: 0.064, - TRAIN: 0.048, - AIR_OR_HSR: 0.201 - } - }, - DE: { - regionName: "Germany", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.139, // Tremod (passenger car) - BUS: 0.0535, // Tremod (average city/coach) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - FR: { - regionName: "France", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13125, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04838, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - AT: { - regionName: "Austria", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14351, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04625, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - SE: { - regionName: "Sweden", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13458, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04557, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - NO: { - regionName: "Norway", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13265, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04185, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - CH: { - regionName: "Switzerland", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.17638, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04866, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - } - }; + // Values are in Kg/PKm (kilograms per passenger-kilometer) + // Sources for EU values: + // - Tremod: 2017, CO2, CH4 and N2O in CO2-equivalent + // - HBEFA: 2020, CO2 (per country) + // German data uses Tremod. Other EU countries (and Switzerland) use HBEFA for car and bus, + // and Tremod for train and air (because HBEFA doesn't provide these). + // EU data is an average of the Tremod/HBEFA data for the countries listed; + // for this average the HBEFA data was used also in the German set (for car and bus). + var carbonDatasets = { + US: { + regionName: 'United States', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 267 / 1609, + BUS: 278 / 1609, + LIGHT_RAIL: 120 / 1609, + SUBWAY: 74 / 1609, + TRAM: 90 / 1609, + TRAIN: 92 / 1609, + AIR_OR_HSR: 217 / 1609, + }, + }, + EU: { + // Plain average of values for the countries below (using HBEFA for car and bus, Tremod for others) + regionName: 'European Union', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.14515, + BUS: 0.04751, + LIGHT_RAIL: 0.064, + SUBWAY: 0.064, + TRAM: 0.064, + TRAIN: 0.048, + AIR_OR_HSR: 0.201, + }, + }, + DE: { + regionName: 'Germany', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.139, // Tremod (passenger car) + BUS: 0.0535, // Tremod (average city/coach) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + FR: { + regionName: 'France', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13125, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04838, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + AT: { + regionName: 'Austria', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.14351, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04625, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + SE: { + regionName: 'Sweden', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13458, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04557, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + NO: { + regionName: 'Norway', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13265, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04185, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + CH: { + regionName: 'Switzerland', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.17638, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04866, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + }; - var defaultCarbonDatasetCode = 'US'; - var currentCarbonDatasetCode = defaultCarbonDatasetCode; + var defaultCarbonDatasetCode = 'US'; + var currentCarbonDatasetCode = defaultCarbonDatasetCode; - // we need to call the method from within a promise in initialize() - // and using this.setCurrentCarbonDatasetLocale doesn't seem to work - var setCurrentCarbonDatasetLocale = function(localeCode) { - for (var code in carbonDatasets) { - if (code == localeCode) { - currentCarbonDatasetCode = localeCode; - break; + // we need to call the method from within a promise in initialize() + // and using this.setCurrentCarbonDatasetLocale doesn't seem to work + var setCurrentCarbonDatasetLocale = function (localeCode) { + for (var code in carbonDatasets) { + if (code == localeCode) { + currentCarbonDatasetCode = localeCode; + break; + } } - } - } + }; - this.loadCarbonDatasetLocale = function() { - return storageGet(CARBON_DATASET_KEY).then(function(localeCode) { - Logger.log("CarbonDatasetHelper.loadCarbonDatasetLocale() obtained value from storage [" + localeCode + "]"); - if (!localeCode) { - localeCode = defaultCarbonDatasetCode; - Logger.log("CarbonDatasetHelper.loadCarbonDatasetLocale() no value in storage, using [" + localeCode + "] instead"); - } - setCurrentCarbonDatasetLocale(localeCode); - }); - } + this.loadCarbonDatasetLocale = function () { + return storageGet(CARBON_DATASET_KEY).then(function (localeCode) { + Logger.log( + 'CarbonDatasetHelper.loadCarbonDatasetLocale() obtained value from storage [' + + localeCode + + ']', + ); + if (!localeCode) { + localeCode = defaultCarbonDatasetCode; + Logger.log( + 'CarbonDatasetHelper.loadCarbonDatasetLocale() no value in storage, using [' + + localeCode + + '] instead', + ); + } + setCurrentCarbonDatasetLocale(localeCode); + }); + }; - this.saveCurrentCarbonDatasetLocale = function (localeCode) { - setCurrentCarbonDatasetLocale(localeCode); - storageSet(CARBON_DATASET_KEY, currentCarbonDatasetCode); - Logger.log("CarbonDatasetHelper.saveCurrentCarbonDatasetLocale() saved value [" + currentCarbonDatasetCode + "] to storage"); - } + this.saveCurrentCarbonDatasetLocale = function (localeCode) { + setCurrentCarbonDatasetLocale(localeCode); + storageSet(CARBON_DATASET_KEY, currentCarbonDatasetCode); + Logger.log( + 'CarbonDatasetHelper.saveCurrentCarbonDatasetLocale() saved value [' + + currentCarbonDatasetCode + + '] to storage', + ); + }; - this.getCarbonDatasetOptions = function() { - var options = []; - for (var code in carbonDatasets) { - options.push({ - text: code, //carbonDatasets[code].regionName, - value: code - }); - } - return options; - }; + this.getCarbonDatasetOptions = function () { + var options = []; + for (var code in carbonDatasets) { + options.push({ + text: code, //carbonDatasets[code].regionName, + value: code, + }); + } + return options; + }; - this.getCurrentCarbonDatasetCode = function () { - return currentCarbonDatasetCode; - }; + this.getCurrentCarbonDatasetCode = function () { + return currentCarbonDatasetCode; + }; - this.getCurrentCarbonDatasetFootprint = function () { - return carbonDatasets[currentCarbonDatasetCode].footprintData; - }; -}) -.service('METDatasetHelper', function() { - var standardMETs = { - "WALKING": { - "VERY_SLOW": { - range: [0, 2.0], - mets: 2.0 - }, - "SLOW": { - range: [2.0, 2.5], - mets: 2.8 - }, - "MODERATE_0": { - range: [2.5, 2.8], - mets: 3.0 - }, - "MODERATE_1": { - range: [2.8, 3.2], - mets: 3.5 - }, - "FAST": { - range: [3.2, 3.5], - mets: 4.3 + this.getCurrentCarbonDatasetFootprint = function () { + return carbonDatasets[currentCarbonDatasetCode].footprintData; + }; + }) + .service('METDatasetHelper', function () { + var standardMETs = { + WALKING: { + VERY_SLOW: { + range: [0, 2.0], + mets: 2.0, + }, + SLOW: { + range: [2.0, 2.5], + mets: 2.8, + }, + MODERATE_0: { + range: [2.5, 2.8], + mets: 3.0, + }, + MODERATE_1: { + range: [2.8, 3.2], + mets: 3.5, + }, + FAST: { + range: [3.2, 3.5], + mets: 4.3, + }, + VERY_FAST_0: { + range: [3.5, 4.0], + mets: 5.0, + }, + 'VERY_FAST_!': { + range: [4.0, 4.5], + mets: 6.0, + }, + VERY_VERY_FAST: { + range: [4.5, 5], + mets: 7.0, + }, + SUPER_FAST: { + range: [5, 6], + mets: 8.3, + }, + RUNNING: { + range: [6, Number.MAX_VALUE], + mets: 9.8, + }, }, - "VERY_FAST_0": { - range: [3.5, 4.0], - mets: 5.0 + BICYCLING: { + VERY_VERY_SLOW: { + range: [0, 5.5], + mets: 3.5, + }, + VERY_SLOW: { + range: [5.5, 10], + mets: 5.8, + }, + SLOW: { + range: [10, 12], + mets: 6.8, + }, + MODERATE: { + range: [12, 14], + mets: 8.0, + }, + FAST: { + range: [14, 16], + mets: 10.0, + }, + VERT_FAST: { + range: [16, 19], + mets: 12.0, + }, + RACING: { + range: [20, Number.MAX_VALUE], + mets: 15.8, + }, }, - "VERY_FAST_!": { - range: [4.0, 4.5], - mets: 6.0 + UNKNOWN: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "VERY_VERY_FAST": { - range: [4.5, 5], - mets: 7.0 + IN_VEHICLE: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "SUPER_FAST": { - range: [5, 6], - mets: 8.3 + CAR: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "RUNNING": { - range: [6, Number.MAX_VALUE], - mets: 9.8 - } - }, - "BICYCLING": { - "VERY_VERY_SLOW": { - range: [0, 5.5], - mets: 3.5 + BUS: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "VERY_SLOW": { - range: [5.5, 10], - mets: 5.8 + LIGHT_RAIL: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "SLOW": { - range: [10, 12], - mets: 6.8 + TRAIN: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "MODERATE": { - range: [12, 14], - mets: 8.0 + TRAM: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "FAST": { - range: [14, 16], - mets: 10.0 + SUBWAY: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "VERT_FAST": { - range: [16, 19], - mets: 12.0 + AIR_OR_HSR: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "RACING": { - range: [20, Number.MAX_VALUE], - mets: 15.8 - } - }, - "UNKNOWN": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "IN_VEHICLE": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "CAR": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "BUS": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "LIGHT_RAIL": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "TRAIN": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "TRAM": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "SUBWAY": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "AIR_OR_HSR": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - } - } - this.getStandardMETs = function() { - return standardMETs; - } -}) -.factory('CustomDatasetHelper', function(METDatasetHelper, Logger, $ionicPlatform) { + }; + this.getStandardMETs = function () { + return standardMETs; + }; + }) + .factory('CustomDatasetHelper', function (METDatasetHelper, Logger, $ionicPlatform) { var cdh = {}; - cdh.getCustomMETs = function() { - console.log("Getting custom METs", cdh.customMETs); - return cdh.customMETs; + cdh.getCustomMETs = function () { + console.log('Getting custom METs', cdh.customMETs); + return cdh.customMETs; }; - cdh.getCustomFootprint = function() { - console.log("Getting custom footprint", cdh.customPerKmFootprint); - return cdh.customPerKmFootprint; + cdh.getCustomFootprint = function () { + console.log('Getting custom footprint', cdh.customPerKmFootprint); + return cdh.customPerKmFootprint; }; - cdh.populateCustomMETs = function() { - let standardMETs = METDatasetHelper.getStandardMETs(); - let modeOptions = cdh.inputParams["MODE"]; - let modeMETEntries = modeOptions.map((opt) => { - if (opt.met_equivalent) { - let currMET = standardMETs[opt.met_equivalent]; - return [opt.value, currMET]; - } else { - if (opt.met) { - let currMET = opt.met; - // if the user specifies a custom MET, they can't specify - // Number.MAX_VALUE since it is not valid JSON - // we assume that they specify -1 instead, and we will - // map -1 to Number.MAX_VALUE here by iterating over all the ranges - for (const rangeName in currMET) { - // console.log("Handling range ", rangeName); - currMET[rangeName].range = currMET[rangeName].range.map((i) => i == -1? Number.MAX_VALUE : i); - } - return [opt.value, currMET]; - } else { - console.warn("Did not find either met_equivalent or met for " - +opt.value+" ignoring entry"); - return undefined; - } + cdh.populateCustomMETs = function () { + let standardMETs = METDatasetHelper.getStandardMETs(); + let modeOptions = cdh.inputParams['MODE']; + let modeMETEntries = modeOptions.map((opt) => { + if (opt.met_equivalent) { + let currMET = standardMETs[opt.met_equivalent]; + return [opt.value, currMET]; + } else { + if (opt.met) { + let currMET = opt.met; + // if the user specifies a custom MET, they can't specify + // Number.MAX_VALUE since it is not valid JSON + // we assume that they specify -1 instead, and we will + // map -1 to Number.MAX_VALUE here by iterating over all the ranges + for (const rangeName in currMET) { + // console.log("Handling range ", rangeName); + currMET[rangeName].range = currMET[rangeName].range.map((i) => + i == -1 ? Number.MAX_VALUE : i, + ); } - }); - cdh.customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); - console.log("After populating, custom METs = ", cdh.customMETs); + return [opt.value, currMET]; + } else { + console.warn( + 'Did not find either met_equivalent or met for ' + opt.value + ' ignoring entry', + ); + return undefined; + } + } + }); + cdh.customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); + console.log('After populating, custom METs = ', cdh.customMETs); }; - cdh.populateCustomFootprints = function() { - let modeOptions = cdh.inputParams["MODE"]; - let modeCO2PerKm = modeOptions.map((opt) => { - if (opt.range_limit_km) { - if (cdh.range_limited_motorized) { - Logger.displayError("Found two range limited motorized options", { - first: cdh.range_limited_motorized, second: opt}); - } - cdh.range_limited_motorized = opt; - console.log("Found range limited motorized mode", cdh.range_limited_motorized); + cdh.populateCustomFootprints = function () { + let modeOptions = cdh.inputParams['MODE']; + let modeCO2PerKm = modeOptions + .map((opt) => { + if (opt.range_limit_km) { + if (cdh.range_limited_motorized) { + Logger.displayError('Found two range limited motorized options', { + first: cdh.range_limited_motorized, + second: opt, + }); } - if (angular.isDefined(opt.kgCo2PerKm)) { - return [opt.value, opt.kgCo2PerKm]; - } else { - return undefined; - } - }).filter((modeCO2) => angular.isDefined(modeCO2));; - cdh.customPerKmFootprint = Object.fromEntries(modeCO2PerKm); - console.log("After populating, custom perKmFootprint", cdh.customPerKmFootprint); - } + cdh.range_limited_motorized = opt; + console.log('Found range limited motorized mode', cdh.range_limited_motorized); + } + if (angular.isDefined(opt.kgCo2PerKm)) { + return [opt.value, opt.kgCo2PerKm]; + } else { + return undefined; + } + }) + .filter((modeCO2) => angular.isDefined(modeCO2)); + cdh.customPerKmFootprint = Object.fromEntries(modeCO2PerKm); + console.log('After populating, custom perKmFootprint', cdh.customPerKmFootprint); + }; - cdh.init = function(newConfig) { + cdh.init = function (newConfig) { try { getLabelOptions(newConfig).then((inputParams) => { - console.log("Input params = ", inputParams); + console.log('Input params = ', inputParams); cdh.inputParams = inputParams; cdh.populateCustomMETs(); cdh.populateCustomFootprints(); }); } catch (e) { setTimeout(() => { - Logger.displayError("Error in metrics-mappings while initializing custom dataset helper", e); + Logger.displayError( + 'Error in metrics-mappings while initializing custom dataset helper', + e, + ); }, 1000); } - } + }; - $ionicPlatform.ready().then(function() { + $ionicPlatform.ready().then(function () { getConfig().then((newConfig) => cdh.init(newConfig)); }); return cdh; -}); + }); diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx index ea360ce8e..2ed26ccfc 100644 --- a/www/js/metrics/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/ActiveMinutesTableCard.tsx @@ -2,24 +2,26 @@ import React, { useMemo, useState } from 'react'; import { Card, DataTable, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; -import { formatDate, formatDateRangeOfDays, secondsToMinutes, segmentDaysByWeeks } from './metricsHelper'; +import { + formatDate, + formatDateRangeOfDays, + secondsToMinutes, + segmentDaysByWeeks, +} from './metricsHelper'; import { useTranslation } from 'react-i18next'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; import { labelKeyToRichMode } from '../survey/multilabel/confirmHelper'; -type Props = { userMetrics: MetricsData } +type Props = { userMetrics: MetricsData }; const ActiveMinutesTableCard = ({ userMetrics }: Props) => { - const { colors } = useTheme(); const { t } = useTranslation(); const cumulativeTotals = useMemo(() => { if (!userMetrics?.duration) return []; const totals = {}; - ACTIVE_MODES.forEach(mode => { - const sum = userMetrics.duration.reduce((acc, day) => ( - acc + (day[`label_${mode}`] || 0) - ), 0); + ACTIVE_MODES.forEach((mode) => { + const sum = userMetrics.duration.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDateRangeOfDays(userMetrics.duration); @@ -28,30 +30,32 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { const recentWeeksActiveModesTotals = useMemo(() => { if (!userMetrics?.duration) return []; - return segmentDaysByWeeks(userMetrics.duration).reverse().map(week => { - const totals = {}; - ACTIVE_MODES.forEach(mode => { - const sum = week.reduce((acc, day) => ( - acc + (day[`label_${mode}`] || 0) - ), 0); - totals[mode] = secondsToMinutes(sum); - }) - totals['period'] = formatDateRangeOfDays(week); - return totals; - }); + return segmentDaysByWeeks(userMetrics.duration) + .reverse() + .map((week) => { + const totals = {}; + ACTIVE_MODES.forEach((mode) => { + const sum = week.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); + totals[mode] = secondsToMinutes(sum); + }); + totals['period'] = formatDateRangeOfDays(week); + return totals; + }); }, [userMetrics?.duration]); const dailyActiveModesTotals = useMemo(() => { if (!userMetrics?.duration) return []; - return userMetrics.duration.map(day => { - const totals = {}; - ACTIVE_MODES.forEach(mode => { - const sum = day[`label_${mode}`] || 0; - totals[mode] = secondsToMinutes(sum); + return userMetrics.duration + .map((day) => { + const totals = {}; + ACTIVE_MODES.forEach((mode) => { + const sum = day[`label_${mode}`] || 0; + totals[mode] = secondsToMinutes(sum); + }); + totals['period'] = formatDate(day); + return totals; }) - totals['period'] = formatDate(day); - return totals; - }).reverse(); + .reverse(); }, [userMetrics?.duration]); const allTotals = [cumulativeTotals, ...recentWeeksActiveModesTotals, ...dailyActiveModesTotals]; @@ -62,38 +66,46 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { const to = Math.min((page + 1) * itemsPerPage, allTotals.length); return ( - + + style={cardStyles.title(colors)} + /> - {ACTIVE_MODES.map((mode, i) => - {labelKeyToRichMode(mode)} - )} + {ACTIVE_MODES.map((mode, i) => ( + + {labelKeyToRichMode(mode)} + + ))} - {allTotals.slice(from, to).map((total, i) => - + {allTotals.slice(from, to).map((total, i) => ( + {total['period']} - {ACTIVE_MODES.map((mode, j) => - {total[mode]} {t('metrics.minutes')} - )} + {ACTIVE_MODES.map((mode, j) => ( + + {total[mode]} {t('metrics.minutes')} + + ))} - )} - setPage(p)} - numberOfPages={Math.ceil(allTotals.length / 5)} numberOfItemsPerPage={5} - label={`${page * 5 + 1}-${page * 5 + 5} of ${allTotals.length}`} /> + ))} + setPage(p)} + numberOfPages={Math.ceil(allTotals.length / 5)} + numberOfItemsPerPage={5} + label={`${page * 5 + 1}-${page * 5 + 5} of ${allTotals.length}`} + /> - ) -} + ); +}; export default ActiveMinutesTableCard; diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 6012cb61a..7c9bf3891 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -1,168 +1,240 @@ import React, { useState, useMemo } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; -import { formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange, segmentDaysByWeeks, isCustomLabels } from './metricsHelper'; +import { + formatDateRangeOfDays, + parseDataFromMetrics, + generateSummaryFromData, + calculatePercentChange, + segmentDaysByWeeks, + isCustomLabels, +} from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { getAngularService } from '../angular-react-helper'; import ChangeIndicator from './ChangeIndicator'; -import color from "color"; +import color from 'color'; -type Props = { userMetrics: MetricsData, aggMetrics: MetricsData } +type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { - const FootprintHelper = getAngularService("FootprintHelper"); - const { colors } = useTheme(); - const { t } = useTranslation(); - - const [emissionsChange, setEmissionsChange] = useState({}); - - const userCarbonRecords = useMemo(() => { - if(userMetrics?.distance?.length > 0) { - //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); - - //formatted data from last week, if exists (14 days ago -> 8 days ago) - let userLastWeekModeMap = {}; - let userLastWeekSummaryMap = {}; - if(lastWeekDistance && lastWeekDistance?.length == 7) { - userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); - userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); - } - - //formatted distance data from this week (7 days ago -> yesterday) - let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); - let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); - let worstDistance = userThisWeekSummaryMap.reduce((prevDistance, currModeSummary) => prevDistance + currModeSummary.values, 0); - - //setting up data to be displayed - let graphRecords = []; - - //set custon dataset, if the labels are custom - if(isCustomLabels(userThisWeekModeMap)){ - FootprintHelper.setUseCustomFootprint(); - } - - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) - let userPrevWeek; - if(userLastWeekSummaryMap[0]) { - userPrevWeek = { - low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, FootprintHelper.getHighestFootprint()) - }; - graphRecords.push({label: t('main-metrics.unlabeled'), x: userPrevWeek.high - userPrevWeek.low, y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`}) - graphRecords.push({label: t('main-metrics.labeled'), x: userPrevWeek.low, y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`}); - } - - //calculate low-high and format range for past week (7 days ago -> yesterday) - let userPastWeek = { - low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, FootprintHelper.getHighestFootprint()), - }; - graphRecords.push({label: t('main-metrics.unlabeled'), x: userPastWeek.high - userPastWeek.low, y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}) - graphRecords.push({label: t('main-metrics.labeled'), x: userPastWeek.low, y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); - if (userPrevWeek) { - let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); - setEmissionsChange(pctChange); - } - - //calculate worst-case carbon footprint - let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); - graphRecords.push({label: t('main-metrics.labeled'), x: worstCarbon, y: `${t('main-metrics.worst-case')}`}); - - return graphRecords; + const FootprintHelper = getAngularService('FootprintHelper'); + const { colors } = useTheme(); + const { t } = useTranslation(); + + const [emissionsChange, setEmissionsChange] = useState({}); + + const userCarbonRecords = useMemo(() => { + if (userMetrics?.distance?.length > 0) { + //separate data into weeks + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); + + //formatted data from last week, if exists (14 days ago -> 8 days ago) + let userLastWeekModeMap = {}; + let userLastWeekSummaryMap = {}; + if (lastWeekDistance && lastWeekDistance?.length == 7) { + userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); + userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); + } + + //formatted distance data from this week (7 days ago -> yesterday) + let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); + let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); + let worstDistance = userThisWeekSummaryMap.reduce( + (prevDistance, currModeSummary) => prevDistance + currModeSummary.values, + 0, + ); + + //setting up data to be displayed + let graphRecords = []; + + //set custon dataset, if the labels are custom + if (isCustomLabels(userThisWeekModeMap)) { + FootprintHelper.setUseCustomFootprint(); + } + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + let userPrevWeek; + if (userLastWeekSummaryMap[0]) { + userPrevWeek = { + low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics( + userLastWeekSummaryMap, + FootprintHelper.getHighestFootprint(), + ), + }; + graphRecords.push({ + label: t('main-metrics.unlabeled'), + x: userPrevWeek.high - userPrevWeek.low, + y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, + }); + graphRecords.push({ + label: t('main-metrics.labeled'), + x: userPrevWeek.low, + y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, + }); + } + + //calculate low-high and format range for past week (7 days ago -> yesterday) + let userPastWeek = { + low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics( + userThisWeekSummaryMap, + FootprintHelper.getHighestFootprint(), + ), + }; + graphRecords.push({ + label: t('main-metrics.unlabeled'), + x: userPastWeek.high - userPastWeek.low, + y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + graphRecords.push({ + label: t('main-metrics.labeled'), + x: userPastWeek.low, + y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + if (userPrevWeek) { + let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); + setEmissionsChange(pctChange); + } + + //calculate worst-case carbon footprint + let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + graphRecords.push({ + label: t('main-metrics.labeled'), + x: worstCarbon, + y: `${t('main-metrics.worst-case')}`, + }); + + return graphRecords; + } + }, [userMetrics?.distance]); + + const groupCarbonRecords = useMemo(() => { + if (aggMetrics?.distance?.length > 0) { + //separate data into weeks + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; + console.log('testing agg metrics', aggMetrics, thisWeekDistance); + + let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); + let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); + + // Issue 422: + // https://github.com/e-mission/e-mission-docs/issues/422 + let aggCarbonData = []; + for (var i in aggThisWeekSummary) { + aggCarbonData.push(aggThisWeekSummary[i]); + if (isNaN(aggCarbonData[i].values)) { + console.warn( + 'WARNING in calculating groupCarbonRecords: value is NaN for mode ' + + aggCarbonData[i].key + + ', changing to 0', + ); + aggCarbonData[i].values = 0; } - }, [userMetrics?.distance]) - - const groupCarbonRecords = useMemo(() => { - if(aggMetrics?.distance?.length > 0) - { - //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; - console.log("testing agg metrics" , aggMetrics, thisWeekDistance); - - let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, "aggregate"); - let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, "distance"); - - // Issue 422: - // https://github.com/e-mission/e-mission-docs/issues/422 - let aggCarbonData = []; - for (var i in aggThisWeekSummary) { - aggCarbonData.push(aggThisWeekSummary[i]); - if (isNaN(aggCarbonData[i].values)) { - console.warn("WARNING in calculating groupCarbonRecords: value is NaN for mode " + aggCarbonData[i].key + ", changing to 0"); - aggCarbonData[i].values = 0; - } - } - - let groupRecords = []; - - let aggCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics(aggCarbonData, FootprintHelper.getHighestFootprint()), - } - console.log("testing group past week", aggCarbon); - groupRecords.push({label: t('main-metrics.unlabeled'), x: aggCarbon.high - aggCarbon.low, y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); - groupRecords.push({label: t('main-metrics.labeled'), x: aggCarbon.low, y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); - - return groupRecords; - } - }, [aggMetrics]) - - const chartData = useMemo(() => { - let tempChartData = []; - if(userCarbonRecords?.length) { - tempChartData = tempChartData.concat(userCarbonRecords); - } - if(groupCarbonRecords?.length) { - tempChartData = tempChartData.concat(groupCarbonRecords); - } - tempChartData = tempChartData.reverse(); - console.log("testing chart data", tempChartData); - return tempChartData; - }, [userCarbonRecords, groupCarbonRecords]); - - const cardSubtitleText = useMemo(() => { - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2).reverse().flat(); - const recentEntriesRange = formatDateRangeOfDays(recentEntries); - return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; - }, [aggMetrics?.distance]); - - //hardcoded here, could be read from config at later customization? - let carbonGoals = [ {label: t('main-metrics.us-2050-goal'), value: 14, color: color(colors.warn).darken(.65).saturate(.5).rgb().toString()}, - {label: t('main-metrics.us-2030-goal'), value: 54, color: color(colors.danger).saturate(.5).rgb().toString()} ]; - let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; - - return ( - - } - style={cardStyles.title(colors)} /> - - { chartData?.length > 0 ? - - - - {t('main-metrics.us-goals-footnote')} - - - : - - - {t('metrics.chart-no-data')} - - } - - - ) -} + } + + let groupRecords = []; + + let aggCarbon = { + low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), + high: FootprintHelper.getFootprintForMetrics( + aggCarbonData, + FootprintHelper.getHighestFootprint(), + ), + }; + console.log('testing group past week', aggCarbon); + groupRecords.push({ + label: t('main-metrics.unlabeled'), + x: aggCarbon.high - aggCarbon.low, + y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + groupRecords.push({ + label: t('main-metrics.labeled'), + x: aggCarbon.low, + y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + + return groupRecords; + } + }, [aggMetrics]); + + const chartData = useMemo(() => { + let tempChartData = []; + if (userCarbonRecords?.length) { + tempChartData = tempChartData.concat(userCarbonRecords); + } + if (groupCarbonRecords?.length) { + tempChartData = tempChartData.concat(groupCarbonRecords); + } + tempChartData = tempChartData.reverse(); + console.log('testing chart data', tempChartData); + return tempChartData; + }, [userCarbonRecords, groupCarbonRecords]); + + const cardSubtitleText = useMemo(() => { + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2) + .reverse() + .flat(); + const recentEntriesRange = formatDateRangeOfDays(recentEntries); + return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; + }, [aggMetrics?.distance]); + + //hardcoded here, could be read from config at later customization? + let carbonGoals = [ + { + label: t('main-metrics.us-2050-goal'), + value: 14, + color: color(colors.warn).darken(0.65).saturate(0.5).rgb().toString(), + }, + { + label: t('main-metrics.us-2030-goal'), + value: 54, + color: color(colors.danger).saturate(0.5).rgb().toString(), + }, + ]; + let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; + + return ( + + } + style={cardStyles.title(colors)} + /> + + {chartData?.length > 0 ? ( + + + + {t('main-metrics.us-goals-footnote')} + + + ) : ( + + + {t('metrics.chart-no-data')} + + + )} + + + ); +}; export default CarbonFootprintCard; diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index 223ae709f..9f1b4490f 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -1,151 +1,189 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; -import { formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange, segmentDaysByWeeks } from './metricsHelper'; +import { + formatDateRangeOfDays, + parseDataFromMetrics, + generateSummaryFromData, + calculatePercentChange, + segmentDaysByWeeks, +} from './metricsHelper'; import { getAngularService } from '../angular-react-helper'; -type Props = { userMetrics: MetricsData, aggMetrics: MetricsData } +type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { - const { colors } = useTheme(); const { t } = useTranslation(); - const FootprintHelper = getAngularService("FootprintHelper"); + const FootprintHelper = getAngularService('FootprintHelper'); const userText = useMemo(() => { - if(userMetrics?.distance?.length > 0) { - //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); - - //formatted data from last week, if exists (14 days ago -> 8 days ago) - let userLastWeekModeMap = {}; - let userLastWeekSummaryMap = {}; - if(lastWeekDistance && lastWeekDistance?.length == 7) { - userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); - userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); - } - - //formatted distance data from this week (7 days ago -> yesterday) - let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); - let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); - let worstDistance = userThisWeekSummaryMap.reduce((prevDistance, currModeSummary) => prevDistance + currModeSummary.values, 0); - - //setting up data to be displayed - let textList = []; - - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) - if(userLastWeekSummaryMap[0]) { - let userPrevWeek = { - low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, FootprintHelper.getHighestFootprint()) - }; - const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`; - if (userPrevWeek.low == userPrevWeek.high) - textList.push({label: label, value: Math.round(userPrevWeek.low)}); - else - textList.push({label: label + '²', value: `${Math.round(userPrevWeek.low)} - ${Math.round(userPrevWeek.high)}`}); - } + if (userMetrics?.distance?.length > 0) { + //separate data into weeks + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); + + //formatted data from last week, if exists (14 days ago -> 8 days ago) + let userLastWeekModeMap = {}; + let userLastWeekSummaryMap = {}; + if (lastWeekDistance && lastWeekDistance?.length == 7) { + userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); + userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); + } - //calculate low-high and format range for past week (7 days ago -> yesterday) - let userPastWeek = { - low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, FootprintHelper.getHighestFootprint()), + //formatted distance data from this week (7 days ago -> yesterday) + let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); + let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); + let worstDistance = userThisWeekSummaryMap.reduce( + (prevDistance, currModeSummary) => prevDistance + currModeSummary.values, + 0, + ); + + //setting up data to be displayed + let textList = []; + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + if (userLastWeekSummaryMap[0]) { + let userPrevWeek = { + low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics( + userLastWeekSummaryMap, + FootprintHelper.getHighestFootprint(), + ), }; - const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`; - if (userPastWeek.low == userPastWeek.high) - textList.push({label: label, value: Math.round(userPastWeek.low)}); + const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`; + if (userPrevWeek.low == userPrevWeek.high) + textList.push({ label: label, value: Math.round(userPrevWeek.low) }); else - textList.push({label: label + '²', value: `${Math.round(userPastWeek.low)} - ${Math.round(userPastWeek.high)}`}); - - //calculate worst-case carbon footprint - let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); - textList.push({label:t('main-metrics.worst-case'), value: Math.round(worstCarbon)}); + textList.push({ + label: label + '²', + value: `${Math.round(userPrevWeek.low)} - ${Math.round(userPrevWeek.high)}`, + }); + } + + //calculate low-high and format range for past week (7 days ago -> yesterday) + let userPastWeek = { + low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics( + userThisWeekSummaryMap, + FootprintHelper.getHighestFootprint(), + ), + }; + const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`; + if (userPastWeek.low == userPastWeek.high) + textList.push({ label: label, value: Math.round(userPastWeek.low) }); + else + textList.push({ + label: label + '²', + value: `${Math.round(userPastWeek.low)} - ${Math.round(userPastWeek.high)}`, + }); - return textList; + //calculate worst-case carbon footprint + let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + textList.push({ label: t('main-metrics.worst-case'), value: Math.round(worstCarbon) }); + + return textList; } }, [userMetrics]); const groupText = useMemo(() => { - if(aggMetrics?.distance?.length > 0) - { - //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; - - let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, "aggregate"); - let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, "distance"); - - // Issue 422: - // https://github.com/e-mission/e-mission-docs/issues/422 - let aggCarbonData = []; - for (var i in aggThisWeekSummary) { - aggCarbonData.push(aggThisWeekSummary[i]); - if (isNaN(aggCarbonData[i].values)) { - console.warn("WARNING in calculating groupCarbonRecords: value is NaN for mode " + aggCarbonData[i].key + ", changing to 0"); - aggCarbonData[i].values = 0; - } - } + if (aggMetrics?.distance?.length > 0) { + //separate data into weeks + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; - let groupText = []; + let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); + let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); - let aggCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics(aggCarbonData, FootprintHelper.getHighestFootprint()), + // Issue 422: + // https://github.com/e-mission/e-mission-docs/issues/422 + let aggCarbonData = []; + for (var i in aggThisWeekSummary) { + aggCarbonData.push(aggThisWeekSummary[i]); + if (isNaN(aggCarbonData[i].values)) { + console.warn( + 'WARNING in calculating groupCarbonRecords: value is NaN for mode ' + + aggCarbonData[i].key + + ', changing to 0', + ); + aggCarbonData[i].values = 0; } - console.log("testing group past week", aggCarbon); - const label = t('main-metrics.average'); - if (aggCarbon.low == aggCarbon.high) - groupText.push({label: label, value: Math.round(aggCarbon.low)}); - else - groupText.push({label: label + '²', value: `${Math.round(aggCarbon.low)} - ${Math.round(aggCarbon.high)}`}); + } + + let groupText = []; - return groupText; + let aggCarbon = { + low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), + high: FootprintHelper.getFootprintForMetrics( + aggCarbonData, + FootprintHelper.getHighestFootprint(), + ), + }; + console.log('testing group past week', aggCarbon); + const label = t('main-metrics.average'); + if (aggCarbon.low == aggCarbon.high) + groupText.push({ label: label, value: Math.round(aggCarbon.low) }); + else + groupText.push({ + label: label + '²', + value: `${Math.round(aggCarbon.low)} - ${Math.round(aggCarbon.high)}`, + }); + + return groupText; } }, [aggMetrics]); const textEntries = useMemo(() => { - let tempText = [] - if(userText?.length){ - tempText = tempText.concat(userText); + let tempText = []; + if (userText?.length) { + tempText = tempText.concat(userText); } - if(groupText?.length) { - tempText = tempText.concat(groupText); + if (groupText?.length) { + tempText = tempText.concat(groupText); } return tempText; }, [userText, groupText]); - + const cardSubtitleText = useMemo(() => { - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2).reverse().flat(); + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2) + .reverse() + .flat(); const recentEntriesRange = formatDateRangeOfDays(recentEntries); return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; }, [aggMetrics?.distance]); return ( - - + - - { textEntries?.length > 0 && - Object.keys(textEntries).map((i) => - - {textEntries[i].label} - {textEntries[i].value + ' ' + "kg CO₂"} + style={cardStyles.title(colors)} + /> + + {textEntries?.length > 0 && + Object.keys(textEntries).map((i) => ( + + {textEntries[i].label} + {textEntries[i].value + ' ' + 'kg CO₂'} - ) - } - - {t('main-metrics.range-uncertain-footnote')} + ))} + + {t('main-metrics.range-uncertain-footnote')} - + - ) -} + ); +}; export default CarbonTextCard; diff --git a/www/js/metrics/ChangeIndicator.tsx b/www/js/metrics/ChangeIndicator.tsx index eafd3460e..a2373faf3 100644 --- a/www/js/metrics/ChangeIndicator.tsx +++ b/www/js/metrics/ChangeIndicator.tsx @@ -1,79 +1,72 @@ -import React, {useMemo} from 'react'; +import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { useTheme, Text } from "react-native-paper"; +import { useTheme, Text } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; -import colorLib from "color"; +import colorLib from 'color'; type Props = { - change: {low: number, high: number}, -} + change: { low: number; high: number }; +}; const ChangeIndicator = ({ change }) => { - const { colors } = useTheme(); - const { t } = useTranslation(); + const { colors } = useTheme(); + const { t } = useTranslation(); - const changeSign = function(changeNum) { - if(changeNum > 0) { - return "+"; - } else { - return "-"; - } - }; + const changeSign = function (changeNum) { + if (changeNum > 0) { + return '+'; + } else { + return '-'; + } + }; - const changeText = useMemo(() => { - if(change) { - let low = isFinite(change.low) ? Math.round(Math.abs(change.low)): '∞'; - let high = isFinite(change.high) ? Math.round(Math.abs(change.high)) : '∞'; - - if(Math.round(change.low) == Math.round(change.high)) - { - let text = changeSign(change.low) + low + "%"; - return text; - } else if(!(isFinite(change.low) || isFinite(change.high))) { - return ""; //if both are not finite, no information is really conveyed, so don't show - } - else { - let text = `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`; - return text; - } - } - },[change]) - - return ( - (changeText != "") ? - 0 ? colors.danger : colors.success)}> - - {changeText + '\n'} - - - {`${t("metrics.this-week")}`} - - - : - <> - ) -} + const changeText = useMemo(() => { + if (change) { + let low = isFinite(change.low) ? Math.round(Math.abs(change.low)) : '∞'; + let high = isFinite(change.high) ? Math.round(Math.abs(change.high)) : '∞'; + + if (Math.round(change.low) == Math.round(change.high)) { + let text = changeSign(change.low) + low + '%'; + return text; + } else if (!(isFinite(change.low) || isFinite(change.high))) { + return ''; //if both are not finite, no information is really conveyed, so don't show + } else { + let text = `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`; + return text; + } + } + }, [change]); + + return changeText != '' ? ( + 0 ? colors.danger : colors.success)}> + {changeText + '\n'} + {`${t('metrics.this-week')}`} + + ) : ( + <> + ); +}; const styles: any = { - text: (colors) => ({ - color: colors.onPrimary, - fontWeight: '400', - textAlign: 'center' - }), - importantText: (colors) => ({ - color: colors.onPrimary, - fontWeight: '500', - textAlign: 'center', - fontSize: 16, - }), - view: (color) => ({ - backgroundColor: colorLib(color).alpha(0.85).rgb().toString(), - padding: 2, - borderStyle: 'solid', - borderColor: colorLib(color).darken(0.4).rgb().toString(), - borderWidth: 2.5, - borderRadius: 10, - }), -} - + text: (colors) => ({ + color: colors.onPrimary, + fontWeight: '400', + textAlign: 'center', + }), + importantText: (colors) => ({ + color: colors.onPrimary, + fontWeight: '500', + textAlign: 'center', + fontSize: 16, + }), + view: (color) => ({ + backgroundColor: colorLib(color).alpha(0.85).rgb().toString(), + padding: 2, + borderStyle: 'solid', + borderColor: colorLib(color).darken(0.4).rgb().toString(), + borderWidth: 2.5, + borderRadius: 10, + }), +}; + export default ChangeIndicator; diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index 479a5f5b5..acaf9c1ed 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -1,7 +1,6 @@ - import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; @@ -10,19 +9,18 @@ import LineChart from '../components/LineChart'; import { getBaseModeByText } from '../diary/diaryHelper'; const ACTIVE_MODES = ['walk', 'bike'] as const; -type ActiveMode = typeof ACTIVE_MODES[number]; +type ActiveMode = (typeof ACTIVE_MODES)[number]; -type Props = { userMetrics: MetricsData } +type Props = { userMetrics: MetricsData }; const DailyActiveMinutesCard = ({ userMetrics }: Props) => { - const { colors } = useTheme(); const { t } = useTranslation(); const dailyActiveMinutesRecords = useMemo(() => { const records = []; const recentDays = userMetrics?.duration?.slice(-14); - recentDays?.forEach(day => { - ACTIVE_MODES.forEach(mode => { + recentDays?.forEach((day) => { + ACTIVE_MODES.forEach((mode) => { const activeSeconds = day[`label_${mode}`]; records.push({ label: labelKeyToRichMode(mode), @@ -31,34 +29,38 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { }); }); }); - return records as {label: ActiveMode, x: string, y: number}[]; + return records as { label: ActiveMode; x: string; y: number }[]; }, [userMetrics?.duration]); return ( - - + + style={cardStyles.title(colors)} + /> - { dailyActiveMinutesRecords.length ? - getBaseModeByText(l, labelOptions).color} /> - : - - + {dailyActiveMinutesRecords.length ? ( + getBaseModeByText(l, labelOptions).color} + /> + ) : ( + + {t('metrics.chart-no-data')} - } + )} ); -} +}; export default DailyActiveMinutesCard; diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 7a0f8c8bc..1727d6e49 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -1,8 +1,7 @@ - import React, { useMemo, useState } from 'react'; import { View } from 'react-native'; import { Card, Checkbox, Text, useTheme } from 'react-native-paper'; -import colorLib from "color"; +import colorLib from 'color'; import BarChart from '../components/BarChart'; import { DayOfMetricData } from './metricsTypes'; import { formatDateRangeOfDays, getLabelsForDay, getUniqueLabelsForDays } from './metricsHelper'; @@ -13,30 +12,36 @@ import { getBaseModeByKey, getBaseModeByText } from '../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; type Props = { - cardTitle: string, - userMetricsDays: DayOfMetricData[], - aggMetricsDays: DayOfMetricData[], - axisUnits: string, - unitFormatFn?: (val: number) => string|number, -} -const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, unitFormatFn}: Props) => { - - const { colors } = useTheme(); + cardTitle: string; + userMetricsDays: DayOfMetricData[]; + aggMetricsDays: DayOfMetricData[]; + axisUnits: string; + unitFormatFn?: (val: number) => string | number; +}; +const MetricsCard = ({ + cardTitle, + userMetricsDays, + aggMetricsDays, + axisUnits, + unitFormatFn, +}: Props) => { + const { colors } = useTheme(); const { t } = useTranslation(); - const [viewMode, setViewMode] = useState<'details'|'graph'>('details'); - const [populationMode, setPopulationMode] = useState<'user'|'aggregate'>('user'); + const [viewMode, setViewMode] = useState<'details' | 'graph'>('details'); + const [populationMode, setPopulationMode] = useState<'user' | 'aggregate'>('user'); const [graphIsStacked, setGraphIsStacked] = useState(true); - const metricDataDays = useMemo(() => ( - populationMode == 'user' ? userMetricsDays : aggMetricsDays - ), [populationMode, userMetricsDays, aggMetricsDays]); + const metricDataDays = useMemo( + () => (populationMode == 'user' ? userMetricsDays : aggMetricsDays), + [populationMode, userMetricsDays, aggMetricsDays], + ); // for each label on each day, create a record for the chart const chartData = useMemo(() => { if (!metricDataDays || viewMode != 'graph') return []; - const records: {label: string, x: string|number, y: string|number}[] = []; - metricDataDays.forEach(day => { + const records: { label: string; x: string | number; y: string | number }[] = []; + metricDataDays.forEach((day) => { const labels = getLabelsForDay(day); - labels.forEach(label => { + labels.forEach((label) => { const rawVal = day[`label_${label}`]; records.push({ label: labelKeyToRichMode(label), @@ -47,7 +52,7 @@ const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, uni }); // sort records (affects the order they appear in the chart legend) records.sort((a, b) => { - if (a.label == 'Unlabeled') return 1; // sort Unlabeled to the end + if (a.label == 'Unlabeled') return 1; // sort Unlabeled to the end if (b.label == 'Unlabeled') return -1; // sort Unlabeled to the end return (a.y as number) - (b.y as number); // otherwise, just sort by time }); @@ -55,8 +60,8 @@ const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, uni }, [metricDataDays, viewMode]); const cardSubtitleText = useMemo(() => { - const groupText = populationMode == 'user' ? t('main-metrics.user-totals') - : t('main-metrics.group-totals'); + const groupText = + populationMode == 'user' ? t('main-metrics.user-totals') : t('main-metrics.group-totals'); return `${groupText} (${formatDateRangeOfDays(metricDataDays)})`; }, [metricDataDays, populationMode]); @@ -67,10 +72,8 @@ const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, uni // for each label, sum up cumulative values across all days const vals = {}; - uniqueLabels.forEach(label => { - const sum = metricDataDays.reduce((acc, day) => ( - acc + (day[`label_${label}`] || 0) - ), 0); + uniqueLabels.forEach((label) => { + const sum = metricDataDays.reduce((acc, day) => acc + (day[`label_${label}`] || 0), 0); vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; }); return vals; @@ -79,55 +82,84 @@ const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, uni // Unlabelled data shows up as 'UNKNOWN' grey and mostly transparent // All other modes are colored according to their base mode const getColorForLabel = (label: string) => { - if (label == "Unlabeled") { + if (label == 'Unlabeled') { const unknownModeColor = getBaseModeByKey('UNKNOWN').color; return colorLib(unknownModeColor).alpha(0.15).rgb().string(); } return getBaseModeByText(label, labelOptions).color; - } + }; return ( - - - setViewMode(v as any)} - buttons={[{ icon: 'abacus', value: 'details' }, { icon: 'chart-bar', value: 'graph' }]} /> - setPopulationMode(p as any)} - buttons={[{ icon: 'account', value: 'user' }, { icon: 'account-group', value: 'aggregate' }]} /> + right={() => ( + + setViewMode(v as any)} + buttons={[ + { icon: 'abacus', value: 'details' }, + { icon: 'chart-bar', value: 'graph' }, + ]} + /> + setPopulationMode(p as any)} + buttons={[ + { icon: 'account', value: 'user' }, + { icon: 'account-group', value: 'aggregate' }, + ]} + /> - } - style={cardStyles.title(colors)} /> + )} + style={cardStyles.title(colors)} + /> - {viewMode=='details' && - - { Object.keys(metricSumValues).map((label, i) => + {viewMode == 'details' && ( + + {Object.keys(metricSumValues).map((label, i) => ( - {labelKeyToRichMode(label)} + {labelKeyToRichMode(label)} {metricSumValues[label] + ' ' + axisUnits} - )} - - } - {viewMode=='graph' && <> - - - Stack bars: - setGraphIsStacked(!graphIsStacked)} /> + ))} - } + )} + {viewMode == 'graph' && ( + <> + + + Stack bars: + setGraphIsStacked(!graphIsStacked)} + /> + + + )} - ) -} + ); +}; export default MetricsCard; diff --git a/www/js/metrics/MetricsDateSelect.tsx b/www/js/metrics/MetricsDateSelect.tsx index c66218453..fa1aaed3e 100644 --- a/www/js/metrics/MetricsDateSelect.tsx +++ b/www/js/metrics/MetricsDateSelect.tsx @@ -6,66 +6,78 @@ and allows the user to select a date. */ -import React, { useState, useCallback, useMemo } from "react"; -import { Text, StyleSheet } from "react-native"; -import { DatePickerModal } from "react-native-paper-dates"; -import { Divider, useTheme } from "react-native-paper"; -import i18next from "i18next"; -import { useTranslation } from "react-i18next"; -import NavBarButton from "../components/NavBarButton"; -import { DateTime } from "luxon"; +import React, { useState, useCallback, useMemo } from 'react'; +import { Text, StyleSheet } from 'react-native'; +import { DatePickerModal } from 'react-native-paper-dates'; +import { Divider, useTheme } from 'react-native-paper'; +import i18next from 'i18next'; +import { useTranslation } from 'react-i18next'; +import NavBarButton from '../components/NavBarButton'; +import { DateTime } from 'luxon'; type Props = { - dateRange: DateTime[], - setDateRange: (dateRange: [DateTime, DateTime]) => void, -} + dateRange: DateTime[]; + setDateRange: (dateRange: [DateTime, DateTime]) => void; +}; const MetricsDateSelect = ({ dateRange, setDateRange }: Props) => { - const { t } = useTranslation(); const { colors } = useTheme(); const [open, setOpen] = useState(false); const todayDate = useMemo(() => new Date(), []); - const dateRangeAsJSDate = useMemo(() => - [ dateRange[0].toJSDate(), dateRange[1].toJSDate() ], - [dateRange]); + const dateRangeAsJSDate = useMemo( + () => [dateRange[0].toJSDate(), dateRange[1].toJSDate()], + [dateRange], + ); const onDismiss = useCallback(() => { setOpen(false); }, [setOpen]); - const onChoose = useCallback(({ startDate, endDate }) => { - setOpen(false); - setDateRange([ - DateTime.fromJSDate(startDate).startOf('day'), - DateTime.fromJSDate(endDate).startOf('day') - ]); - }, [setOpen, setDateRange]); + const onChoose = useCallback( + ({ startDate, endDate }) => { + setOpen(false); + setDateRange([ + DateTime.fromJSDate(startDate).startOf('day'), + DateTime.fromJSDate(endDate).startOf('day'), + ]); + }, + [setOpen, setDateRange], + ); - return (<> - setOpen(true)}> - {dateRange[0] && (<> - {dateRange[0].toLocaleString()} - - )} - {dateRange[1]?.toLocaleString() || t('diary.today')} - - - ); + return ( + <> + setOpen(true)}> + {dateRange[0] && ( + <> + {dateRange[0].toLocaleString()} + + + )} + {dateRange[1]?.toLocaleString() || t('diary.today')} + + + + ); }; export const s = StyleSheet.create({ divider: { width: '3ch', marginHorizontal: 'auto', - } + }, }); export default MetricsDateSelect; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 450155622..d23cdd454 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,36 +1,35 @@ -import React, { useEffect, useState, useMemo } from "react"; -import { getAngularService } from "../angular-react-helper"; -import { View, ScrollView, useWindowDimensions } from "react-native"; -import { Appbar } from "react-native-paper"; -import NavBarButton from "../components/NavBarButton"; -import { useTranslation } from "react-i18next"; -import { DateTime } from "luxon"; -import { MetricsData } from "./metricsTypes"; -import MetricsCard from "./MetricsCard"; -import { formatForDisplay, useImperialConfig } from "../config/useImperialConfig"; -import MetricsDateSelect from "./MetricsDateSelect"; -import WeeklyActiveMinutesCard from "./WeeklyActiveMinutesCard"; -import { secondsToHours, secondsToMinutes } from "./metricsHelper"; -import CarbonFootprintCard from "./CarbonFootprintCard"; -import Carousel from "../components/Carousel"; -import DailyActiveMinutesCard from "./DailyActiveMinutesCard"; -import CarbonTextCard from "./CarbonTextCard"; -import ActiveMinutesTableCard from "./ActiveMinutesTableCard"; -import { getAggregateData, getMetrics } from "../commHelper"; +import React, { useEffect, useState, useMemo } from 'react'; +import { getAngularService } from '../angular-react-helper'; +import { View, ScrollView, useWindowDimensions } from 'react-native'; +import { Appbar } from 'react-native-paper'; +import NavBarButton from '../components/NavBarButton'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { MetricsData } from './metricsTypes'; +import MetricsCard from './MetricsCard'; +import { formatForDisplay, useImperialConfig } from '../config/useImperialConfig'; +import MetricsDateSelect from './MetricsDateSelect'; +import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; +import { secondsToHours, secondsToMinutes } from './metricsHelper'; +import CarbonFootprintCard from './CarbonFootprintCard'; +import Carousel from '../components/Carousel'; +import DailyActiveMinutesCard from './DailyActiveMinutesCard'; +import CarbonTextCard from './CarbonTextCard'; +import ActiveMinutesTableCard from './ActiveMinutesTableCard'; +import { getAggregateData, getMetrics } from '../commHelper'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; -async function fetchMetricsFromServer(type: 'user'|'aggregate', dateRange: DateTime[]) { +async function fetchMetricsFromServer(type: 'user' | 'aggregate', dateRange: DateTime[]) { const query = { freq: 'D', start_time: dateRange[0].toSeconds(), end_time: dateRange[1].toSeconds(), metric_list: METRIC_LIST, - is_return_aggregate: (type == 'aggregate'), - } - if (type == 'user') - return getMetrics('timestamp', query); - return getAggregateData("result/metrics/timestamp", query); + is_return_aggregate: type == 'aggregate', + }; + if (type == 'user') return getMetrics('timestamp', query); + return getAggregateData('result/metrics/timestamp', query); } function getLastTwoWeeksDtRange() { @@ -41,10 +40,9 @@ function getLastTwoWeeksDtRange() { } const MetricsTab = () => { - const { t } = useTranslation(); - const { getFormattedSpeed, speedSuffix, - getFormattedDistance, distanceSuffix } = useImperialConfig(); + const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = + useImperialConfig(); const [dateRange, setDateRange] = useState(getLastTwoWeeksDtRange); const [aggMetrics, setAggMetrics] = useState(null); @@ -55,11 +53,11 @@ const MetricsTab = () => { loadMetricsForPopulation('aggregate', dateRange); }, [dateRange]); - async function loadMetricsForPopulation(population: 'user'|'aggregate', dateRange: DateTime[]) { + async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { const serverResponse = await fetchMetricsFromServer(population, dateRange); - console.debug("Got metrics = ", serverResponse); + console.debug('Got metrics = ', serverResponse); const metrics = {}; - const dataKey = (population == 'user') ? 'user_metrics' : 'aggregate_metrics'; + const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; METRIC_LIST.forEach((metricName, i) => { metrics[metricName] = serverResponse[dataKey][i]; }); @@ -75,49 +73,60 @@ const MetricsTab = () => { } const { width: windowWidth } = useWindowDimensions(); - const cardWidth = windowWidth * .88; + const cardWidth = windowWidth * 0.88; - return (<> - - - - - - - - - - - - - - - - - - - - {/* + + + + + + + + + + + + + + + + + + + + {/* */} - - - ); -} + + + + ); +}; export const cardMargin = 10; @@ -134,7 +143,7 @@ export const cardStyles: any = { titleText: (colors) => ({ color: colors.onPrimary, fontWeight: '500', - textAlign: 'center' + textAlign: 'center', }), subtitleText: { fontSize: 13, @@ -146,7 +155,7 @@ export const cardStyles: any = { padding: 8, paddingBottom: 12, flex: 1, - } -} + }, +}; export default MetricsTab; diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index 99bf9d425..387ebc79d 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -1,7 +1,6 @@ - import React, { useMemo, useState } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardMargin, cardStyles } from './MetricsTab'; import { formatDateRangeOfDays, segmentDaysByWeeks } from './metricsHelper'; @@ -11,68 +10,70 @@ import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHe import { getBaseModeByText } from '../diary/diaryHelper'; export const ACTIVE_MODES = ['walk', 'bike'] as const; -type ActiveMode = typeof ACTIVE_MODES[number]; +type ActiveMode = (typeof ACTIVE_MODES)[number]; -type Props = { userMetrics: MetricsData } +type Props = { userMetrics: MetricsData }; const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { - const { colors } = useTheme(); const { t } = useTranslation(); - const weeklyActiveMinutesRecords = useMemo(() => { const records = []; - const [ recentWeek, prevWeek ] = segmentDaysByWeeks(userMetrics?.duration, 2); - ACTIVE_MODES.forEach(mode => { - const prevSum = prevWeek?.reduce((acc, day) => ( - acc + (day[`label_${mode}`] || 0) - ), 0); + const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, 2); + ACTIVE_MODES.forEach((mode) => { + const prevSum = prevWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); if (prevSum) { const xLabel = `Previous Week\n(${formatDateRangeOfDays(prevWeek)})`; // TODO: i18n - records.push({label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60}); + records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60 }); } - const recentSum = recentWeek?.reduce((acc, day) => ( - acc + (day[`label_${mode}`] || 0) - ), 0); + const recentSum = recentWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); if (recentSum) { const xLabel = `Past Week\n(${formatDateRangeOfDays(recentWeek)})`; // TODO: i18n - records.push({label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60}); + records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 }); } }); - return records as {label: ActiveMode, x: string, y: number}[]; + return records as { label: ActiveMode; x: string; y: number }[]; }, [userMetrics?.duration]); return ( - - + + style={cardStyles.title(colors)} + /> - { weeklyActiveMinutesRecords.length ? - - getBaseModeByText(l, labelOptions).color} /> - + {weeklyActiveMinutesRecords.length ? ( + + getBaseModeByText(l, labelOptions).color} + /> + {t('main-metrics.weekly-goal-footnote')} - : - - + ) : ( + + {t('metrics.chart-no-data')} - } + )} - ) -} + ); +}; export default WeeklyActiveMinutesCard; diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index d1cd435d4..3df71cdc1 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,12 +1,12 @@ -import { DateTime } from "luxon"; -import { formatForDisplay } from "../config/useImperialConfig"; -import { DayOfMetricData } from "./metricsTypes"; +import { DateTime } from 'luxon'; +import { formatForDisplay } from '../config/useImperialConfig'; +import { DayOfMetricData } from './metricsTypes'; import moment from 'moment'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; - metricDataDays.forEach(e => { - Object.keys(e).forEach(k => { + metricDataDays.forEach((e) => { + Object.keys(e).forEach((k) => { if (k.startsWith('label_')) { const label = k.substring(6); // remove 'label_' prefix leaving just the mode label if (!uniqueLabels.includes(label)) uniqueLabels.push(label); @@ -16,42 +16,39 @@ export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { return uniqueLabels; } -export const getLabelsForDay = (metricDataDay: DayOfMetricData) => ( +export const getLabelsForDay = (metricDataDay: DayOfMetricData) => Object.keys(metricDataDay).reduce((acc, k) => { if (k.startsWith('label_')) { acc.push(k.substring(6)); // remove 'label_' prefix leaving just the mode label } return acc; - }, [] as string[]) -); + }, [] as string[]); -export const secondsToMinutes = (seconds: number) => - formatForDisplay(seconds / 60); +export const secondsToMinutes = (seconds: number) => formatForDisplay(seconds / 60); -export const secondsToHours = (seconds: number) => - formatForDisplay(seconds / 3600); +export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 3600); // segments metricsDays into weeks, with the most recent week first -export function segmentDaysByWeeks (days: DayOfMetricData[], nWeeks?: number) { +export function segmentDaysByWeeks(days: DayOfMetricData[], nWeeks?: number) { const weeks: DayOfMetricData[][] = []; for (let i = days?.length - 1; i >= 0; i -= 7) { weeks.push(days.slice(Math.max(i - 6, 0), i + 1)); } if (nWeeks) return weeks.slice(0, nWeeks); return weeks; -}; +} export function formatDate(day: DayOfMetricData) { const dt = DateTime.fromISO(day.fmt_time, { zone: 'utc' }); - return dt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); + return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); } export function formatDateRangeOfDays(days: DayOfMetricData[]) { if (!days?.length) return ''; const firstDayDt = DateTime.fromISO(days[0].fmt_time, { zone: 'utc' }); const lastDayDt = DateTime.fromISO(days[days.length - 1].fmt_time, { zone: 'utc' }); - const firstDay = firstDayDt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); - const lastDay = lastDayDt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); + const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); + const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); return `${firstDay} - ${lastDay}`; } @@ -61,50 +58,49 @@ export function formatDateRangeOfDays(days: DayOfMetricData[]) { const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; /* -* metric2val is a function that takes a metric entry and a field and returns -* the appropriate value. -* for regular data (user-specific), this will return the field value -* for avg data (aggregate), this will return the field value/nUsers -*/ -const metricToValue = function(population:'user'|'aggreagte', metric, field) { - if(population == "user"){ + * metric2val is a function that takes a metric entry and a field and returns + * the appropriate value. + * for regular data (user-specific), this will return the field value + * for avg data (aggregate), this will return the field value/nUsers + */ +const metricToValue = function (population: 'user' | 'aggreagte', metric, field) { + if (population == 'user') { return metric[field]; + } else { + return metric[field] / metric.nUsers; } - else{ - return metric[field]/metric.nUsers; - } -} +}; //testing agains global list of what is "on foot" //returns true | false -const isOnFoot = function(mode: string) { +const isOnFoot = function (mode: string) { for (let ped_mode in ON_FOOT_MODES) { if (mode === ped_mode) { return true; } } return false; -} +}; //from two weeks fo low and high values, calculates low and high change export function calculatePercentChange(pastWeekRange, previousWeekRange) { let greaterLesserPct = { - low: (pastWeekRange.low/previousWeekRange.low) * 100 - 100, - high: (pastWeekRange.high/previousWeekRange.high) * 100 - 100, - } + low: (pastWeekRange.low / previousWeekRange.low) * 100 - 100, + high: (pastWeekRange.high / previousWeekRange.high) * 100 - 100, + }; return greaterLesserPct; } export function parseDataFromMetrics(metrics, population) { - console.log("Called parseDataFromMetrics on ", metrics); + console.log('Called parseDataFromMetrics on ', metrics); let mode_bins = {}; - metrics?.forEach(function(metric) { + metrics?.forEach(function (metric) { let onFootVal = 0; for (let field in metric) { /*For modes inferred from sensor data, we check if the string is all upper case by converting it to upper case and seeing if it is changed*/ - if(field == field.toUpperCase()) { + if (field == field.toUpperCase()) { /*sum all possible on foot modes: see https://github.com/e-mission/e-mission-docs/issues/422 */ if (isOnFoot(field)) { onFootVal += metricToValue(population, metric, field); @@ -114,49 +110,56 @@ export function parseDataFromMetrics(metrics, population) { mode_bins[field] = []; } //for all except onFoot, add to bin - could discover mult onFoot modes - if (field != "ON_FOOT") { - mode_bins[field].push([metric.ts, metricToValue(population, metric, field), metric.fmt_time]); + if (field != 'ON_FOOT') { + mode_bins[field].push([ + metric.ts, + metricToValue(population, metric, field), + metric.fmt_time, + ]); } } //this section handles user lables, assuming 'label_' prefix - if(field.startsWith('label_')) { + if (field.startsWith('label_')) { let actualMode = field.slice(6, field.length); //remove prefix - console.log("Mapped field "+field+" to mode "+actualMode); + console.log('Mapped field ' + field + ' to mode ' + actualMode); if (!(actualMode in mode_bins)) { - mode_bins[actualMode] = []; + mode_bins[actualMode] = []; } - mode_bins[actualMode].push([metric.ts, Math.round(metricToValue(population, metric, field)), moment(metric.fmt_time).format()]); + mode_bins[actualMode].push([ + metric.ts, + Math.round(metricToValue(population, metric, field)), + moment(metric.fmt_time).format(), + ]); } } //handle the ON_FOOT modes once all have been summed - if ("ON_FOOT" in mode_bins) { - mode_bins["ON_FOOT"].push([metric.ts, Math.round(onFootVal), metric.fmt_time]); + if ('ON_FOOT' in mode_bins) { + mode_bins['ON_FOOT'].push([metric.ts, Math.round(onFootVal), metric.fmt_time]); } }); let return_val = []; for (let mode in mode_bins) { - return_val.push({key: mode, values: mode_bins[mode]}); + return_val.push({ key: mode, values: mode_bins[mode] }); } return return_val; } export function generateSummaryFromData(modeMap, metric) { - console.log("Invoked getSummaryDataRaw on ", modeMap, "with", metric); + console.log('Invoked getSummaryDataRaw on ', modeMap, 'with', metric); let summaryMap = []; - for (let i=0; i < modeMap.length; i++){ + for (let i = 0; i < modeMap.length; i++) { let summary = {}; - summary['key'] = modeMap[i].key; + summary['key'] = modeMap[i].key; let sumVals = 0; - for (let j = 0; j < modeMap[i].values.length; j++) - { + for (let j = 0; j < modeMap[i].values.length; j++) { sumVals += modeMap[i].values[j][1]; //2nd item of array is value } - if (metric === 'mean_speed'){ + if (metric === 'mean_speed') { //we care about avg speed, sum for other metrics summary['values'] = Math.round(sumVals / modeMap[i].values.length); } else { @@ -170,13 +173,13 @@ export function generateSummaryFromData(modeMap, metric) { } /* -* We use the results to determine whether these results are from custom -* labels or from the automatically sensed labels. Automatically sensedV -* labels are in all caps, custom labels are prefixed by label, but have had -* the label_prefix stripped out before this. Results should have either all -* sensed labels or all custom labels. -*/ -export const isCustomLabels = function(modeMap) { + * We use the results to determine whether these results are from custom + * labels or from the automatically sensed labels. Automatically sensedV + * labels are in all caps, custom labels are prefixed by label, but have had + * the label_prefix stripped out before this. Results should have either all + * sensed labels or all custom labels. + */ +export const isCustomLabels = function (modeMap) { const isSensed = (mode) => mode == mode.toUpperCase(); const isCustom = (mode) => mode == mode.toLowerCase(); const metricSummaryChecksCustom = []; @@ -185,28 +188,34 @@ export const isCustomLabels = function(modeMap) { const distanceKeys = modeMap.map((e) => e.key); const isSensedKeys = distanceKeys.map(isSensed); const isCustomKeys = distanceKeys.map(isCustom); - console.log("Checking metric keys", distanceKeys, " sensed ", isSensedKeys, - " custom ", isCustomKeys); + console.log( + 'Checking metric keys', + distanceKeys, + ' sensed ', + isSensedKeys, + ' custom ', + isCustomKeys, + ); const isAllCustomForMetric = isAllCustom(isSensedKeys, isCustomKeys); metricSummaryChecksSensed.push(!isAllCustomForMetric); metricSummaryChecksCustom.push(isAllCustomForMetric); - console.log("overall custom/not results for each metric = ", metricSummaryChecksCustom); + console.log('overall custom/not results for each metric = ', metricSummaryChecksCustom); return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom); -} +}; -const isAllCustom = function(isSensedKeys, isCustomKeys) { - const allSensed = isSensedKeys.reduce((a, b) => a && b, true); - const anySensed = isSensedKeys.reduce((a, b) => a || b, false); - const allCustom = isCustomKeys.reduce((a, b) => a && b, true); - const anyCustom = isCustomKeys.reduce((a, b) => a || b, false); - if ((allSensed && !anyCustom)) { - return false; // sensed, not custom - } - if ((!anySensed && allCustom)) { - return true; // custom, not sensed; false implies that the other option is true - } - // Logger.displayError("Mixed entries that combine sensed and custom labels", - // "Please report to your program admin"); - return undefined; -} \ No newline at end of file +const isAllCustom = function (isSensedKeys, isCustomKeys) { + const allSensed = isSensedKeys.reduce((a, b) => a && b, true); + const anySensed = isSensedKeys.reduce((a, b) => a || b, false); + const allCustom = isCustomKeys.reduce((a, b) => a && b, true); + const anyCustom = isCustomKeys.reduce((a, b) => a || b, false); + if (allSensed && !anyCustom) { + return false; // sensed, not custom + } + if (!anySensed && allCustom) { + return true; // custom, not sensed; false implies that the other option is true + } + // Logger.displayError("Mixed entries that combine sensed and custom labels", + // "Please report to your program admin"); + return undefined; +}; diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index d51c98b3a..cfe4444a3 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -1,14 +1,14 @@ -import { METRIC_LIST } from "./MetricsTab" +import { METRIC_LIST } from './MetricsTab'; -type MetricName = typeof METRIC_LIST[number]; -type LabelProps = {[k in `label_${string}`]?: number}; // label_, where could be anything +type MetricName = (typeof METRIC_LIST)[number]; +type LabelProps = { [k in `label_${string}`]?: number }; // label_, where could be anything export type DayOfMetricData = LabelProps & { - ts: number, - fmt_time: string, - nUsers: number, - local_dt: {[k: string]: any}, // TODO type datetime obj -} + ts: number; + fmt_time: string; + nUsers: number; + local_dt: { [k: string]: any }; // TODO type datetime obj +}; export type MetricsData = { - [key in MetricName]: DayOfMetricData[] -} + [key in MetricName]: DayOfMetricData[]; +}; diff --git a/www/js/ngApp.js b/www/js/ngApp.js index f82c53482..228c2a989 100644 --- a/www/js/ngApp.js +++ b/www/js/ngApp.js @@ -32,51 +32,61 @@ import App from './App'; import { getTheme } from './appTheme'; import { SafeAreaView } from 'react-native-safe-area-context'; -angular.module('emission', ['ionic', 'jm.i18next', - 'emission.controllers','emission.services', 'emission.plugin.logger', - 'emission.splash.referral', 'emission.services.email', - 'emission.main', 'pascalprecht.translate', 'LocalStorageModule']) +angular + .module('emission', [ + 'ionic', + 'jm.i18next', + 'emission.controllers', + 'emission.services', + 'emission.plugin.logger', + 'emission.splash.referral', + 'emission.services.email', + 'emission.main', + 'pascalprecht.translate', + 'LocalStorageModule', + ]) -.run(function($ionicPlatform, $rootScope, $http, Logger, localStorageService) { - console.log("Starting run"); - // ensure that plugin events are delivered after the ionicPlatform is ready - // https://github.com/katzer/cordova-plugin-local-notifications#launch-details - window.skipLocalNotificationReady = true; - // alert("Starting run"); - $ionicPlatform.ready(function() { - // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard - // for form inputs) - Logger.log("ionicPlatform is ready"); + .run(function ($ionicPlatform, $rootScope, $http, Logger, localStorageService) { + console.log('Starting run'); + // ensure that plugin events are delivered after the ionicPlatform is ready + // https://github.com/katzer/cordova-plugin-local-notifications#launch-details + window.skipLocalNotificationReady = true; + // alert("Starting run"); + $ionicPlatform.ready(function () { + // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard + // for form inputs) + Logger.log('ionicPlatform is ready'); - if (window.StatusBar) { - // org.apache.cordova.statusbar required - StatusBar.styleDefault(); - } - cordova.plugin.http.setDataSerializer('json'); - // backwards compat hack to be consistent with - // https://github.com/e-mission/e-mission-data-collection/commit/92f41145e58c49e3145a9222a78d1ccacd16d2a7#diff-962320754eba07107ecd413954411f725c98fd31cddbb5defd4a542d1607e5a3R160 - // remove during migration to react native - localStorageService.remove("OP_GEOFENCE_CFG"); - cordova.plugins.BEMUserCache.removeLocalStorage("OP_GEOFENCE_CFG"); + if (window.StatusBar) { + // org.apache.cordova.statusbar required + StatusBar.styleDefault(); + } + cordova.plugin.http.setDataSerializer('json'); + // backwards compat hack to be consistent with + // https://github.com/e-mission/e-mission-data-collection/commit/92f41145e58c49e3145a9222a78d1ccacd16d2a7#diff-962320754eba07107ecd413954411f725c98fd31cddbb5defd4a542d1607e5a3R160 + // remove during migration to react native + localStorageService.remove('OP_GEOFENCE_CFG'); + cordova.plugins.BEMUserCache.removeLocalStorage('OP_GEOFENCE_CFG'); - const rootEl = document.getElementById('appRoot'); - const reactRoot = createRoot(rootEl); + const rootEl = document.getElementById('appRoot'); + const reactRoot = createRoot(rootEl); - const theme = getTheme(); + const theme = getTheme(); - reactRoot.render( - - - - - - - ); + + + + + , + ); + }); + console.log('Ending run'); }); - console.log("Ending run"); -}); diff --git a/www/js/onboarding/OnboardingStack.tsx b/www/js/onboarding/OnboardingStack.tsx index c547fd074..cfe0b5c6a 100644 --- a/www/js/onboarding/OnboardingStack.tsx +++ b/www/js/onboarding/OnboardingStack.tsx @@ -1,16 +1,15 @@ -import React, { useContext } from "react"; -import { StyleSheet } from "react-native"; -import { AppContext } from "../App"; -import WelcomePage from "./WelcomePage"; -import ProtocolPage from "./ProtocolPage"; -import SurveyPage from "./SurveyPage"; -import SaveQrPage from "./SaveQrPage"; -import SummaryPage from "./SummaryPage"; -import { OnboardingRoute } from "./onboardingHelper"; -import { displayErrorMsg } from "../plugin/logger"; +import React, { useContext } from 'react'; +import { StyleSheet } from 'react-native'; +import { AppContext } from '../App'; +import WelcomePage from './WelcomePage'; +import ProtocolPage from './ProtocolPage'; +import SurveyPage from './SurveyPage'; +import SaveQrPage from './SaveQrPage'; +import SummaryPage from './SummaryPage'; +import { OnboardingRoute } from './onboardingHelper'; +import { displayErrorMsg } from '../plugin/logger'; const OnboardingStack = () => { - const { onboardingState } = useContext(AppContext); console.debug('onboardingState in OnboardingStack', onboardingState); @@ -28,7 +27,7 @@ const OnboardingStack = () => { } else { displayErrorMsg('OnboardingStack: unknown route', onboardingState.route); } -} +}; export const onboardingStyles = StyleSheet.create({ page: { @@ -50,4 +49,4 @@ export const onboardingStyles = StyleSheet.create({ }, }); -export default OnboardingStack +export default OnboardingStack; diff --git a/www/js/onboarding/PrivacyPolicy.tsx b/www/js/onboarding/PrivacyPolicy.tsx index f237e359c..bfd884cac 100644 --- a/www/js/onboarding/PrivacyPolicy.tsx +++ b/www/js/onboarding/PrivacyPolicy.tsx @@ -1,59 +1,73 @@ -import React, { useMemo } from "react"; -import { StyleSheet, Text } from "react-native"; -import { useTranslation } from "react-i18next"; -import useAppConfig from "../useAppConfig"; -import { getTemplateText } from "./StudySummary"; +import React, { useMemo } from 'react'; +import { StyleSheet, Text } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import useAppConfig from '../useAppConfig'; +import { getTemplateText } from './StudySummary'; const PrivacyPolicy = () => { - const { t, i18n } = useTranslation(); - const appConfig = useAppConfig(); + const { t, i18n } = useTranslation(); + const appConfig = useAppConfig(); - let opCodeText; - if(appConfig?.opcode?.autogen) { - opCodeText = {t('consent-text.opcode.autogen')}; - - } else { - opCodeText = {t('consent-text.opcode.not-autogen')}; - } + let opCodeText; + if (appConfig?.opcode?.autogen) { + opCodeText = {t('consent-text.opcode.autogen')}; + } else { + opCodeText = {t('consent-text.opcode.not-autogen')}; + } - let yourRightsText; - if(appConfig?.intro?.app_required) { - yourRightsText = {t('consent-text.rights.app-required', {program_admin_contact: appConfig?.intro?.program_admin_contact})}; + let yourRightsText; + if (appConfig?.intro?.app_required) { + yourRightsText = ( + + {t('consent-text.rights.app-required', { + program_admin_contact: appConfig?.intro?.program_admin_contact, + })} + + ); + } else { + yourRightsText = ( + + {t('consent-text.rights.app-not-required', { + program_or_study: appConfig?.intro?.program_or_study, + })} + + ); + } - } else { - yourRightsText = {t('consent-text.rights.app-not-required', {program_or_study: appConfig?.intro?.program_or_study})}; - } + const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); - const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); + return ( + <> + {t('consent-text.title')} + {t('consent-text.introduction.header')} + {templateText?.short_textual_description} + {'\n'} + {t('consent-text.introduction.what-is-openpath')} + {'\n'} + + {t('consent-text.introduction.what-is-NREL', { + program_or_study: appConfig?.intro?.program_or_study, + })} + + {'\n'} + {t('consent-text.introduction.if-disagree')} + {'\n'} - return ( - <> - {t('consent-text.title')} - {t('consent-text.introduction.header')} - {templateText?.short_textual_description} - {'\n'} - {t('consent-text.introduction.what-is-openpath')} - {'\n'} - {t('consent-text.introduction.what-is-NREL', {program_or_study: appConfig?.intro?.program_or_study})} - {'\n'} - {t('consent-text.introduction.if-disagree')} - {'\n'} + {t('consent-text.why.header')} + {templateText?.why_we_collect} + {'\n'} - {t('consent-text.why.header')} - {templateText?.why_we_collect} - {'\n'} - - {t('consent-text.what.header')} - {t('consent-text.what.no-pii')} - {'\n'} - {t('consent-text.what.phone-sensor')} - {'\n'} - {t('consent-text.what.labeling')} - {'\n'} - {t('consent-text.what.demographics')} - {'\n'} - {t('consent-text.what.on-nrel-site')} - {/* Linking is broken, look into enabling after migration + {t('consent-text.what.header')} + {t('consent-text.what.no-pii')} + {'\n'} + {t('consent-text.what.phone-sensor')} + {'\n'} + {t('consent-text.what.labeling')} + {'\n'} + {t('consent-text.what.demographics')} + {'\n'} + {t('consent-text.what.on-nrel-site')} + {/* Linking is broken, look into enabling after migration {t('consent-text.what.open-source-data')} { {' '}https://github.com/e-mission/em-public-dashboard.git{' '} */} - {'\n'} + {'\n'} - {t('consent-text.opcode.header')} - {opCodeText} - {'\n'} + {t('consent-text.opcode.header')} + {opCodeText} + {'\n'} - {t('consent-text.who-sees.header')} - {t('consent-text.who-sees.public-dash')} - {'\n'} - {t('consent-text.who-sees.individual-info')} - {'\n'} - {t('consent-text.who-sees.program-admins', { - deployment_partner_name: appConfig?.intro?.deployment_partner_name, - raw_data_use: templateText?.raw_data_use})} - {t('consent-text.who-sees.nrel-devs')} - {'\n'} - {t('consent-text.who-sees.TSDC-info')} - {/* Linking is broken, look into enabling after migration + {t('consent-text.who-sees.header')} + {t('consent-text.who-sees.public-dash')} + {'\n'} + {t('consent-text.who-sees.individual-info')} + {'\n'} + + {t('consent-text.who-sees.program-admins', { + deployment_partner_name: appConfig?.intro?.deployment_partner_name, + raw_data_use: templateText?.raw_data_use, + })} + + {t('consent-text.who-sees.nrel-devs')} + {'\n'} + + {t('consent-text.who-sees.TSDC-info')} + {/* Linking is broken, look into enabling after migration { @@ -121,15 +139,16 @@ const PrivacyPolicy = () => { }}> {t('consent-text.who-sees.fact-sheet')} */} - {t('consent-text.who-sees.on-nrel-site')} - - {'\n'} + {t('consent-text.who-sees.on-nrel-site')} + + {'\n'} - {t('consent-text.rights.header')} - {yourRightsText} - {'\n'} - {t('consent-text.rights.destroy-data-pt1')} - {/* Linking is broken, look into enabling after migration + {t('consent-text.rights.header')} + {yourRightsText} + {'\n'} + + {t('consent-text.rights.destroy-data-pt1')} + {/* Linking is broken, look into enabling after migration { @@ -137,41 +156,49 @@ const PrivacyPolicy = () => { }}> k.shankari@nrel.gov */} - (k.shankari@nrel.gov) - {t('consent-text.rights.destroy-data-pt2')} - - {'\n'} - - {t('consent-text.questions.header')} - {t('consent-text.questions.for-questions', {program_admin_contact: appConfig?.intro?.program_admin_contact})} - {'\n'} - - {t('consent-text.consent.header')} - {t('consent-text.consent.press-button-to-consent', {program_or_study: appConfig?.intro?.program_or_study})} - - ) -} + (k.shankari@nrel.gov) + {t('consent-text.rights.destroy-data-pt2')} + + {'\n'} + + {t('consent-text.questions.header')} + + {t('consent-text.questions.for-questions', { + program_admin_contact: appConfig?.intro?.program_admin_contact, + })} + + {'\n'} + + {t('consent-text.consent.header')} + + {t('consent-text.consent.press-button-to-consent', { + program_or_study: appConfig?.intro?.program_or_study, + })} + + + ); +}; const styles = StyleSheet.create({ - hyperlinkStyle: (linkColor) => ({ - color: linkColor - }), - text: { - fontSize: 14, - }, - header: { - fontWeight: "bold", - fontSize: 18 - }, - title: { - fontWeight: "bold", - fontSize: 22, - paddingBottom: 10, - textAlign: "center" - }, - divider: { - marginVertical: 10 - } - }); + hyperlinkStyle: (linkColor) => ({ + color: linkColor, + }), + text: { + fontSize: 14, + }, + header: { + fontWeight: 'bold', + fontSize: 18, + }, + title: { + fontWeight: 'bold', + fontSize: 22, + paddingBottom: 10, + textAlign: 'center', + }, + divider: { + marginVertical: 10, + }, +}); export default PrivacyPolicy; diff --git a/www/js/onboarding/ProtocolPage.tsx b/www/js/onboarding/ProtocolPage.tsx index a047a0aae..1f096ecc9 100644 --- a/www/js/onboarding/ProtocolPage.tsx +++ b/www/js/onboarding/ProtocolPage.tsx @@ -10,7 +10,6 @@ import { markConsented } from '../splash/startprefs'; import { setProtocolDone } from './onboardingHelper'; const ProtocolPage = () => { - const { t } = useTranslation(); const context = useContext(AppContext); const { refreshOnboardingState } = context; @@ -18,25 +17,33 @@ const ProtocolPage = () => { /* If the user does not consent, we boot them back out to the join screen */ function disagree() { resetDataAndRefresh(); - }; + } function agree() { - setProtocolDone(true); - refreshOnboardingState(); - }; + setProtocolDone(true); + refreshOnboardingState(); + } // privacy policy and data collection info, followed by accept/reject buttons - return (<> - - - - - - - - - - ); -} + return ( + <> + + + + + + + + + + + ); +}; export default ProtocolPage; diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 3bfc93bb4..768fa9101 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -1,22 +1,21 @@ -import React, { useContext, useEffect, useState } from "react"; -import { View, StyleSheet } from "react-native"; -import { ActivityIndicator, Button, Surface, Text } from "react-native-paper"; -import { registerUserDone, setRegisterUserDone, setSaveQrDone } from "./onboardingHelper"; -import { AppContext } from "../App"; -import { getAngularService } from "../angular-react-helper"; -import { displayError, logDebug } from "../plugin/logger"; -import { useTranslation } from "react-i18next"; -import QrCode, { shareQR } from "../components/QrCode"; -import { onboardingStyles } from "./OnboardingStack"; -import { preloadDemoSurveyResponse } from "./SurveyPage"; -import { storageSet } from "../plugin/storage"; -import { registerUser } from "../commHelper"; -import { resetDataAndRefresh } from "../config/dynamicConfig"; -import { markConsented } from "../splash/startprefs"; -import i18next from "i18next"; - -const SaveQrPage = ({ }) => { +import React, { useContext, useEffect, useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { ActivityIndicator, Button, Surface, Text } from 'react-native-paper'; +import { registerUserDone, setRegisterUserDone, setSaveQrDone } from './onboardingHelper'; +import { AppContext } from '../App'; +import { getAngularService } from '../angular-react-helper'; +import { displayError, logDebug } from '../plugin/logger'; +import { useTranslation } from 'react-i18next'; +import QrCode, { shareQR } from '../components/QrCode'; +import { onboardingStyles } from './OnboardingStack'; +import { preloadDemoSurveyResponse } from './SurveyPage'; +import { storageSet } from '../plugin/storage'; +import { registerUser } from '../commHelper'; +import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { markConsented } from '../splash/startprefs'; +import i18next from 'i18next'; +const SaveQrPage = ({}) => { const { t } = useTranslation(); const { permissionStatus, onboardingState, refreshOnboardingState } = useContext(AppContext); const { overallStatus } = permissionStatus; @@ -24,36 +23,39 @@ const SaveQrPage = ({ }) => { useEffect(() => { if (overallStatus == true && !registerUserDone) { logDebug('permissions done, going to log in'); - markConsented() - .then(login(onboardingState.opcode) - .then((response) => { - logDebug('login done, refreshing onboarding state'); - setRegisterUserDone(true); - preloadDemoSurveyResponse(); - refreshOnboardingState(); - }) - ); + markConsented().then( + login(onboardingState.opcode).then((response) => { + logDebug('login done, refreshing onboarding state'); + setRegisterUserDone(true); + preloadDemoSurveyResponse(); + refreshOnboardingState(); + }), + ); } else { logDebug('permissions not done, waiting'); } }, [overallStatus]); function login(token) { - const EXPECTED_METHOD = "prompted-auth"; - const dbStorageObject = {"token": token}; - logDebug("about to login with token"); - return storageSet(EXPECTED_METHOD, dbStorageObject).then((r) => { - registerUser().then((r) => { - logDebug("registered user in CommHelper result " + r); - refreshOnboardingState(); - }).catch((e) => { - displayError(e, "User registration error"); - resetDataAndRefresh(); + const EXPECTED_METHOD = 'prompted-auth'; + const dbStorageObject = { token: token }; + logDebug('about to login with token'); + return storageSet(EXPECTED_METHOD, dbStorageObject) + .then((r) => { + registerUser() + .then((r) => { + logDebug('registered user in CommHelper result ' + r); + refreshOnboardingState(); + }) + .catch((e) => { + displayError(e, 'User registration error'); + resetDataAndRefresh(); + }); + }) + .catch((e) => { + displayError(e, 'Sign in error'); }); - }).catch((e) => { - displayError(e, "Sign in error"); - }); - }; + } function onFinish() { setSaveQrDone(true); @@ -63,30 +65,28 @@ const SaveQrPage = ({ }) => { return ( - + {t('login.make-sure-save-your-opcode')} - + {t('login.cannot-retrieve')} - - - - {onboardingState.opcode} - + + + {onboardingState.opcode} - - ); -} +}; const s = StyleSheet.create({ opcodeText: { diff --git a/www/js/onboarding/StudySummary.tsx b/www/js/onboarding/StudySummary.tsx index 3996ba076..9913c6d81 100644 --- a/www/js/onboarding/StudySummary.tsx +++ b/www/js/onboarding/StudySummary.tsx @@ -1,45 +1,48 @@ -import React, { useMemo } from "react"; -import { View, StyleSheet } from "react-native"; -import { Text } from "react-native-paper"; -import { useTranslation } from "react-i18next"; -import useAppConfig from "../useAppConfig"; +import React, { useMemo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Text } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import useAppConfig from '../useAppConfig'; export function getTemplateText(configObject, lang) { - if (configObject && (configObject.name)) { + if (configObject && configObject.name) { return configObject.intro.translated_text[lang]; } } const StudySummary = () => { - const { i18n } = useTranslation(); const appConfig = useAppConfig(); const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); - return (<> - {templateText?.deployment_name} - {appConfig?.intro?.deployment_partner_name + " " + templateText?.deployment_name} - - {"✔️ " + templateText?.summary_line_1} - {"✔️ " + templateText?.summary_line_2} - {"✔️ " + templateText?.summary_line_3} - - ) + return ( + <> + {templateText?.deployment_name} + + {appConfig?.intro?.deployment_partner_name + ' ' + templateText?.deployment_name} + + + {'✔️ ' + templateText?.summary_line_1} + {'✔️ ' + templateText?.summary_line_2} + {'✔️ ' + templateText?.summary_line_3} + + + ); }; const styles = StyleSheet.create({ title: { - fontWeight: "bold", + fontWeight: 'bold', fontSize: 24, paddingBottom: 10, - textAlign: "center" + textAlign: 'center', }, text: { fontSize: 15, }, studyName: { - fontWeight: "bold", + fontWeight: 'bold', fontSize: 17, }, }); diff --git a/www/js/onboarding/SummaryPage.tsx b/www/js/onboarding/SummaryPage.tsx index d15e9f60e..7acd1d1be 100644 --- a/www/js/onboarding/SummaryPage.tsx +++ b/www/js/onboarding/SummaryPage.tsx @@ -8,7 +8,6 @@ import StudySummary from './StudySummary'; import { setSummaryDone } from './onboardingHelper'; const SummaryPage = () => { - const { t } = useTranslation(); const context = useContext(AppContext); const { refreshOnboardingState } = context; @@ -16,21 +15,26 @@ const SummaryPage = () => { function next() { setSummaryDone(true); refreshOnboardingState(); - }; + } // summary of the study, followed by 'next' button - return (<> - - - - - - - - - - - ); -} + return ( + <> + + + + + + + + + + + + ); +}; export default SummaryPage; diff --git a/www/js/onboarding/SurveyPage.tsx b/www/js/onboarding/SurveyPage.tsx index c02439cbf..3ba430e85 100644 --- a/www/js/onboarding/SurveyPage.tsx +++ b/www/js/onboarding/SurveyPage.tsx @@ -1,16 +1,19 @@ -import React, { useState, useEffect, useContext, useMemo } from "react"; -import { View, StyleSheet } from "react-native"; -import { ActivityIndicator, Button, Surface, Text } from "react-native-paper"; -import EnketoModal from "../survey/enketo/EnketoModal"; -import { DEMOGRAPHIC_SURVEY_DATAKEY, DEMOGRAPHIC_SURVEY_NAME } from "../control/DemographicsSettingRow"; -import { loadPreviousResponseForSurvey } from "../survey/enketo/enketoHelper"; -import { AppContext } from "../App"; -import { markIntroDone, registerUserDone } from "./onboardingHelper"; -import { useTranslation } from "react-i18next"; -import { DateTime } from "luxon"; -import { onboardingStyles } from "./OnboardingStack"; -import { displayErrorMsg } from "../plugin/logger"; -import i18next from "i18next"; +import React, { useState, useEffect, useContext, useMemo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { ActivityIndicator, Button, Surface, Text } from 'react-native-paper'; +import EnketoModal from '../survey/enketo/EnketoModal'; +import { + DEMOGRAPHIC_SURVEY_DATAKEY, + DEMOGRAPHIC_SURVEY_NAME, +} from '../control/DemographicsSettingRow'; +import { loadPreviousResponseForSurvey } from '../survey/enketo/enketoHelper'; +import { AppContext } from '../App'; +import { markIntroDone, registerUserDone } from './onboardingHelper'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { onboardingStyles } from './OnboardingStack'; +import { displayErrorMsg } from '../plugin/logger'; +import i18next from 'i18next'; let preloadedResponsePromise: Promise = null; export const preloadDemoSurveyResponse = () => { @@ -22,10 +25,9 @@ export const preloadDemoSurveyResponse = () => { preloadedResponsePromise = loadPreviousResponseForSurvey(DEMOGRAPHIC_SURVEY_DATAKEY); } return preloadedResponsePromise; -} +}; const SurveyPage = () => { - const { t } = useTranslation(); const { refreshOnboardingState } = useContext(AppContext); const [surveyModalVisible, setSurveyModalVisible] = useState(false); @@ -33,7 +35,7 @@ const SurveyPage = () => { const prevSurveyResponseDate = useMemo(() => { if (prevSurveyResponse) { const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(prevSurveyResponse, "text/xml"); + const xmlDoc = parser.parseFromString(prevSurveyResponse, 'text/xml'); const surveyEndDt = xmlDoc.querySelector('end')?.textContent; // ISO datetime of survey completion return DateTime.fromISO(surveyEndDt).toLocaleString(DateTime.DATE_FULL); } @@ -60,42 +62,49 @@ const SurveyPage = () => { refreshOnboardingState(); } - return (<> - - {prevSurveyResponse ? - - - {t('survey.prev-survey-found')} - {prevSurveyResponseDate} + return ( + <> + + {prevSurveyResponse ? ( + + + + {' '} + {t('survey.prev-survey-found')}{' '} + + {prevSurveyResponseDate} + + + + + - - - + ) : ( + + + {t('survey.loading-prior-survey')} - - : - - - - {t('survey.loading-prior-survey')} - - - } - - setSurveyModalVisible(false)} - onResponseSaved={onFinish} surveyName={DEMOGRAPHIC_SURVEY_NAME} - opts={{ - /* If there is no prev response, we need an initial response from the user and should + )} + + setSurveyModalVisible(false)} + onResponseSaved={onFinish} + surveyName={DEMOGRAPHIC_SURVEY_NAME} + opts={{ + /* If there is no prev response, we need an initial response from the user and should not allow them to dismiss the modal by the "<- Dismiss" button */ - undismissable: !prevSurveyResponse, - prefilledSurveyResponse: prevSurveyResponse, - dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, - }} /> - ); + undismissable: !prevSurveyResponse, + prefilledSurveyResponse: prevSurveyResponse, + dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, + }} + /> + + ); }; export default SurveyPage; diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index cb317c5bc..7c09a21d3 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -1,16 +1,33 @@ import React, { useContext, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { View, Image, Modal, ScrollView, StyleSheet, ViewStyle, useWindowDimensions } from 'react-native'; -import { Button, Dialog, Divider, IconButton, Surface, Text, TextInput, TouchableRipple, useTheme } from 'react-native-paper'; +import { + View, + Image, + Modal, + ScrollView, + StyleSheet, + ViewStyle, + useWindowDimensions, +} from 'react-native'; +import { + Button, + Dialog, + Divider, + IconButton, + Surface, + Text, + TextInput, + TouchableRipple, + useTheme, +} from 'react-native-paper'; import color from 'color'; import { initByUser } from '../config/dynamicConfig'; import { AppContext } from '../App'; -import { displayError, logDebug } from "../plugin/logger"; +import { displayError, logDebug } from '../plugin/logger'; import { onboardingStyles } from './OnboardingStack'; import { Icon } from '../components/Icon'; const WelcomePage = () => { - const { t } = useTranslation(); const { colors } = useTheme(); const { width: windowWidth } = useWindowDimensions(); @@ -23,128 +40,158 @@ const WelcomePage = () => { const getCode = function (result) { let url = new window.URL(result.text); let notCancelled = result.cancelled == false; - let isQR = result.format == "QR_CODE"; - let hasPrefix = url.protocol == "emission:"; - let hasToken = url.searchParams.has("token"); - let code = url.searchParams.get("token"); + let isQR = result.format == 'QR_CODE'; + let hasPrefix = url.protocol == 'emission:'; + let hasToken = url.searchParams.has('token'); + let code = url.searchParams.get('token'); - logDebug("QR code " + result.text + " checks: cancel, format, prefix, params, code " + notCancelled + isQR + hasPrefix + hasToken + code); + logDebug( + 'QR code ' + + result.text + + ' checks: cancel, format, prefix, params, code ' + + notCancelled + + isQR + + hasPrefix + + hasToken + + code, + ); if (notCancelled && isQR && hasPrefix && hasToken) { return code; } else { return false; } - }; + }; const scanCode = function () { window['cordova'].plugins.barcodeScanner.scan( function (result) { - console.debug("scanned code", result); + console.debug('scanned code', result); let code = getCode(result); if (code != false) { - console.log("found code", code); + console.log('found code', code); loginWithToken(code); } else { - displayError(result.text, "invalid study reference"); + displayError(result.text, 'invalid study reference'); } }, function (error) { - displayError(error, "Scanning failed: "); - }); + displayError(error, 'Scanning failed: '); + }, + ); }; function loginWithToken(token) { - initByUser({token}).then((configUpdated) => { - if (configUpdated) { - setPasteModalVis(false); - refreshOnboardingState(); - } - }).catch(err => { - console.error('Error logging in with token', err); - setExistingToken(''); - }); + initByUser({ token }) + .then((configUpdated) => { + if (configUpdated) { + setPasteModalVis(false); + refreshOnboardingState(); + } + }) + .catch((err) => { + console.error('Error logging in with token', err); + setExistingToken(''); + }); } - return (<> - - - setInfoPopupVis(true)} /> - - + return ( + <> + + + setInfoPopupVis(true)} + /> + + + + + + }} + /> + + + {t('join.to-proceed-further')} + {t('join.code-hint')} + + + + + {t('join.scan-code')} + + {t('join.scan-hint')} + + + + setPasteModalVis(true)} icon="content-paste"> + {t('join.paste-code')} + + {t('join.paste-hint')} + + + - - - }} /> - - - {t('join.to-proceed-further')} - {t('join.code-hint')} - - - - - {t('join.scan-code')} - - {t('join.scan-hint')} - - - - setPasteModalVis(true)} icon='content-paste'> - {t('join.paste-code')} - - {t('join.paste-hint')} - - - - - setPasteModalVis(false)}> - setPasteModalVis(false)}> - - - - - - - - setInfoPopupVis(false)}> - setInfoPopupVis(false)}> - - {t('join.about-app-title', {appName: t('join.app-name')})} - - - - {t('join.about-app-para-1')} - {t('join.about-app-para-2')} - {t('join.about-app-para-3')} - {t('join.tips-title')} - - {t('join.all-green-status')} - - {t('join.dont-force-kill')} - - {t('join.background-restrictions')} - - - - - - - - ); -} + setPasteModalVis(false)}> + setPasteModalVis(false)}> + + + + + + + + setInfoPopupVis(false)}> + setInfoPopupVis(false)}> + {t('join.about-app-title', { appName: t('join.app-name') })} + + + {t('join.about-app-para-1')} + {t('join.about-app-para-2')} + {t('join.about-app-para-3')} + {t('join.tips-title')} + - {t('join.all-green-status')} + - {t('join.dont-force-kill')} + - {t('join.background-restrictions')} + + + + + + + + + ); +}; const s: any = StyleSheet.create({ headerArea: ((windowWidth, colors) => ({ width: windowWidth * 2.5, height: windowWidth, - left: -windowWidth * .75, + left: -windowWidth * 0.75, borderBottomRightRadius: '50%', borderBottomLeftRadius: '50%', position: 'absolute', - top: windowWidth * -2/3, + top: (windowWidth * -2) / 3, backgroundColor: colors.primary, boxShadow: `0 16px ${color(colors.primary).alpha(0.3).rgb().string()}`, })) as ViewStyle, @@ -183,9 +230,7 @@ const s: any = StyleSheet.create({ }, }); - const WelcomePageButton = ({ onPress, icon, children }) => { - const { colors } = useTheme(); const { width: windowWidth } = useWindowDimensions(); @@ -193,13 +238,13 @@ const WelcomePageButton = ({ onPress, icon, children }) => { - + {children} ); -} +}; const welcomeButtonStyles: any = StyleSheet.create({ btn: ((colors): ViewStyle => ({ diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 4110c2394..b776d65bd 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -1,9 +1,9 @@ -import { DateTime } from "luxon"; -import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; -import { storageGet, storageSet } from "../plugin/storage"; -import { logDebug } from "../plugin/logger"; -import { readConsentState, isConsented } from "../splash/startprefs"; -import { getAngularService } from "../angular-react-helper"; +import { DateTime } from 'luxon'; +import { getConfig, resetDataAndRefresh } from '../config/dynamicConfig'; +import { storageGet, storageSet } from '../plugin/storage'; +import { logDebug } from '../plugin/logger'; +import { readConsentState, isConsented } from '../splash/startprefs'; +import { getAngularService } from '../angular-react-helper'; export const INTRO_DONE_KEY = 'intro_done'; @@ -13,53 +13,70 @@ export const INTRO_DONE_KEY = 'intro_done'; // route = SAVE_QR if config present, protocol done, but save qr not done // route = SURVEY if config present, consented and save qr done // route = DONE if onboarding is finished (intro_done marked) -export enum OnboardingRoute { WELCOME, SUMMARY, PROTOCOL, SAVE_QR, SURVEY, DONE }; -export type OnboardingState = { - opcode: string, - route: OnboardingRoute, +export enum OnboardingRoute { + WELCOME, + SUMMARY, + PROTOCOL, + SAVE_QR, + SURVEY, + DONE, } +export type OnboardingState = { + opcode: string; + route: OnboardingRoute; +}; export let summaryDone = false; -export const setSummaryDone = (b) => summaryDone = b; +export const setSummaryDone = (b) => (summaryDone = b); export let protocolDone = false; -export const setProtocolDone = (b) => protocolDone = b; +export const setProtocolDone = (b) => (protocolDone = b); export let saveQrDone = false; -export const setSaveQrDone = (b) => saveQrDone = b; +export const setSaveQrDone = (b) => (saveQrDone = b); export let registerUserDone = false; -export const setRegisterUserDone = (b) => registerUserDone = b; +export const setRegisterUserDone = (b) => (registerUserDone = b); export function getPendingOnboardingState(): Promise { - return Promise.all([getConfig(), readConsented(), readIntroDone()]).then(([config, isConsented, isIntroDone]) => { - let route: OnboardingRoute; + return Promise.all([getConfig(), readConsented(), readIntroDone()]).then( + ([config, isConsented, isIntroDone]) => { + let route: OnboardingRoute; - // backwards compat - prev. versions might have config cleared but still have intro_done set - if (!config && (isIntroDone || isConsented)) { - resetDataAndRefresh(); // if there's no config, we need to reset everything - return null; - } - - if (isIntroDone) { - route = OnboardingRoute.DONE; - } else if (!config) { - route = OnboardingRoute.WELCOME; - } else if (!protocolDone && !summaryDone) { - route = OnboardingRoute.SUMMARY; - } else if (!protocolDone) { - route = OnboardingRoute.PROTOCOL; - } else if (!saveQrDone) { - route = OnboardingRoute.SAVE_QR; - } else { - route = OnboardingRoute.SURVEY; - } + // backwards compat - prev. versions might have config cleared but still have intro_done set + if (!config && (isIntroDone || isConsented)) { + resetDataAndRefresh(); // if there's no config, we need to reset everything + return null; + } - logDebug("pending onboarding state is " + route + " intro, config, consent, qr saved : " + isIntroDone + config + isConsented + saveQrDone); + if (isIntroDone) { + route = OnboardingRoute.DONE; + } else if (!config) { + route = OnboardingRoute.WELCOME; + } else if (!protocolDone && !summaryDone) { + route = OnboardingRoute.SUMMARY; + } else if (!protocolDone) { + route = OnboardingRoute.PROTOCOL; + } else if (!saveQrDone) { + route = OnboardingRoute.SAVE_QR; + } else { + route = OnboardingRoute.SURVEY; + } - return { route, opcode: config?.joined?.opcode }; - }); -}; + logDebug( + 'pending onboarding state is ' + + route + + ' intro, config, consent, qr saved : ' + + isIntroDone + + config + + isConsented + + saveQrDone, + ); + + return { route, opcode: config?.joined?.opcode }; + }, + ); +} async function readConsented() { return readConsentState().then(isConsented) as Promise; @@ -71,13 +88,12 @@ export async function readIntroDone() { export async function markIntroDone() { const currDateTime = DateTime.now().toISO(); - return storageSet(INTRO_DONE_KEY, currDateTime) - .then(() => { - //handle "on intro" events - logDebug("intro done, calling registerPush and storeDeviceSettings"); - const PushNotify = getAngularService("PushNotify"); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - PushNotify.registerPush(); - StoreSeviceSettings.storeDeviceSettings(); - }); + return storageSet(INTRO_DONE_KEY, currDateTime).then(() => { + //handle "on intro" events + logDebug('intro done, calling registerPush and storeDeviceSettings'); + const PushNotify = getAngularService('PushNotify'); + const StoreSeviceSettings = getAngularService('StoreDeviceSettings'); + PushNotify.registerPush(); + StoreSeviceSettings.storeDeviceSettings(); + }); } diff --git a/www/js/plugin/clientStats.ts b/www/js/plugin/clientStats.ts index cefaf8f22..6735ef5ff 100644 --- a/www/js/plugin/clientStats.ts +++ b/www/js/plugin/clientStats.ts @@ -1,24 +1,24 @@ -import { displayErrorMsg } from "./logger"; +import { displayErrorMsg } from './logger'; -const CLIENT_TIME = "stats/client_time"; -const CLIENT_ERROR = "stats/client_error"; -const CLIENT_NAV_EVENT = "stats/client_nav_event"; +const CLIENT_TIME = 'stats/client_time'; +const CLIENT_ERROR = 'stats/client_error'; +const CLIENT_NAV_EVENT = 'stats/client_nav_event'; export const statKeys = { - STATE_CHANGED: "state_changed", - BUTTON_FORCE_SYNC: "button_sync_forced", - CHECKED_DIARY: "checked_diary", - DIARY_TIME: "diary_time", - METRICS_TIME: "metrics_time", - CHECKED_INF_SCROLL: "checked_inf_scroll", - INF_SCROLL_TIME: "inf_scroll_time", - VERIFY_TRIP: "verify_trip", - LABEL_TAB_SWITCH: "label_tab_switch", - SELECT_LABEL: "select_label", - EXPANDED_TRIP: "expanded_trip", - NOTIFICATION_OPEN: "notification_open", - REMINDER_PREFS: "reminder_time_prefs", - MISSING_KEYS: "missing_keys" + STATE_CHANGED: 'state_changed', + BUTTON_FORCE_SYNC: 'button_sync_forced', + CHECKED_DIARY: 'checked_diary', + DIARY_TIME: 'diary_time', + METRICS_TIME: 'metrics_time', + CHECKED_INF_SCROLL: 'checked_inf_scroll', + INF_SCROLL_TIME: 'inf_scroll_time', + VERIFY_TRIP: 'verify_trip', + LABEL_TAB_SWITCH: 'label_tab_switch', + SELECT_LABEL: 'select_label', + EXPANDED_TRIP: 'expanded_trip', + NOTIFICATION_OPEN: 'notification_open', + REMINDER_PREFS: 'reminder_time_prefs', + MISSING_KEYS: 'missing_keys', }; let appVersion; @@ -28,32 +28,32 @@ export const getAppVersion = () => { appVersion = version; return version; }); -} +}; const getStatsEvent = async (name: string, reading: any) => { const ts = Date.now() / 1000; const client_app_version = await getAppVersion(); const client_os_version = window['device'].version; return { name, ts, reading, client_app_version, client_os_version }; -} +}; export const addStatReading = async (name: string, reading: any) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, reading); if (db) return db.putMessage(CLIENT_TIME, event); - displayErrorMsg("addStatReading: db is not defined"); -} + displayErrorMsg('addStatReading: db is not defined'); +}; export const addStatEvent = async (name: string) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, null); if (db) return db.putMessage(CLIENT_NAV_EVENT, event); - displayErrorMsg("addStatEvent: db is not defined"); -} + displayErrorMsg('addStatEvent: db is not defined'); +}; export const addStatError = async (name: string, errorStr: string) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, errorStr); if (db) return db.putMessage(CLIENT_ERROR, event); - displayErrorMsg("addStatError: db is not defined"); -} + displayErrorMsg('addStatError: db is not defined'); +}; diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index d127f5549..376c6486b 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -1,28 +1,33 @@ import angular from 'angular'; -angular.module('emission.plugin.logger', []) +angular + .module('emission.plugin.logger', []) -// explicit annotations needed in .ts files - Babel does not fix them (see webpack.prod.js) -.factory('Logger', ['$window', '$ionicPopup', function($window, $ionicPopup) { - var loggerJs: any = {}; - loggerJs.log = function(message) { + // explicit annotations needed in .ts files - Babel does not fix them (see webpack.prod.js) + .factory('Logger', [ + '$window', + '$ionicPopup', + function ($window, $ionicPopup) { + var loggerJs: any = {}; + loggerJs.log = function (message) { $window.Logger.log($window.Logger.LEVEL_DEBUG, message); - } - loggerJs.displayError = function(title, error) { - var display_msg = error.message + "\n" + error.stack; - if (!angular.isDefined(error.message)) { - display_msg = JSON.stringify(error); - } - // Check for OPcode DNE errors and prepend the title with "Invalid OPcode" - if (error.includes?.("403") || error.message?.includes?.("403")) { - title = "Invalid OPcode: " + title; - } - $ionicPopup.alert({"title": title, "template": display_msg}); - console.log(title + display_msg); - $window.Logger.log($window.Logger.LEVEL_ERROR, title + display_msg); - } - return loggerJs; -}]); + }; + loggerJs.displayError = function (title, error) { + var display_msg = error.message + '\n' + error.stack; + if (!angular.isDefined(error.message)) { + display_msg = JSON.stringify(error); + } + // Check for OPcode DNE errors and prepend the title with "Invalid OPcode" + if (error.includes?.('403') || error.message?.includes?.('403')) { + title = 'Invalid OPcode: ' + title; + } + $ionicPopup.alert({ title: title, template: display_msg }); + console.log(title + display_msg); + $window.Logger.log($window.Logger.LEVEL_ERROR, title + display_msg); + }; + return loggerJs; + }, + ]); export const logDebug = (message: string) => window['Logger'].log(window['Logger'].LEVEL_DEBUG, message); @@ -40,8 +45,8 @@ export function displayError(error: Error, title?: string) { export function displayErrorMsg(errorMsg: string, title?: string) { // Check for OPcode 'Does Not Exist' errors and prepend the title with "Invalid OPcode" - if (errorMsg.includes?.("403")) { - title = "Invalid OPcode: " + (title || ''); + if (errorMsg.includes?.('403')) { + title = 'Invalid OPcode: ' + (title || ''); } const displayMsg = `━━━━\n${title}\n━━━━\n` + errorMsg; window.alert(displayMsg); diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 643e985e1..63604e8c1 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -1,15 +1,15 @@ -import { getAngularService } from "../angular-react-helper"; -import { addStatReading, statKeys } from "./clientStats"; -import { logDebug, logWarn } from "./logger"; +import { getAngularService } from '../angular-react-helper'; +import { addStatReading, statKeys } from './clientStats'; +import { logDebug, logWarn } from './logger'; const mungeValue = (key, value) => { let store_val = value; - if (typeof value != "object") { + if (typeof value != 'object') { store_val = {}; store_val[key] = value; } return store_val; -} +}; /* * If a non-JSON object was munged for storage, unwrap it. @@ -22,16 +22,16 @@ const unmungeValue = (key, retData) => { // it must have been an object return retData; } -} +}; -const localStorageSet = (key: string, value: {[k: string]: any}) => { +const localStorageSet = (key: string, value: { [k: string]: any }) => { //checking for a value to prevent storing undefined //case where local was null and native was undefined stored "undefined" //see discussion: https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373753945 if (value) { localStorage.setItem(key, JSON.stringify(value)); } -} +}; const localStorageGet = (key: string) => { const value = localStorage.getItem(key); @@ -40,7 +40,7 @@ const localStorageGet = (key: string) => { } else { return null; } -} +}; /* We redundantly store data in both local and native storage. This function checks both for a value. If a value is present in only one, it copies it to the other and returns it. @@ -48,47 +48,55 @@ const localStorageGet = (key: string) => { local storage and returns it. */ function getUnifiedValue(key) { const ls_stored_val = localStorageGet(key); - return window['cordova'].plugins.BEMUserCache.getLocalStorage(key, false).then((uc_stored_val) => { - logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + return window['cordova'].plugins.BEMUserCache.getLocalStorage(key, false).then( + (uc_stored_val) => { + logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}.`); - /* compare stored values by stringified JSON equality, not by == or ===. + /* compare stored values by stringified JSON equality, not by == or ===. for objects, == or === only compares the references, not the contents of the objects */ - if (JSON.stringify(ls_stored_val) == JSON.stringify(uc_stored_val)) { - logDebug("local and native values match, already synced"); - return uc_stored_val; - } else { - // the values are different - if (ls_stored_val == null) { - // local value is missing, fill it in from native - console.assert(uc_stored_val != null, "uc_stored_val should be non-null"); - logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + if (JSON.stringify(ls_stored_val) == JSON.stringify(uc_stored_val)) { + logDebug('local and native values match, already synced'); + return uc_stored_val; + } else { + // the values are different + if (ls_stored_val == null) { + // local value is missing, fill it in from native + console.assert(uc_stored_val != null, 'uc_stored_val should be non-null'); + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying native ${key} to local...`); - localStorageSet(key, uc_stored_val); - return uc_stored_val; - } else if (uc_stored_val == null) { - // native value is missing, fill it in from local - console.assert(ls_stored_val != null); - logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + localStorageSet(key, uc_stored_val); + return uc_stored_val; + } else if (uc_stored_val == null) { + // native value is missing, fill it in from local + console.assert(ls_stored_val != null); + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying local ${key} to native...`); - return window['cordova'].plugins.BEMUserCache.putLocalStorage(key, ls_stored_val).then(() => { - // we only return the value after we have finished writing - return ls_stored_val; - }); - } - // both values are present, but they are different - console.assert(ls_stored_val != null && uc_stored_val != null, - "ls_stored_val =" + JSON.stringify(ls_stored_val) + - "uc_stored_val =" + JSON.stringify(uc_stored_val)); - logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + return window['cordova'].plugins.BEMUserCache.putLocalStorage(key, ls_stored_val).then( + () => { + // we only return the value after we have finished writing + return ls_stored_val; + }, + ); + } + // both values are present, but they are different + console.assert( + ls_stored_val != null && uc_stored_val != null, + 'ls_stored_val =' + + JSON.stringify(ls_stored_val) + + 'uc_stored_val =' + + JSON.stringify(uc_stored_val), + ); + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying native ${key} to local...`); - localStorageSet(key, uc_stored_val); - return uc_stored_val; - } - }); + localStorageSet(key, uc_stored_val); + return uc_stored_val; + } + }, + ); } export function storageSet(key: string, value: any) { @@ -112,7 +120,7 @@ export function storageRemove(key: string) { return window['cordova'].plugins.BEMUserCache.removeLocalStorage(key); } -export function storageClear({ local, native }: { local?: boolean, native?: boolean }) { +export function storageClear({ local, native }: { local?: boolean; native?: boolean }) { if (local) localStorage.clear(); if (native) return window['cordova'].plugins.BEMUserCache.clearAll(); return Promise.resolve(); @@ -138,42 +146,51 @@ function findMissing(fromKeys, toKeys) { } export function storageSyncLocalAndNative() { - console.log("STORAGE_PLUGIN: Called syncAllWebAndNativeValues "); - const syncKeys = window['cordova'].plugins.BEMUserCache.listAllLocalStorageKeys().then((nativeKeys) => { - console.log("STORAGE_PLUGIN: native plugin returned"); - const webKeys = Object.keys(localStorage); - // I thought about iterating through the lists and copying over - // only missing values, etc but `getUnifiedValue` already does - // that, and we don't need to copy it - // so let's just find all the missing values and read them - logDebug("STORAGE_PLUGIN: Comparing web keys " + webKeys + " with " + nativeKeys); - let [foundNative, missingNative] = findMissing(webKeys, nativeKeys); - let [foundWeb, missingWeb] = findMissing(nativeKeys, webKeys); - logDebug("STORAGE_PLUGIN: Found native keys " + foundNative + " missing native keys " + missingNative); - logDebug("STORAGE_PLUGIN: Found web keys " + foundWeb + " missing web keys " + missingWeb); - const allMissing = missingNative.concat(missingWeb); - logDebug("STORAGE_PLUGIN: Syncing all missing keys " + allMissing); - allMissing.forEach(getUnifiedValue); - if (allMissing.length != 0) { - addStatReading(statKeys.MISSING_KEYS, { - "type": "local_storage_mismatch", - "allMissingLength": allMissing.length, - "missingWebLength": missingWeb.length, - "missingNativeLength": missingNative.length, - "foundWebLength": foundWeb.length, - "foundNativeLength": foundNative.length, - "allMissing": allMissing, - }).then(logDebug("Logged missing keys to client stats")); - } - }); - const listAllKeys = window['cordova'].plugins.BEMUserCache.listAllUniqueKeys().then((nativeKeys) => { - logDebug("STORAGE_PLUGIN: For the record, all unique native keys are " + nativeKeys); - if (nativeKeys.length == 0) { - addStatReading(statKeys.MISSING_KEYS, { - "type": "all_native", - }).then(logDebug("Logged all missing native keys to client stats")); - } - }); + console.log('STORAGE_PLUGIN: Called syncAllWebAndNativeValues '); + const syncKeys = window['cordova'].plugins.BEMUserCache.listAllLocalStorageKeys().then( + (nativeKeys) => { + console.log('STORAGE_PLUGIN: native plugin returned'); + const webKeys = Object.keys(localStorage); + // I thought about iterating through the lists and copying over + // only missing values, etc but `getUnifiedValue` already does + // that, and we don't need to copy it + // so let's just find all the missing values and read them + logDebug('STORAGE_PLUGIN: Comparing web keys ' + webKeys + ' with ' + nativeKeys); + let [foundNative, missingNative] = findMissing(webKeys, nativeKeys); + let [foundWeb, missingWeb] = findMissing(nativeKeys, webKeys); + logDebug( + 'STORAGE_PLUGIN: Found native keys ' + + foundNative + + ' missing native keys ' + + missingNative, + ); + logDebug('STORAGE_PLUGIN: Found web keys ' + foundWeb + ' missing web keys ' + missingWeb); + const allMissing = missingNative.concat(missingWeb); + logDebug('STORAGE_PLUGIN: Syncing all missing keys ' + allMissing); + allMissing.forEach(getUnifiedValue); + if (allMissing.length != 0) { + addStatReading(statKeys.MISSING_KEYS, { + type: 'local_storage_mismatch', + allMissingLength: allMissing.length, + missingWebLength: missingWeb.length, + missingNativeLength: missingNative.length, + foundWebLength: foundWeb.length, + foundNativeLength: foundNative.length, + allMissing: allMissing, + }).then(logDebug('Logged missing keys to client stats')); + } + }, + ); + const listAllKeys = window['cordova'].plugins.BEMUserCache.listAllUniqueKeys().then( + (nativeKeys) => { + logDebug('STORAGE_PLUGIN: For the record, all unique native keys are ' + nativeKeys); + if (nativeKeys.length == 0) { + addStatReading(statKeys.MISSING_KEYS, { + type: 'all_native', + }).then(logDebug('Logged all missing native keys to client stats')); + } + }, + ); return Promise.all([syncKeys, listAllKeys]); } diff --git a/www/js/services.js b/www/js/services.js index 0c9c6e2ac..444ff94b7 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -3,30 +3,39 @@ import angular from 'angular'; import { getRawEntries } from './commHelper'; -angular.module('emission.services', ['emission.plugin.logger']) +angular + .module('emission.services', ['emission.plugin.logger']) -.service('ReferHelper', function($http) { - - this.habiticaRegister = function(groupid, successCallback, errorCallback) { - window.cordova.plugins.BEMServerComm.getUserPersonalData("/join.group/"+groupid, successCallback, errorCallback); + .service('ReferHelper', function ($http) { + this.habiticaRegister = function (groupid, successCallback, errorCallback) { + window.cordova.plugins.BEMServerComm.getUserPersonalData( + '/join.group/' + groupid, + successCallback, + errorCallback, + ); }; - this.joinGroup = function(groupid, userid) { - - // TODO: - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData("/join.group/"+groupid, "inviter", userid, resolve, reject); - }) + this.joinGroup = function (groupid, userid) { + // TODO: + return new Promise(function (resolve, reject) { + window.cordova.plugins.BEMServerComm.postUserPersonalData( + '/join.group/' + groupid, + 'inviter', + userid, + resolve, + reject, + ); + }); - //function firstUpperCase(string) { - // return string[0].toUpperCase() + string.slice(1); - //}*/ - } -}) -.service('UnifiedDataLoader', function($window, Logger) { - var combineWithDedup = function(list1, list2) { + //function firstUpperCase(string) { + // return string[0].toUpperCase() + string.slice(1); + //}*/ + }; + }) + .service('UnifiedDataLoader', function ($window, Logger) { + var combineWithDedup = function (list1, list2) { var combinedList = list1.concat(list2); - return combinedList.filter(function(value, i, array) { - var firstIndexOfValue = array.findIndex(function(element, index, array) { + return combinedList.filter(function (value, i, array) { + var firstIndexOfValue = array.findIndex(function (element, index, array) { return element.metadata.write_ts == value.metadata.write_ts; }); return firstIndexOfValue == i; @@ -34,259 +43,296 @@ angular.module('emission.services', ['emission.plugin.logger']) }; // TODO: generalize to iterable of promises - var combinedPromise = function(localPromise, remotePromise, combiner) { - return new Promise(function(resolve, reject) { - var localResult = []; - var localError = null; + var combinedPromise = function (localPromise, remotePromise, combiner) { + return new Promise(function (resolve, reject) { + var localResult = []; + var localError = null; - var remoteResult = []; - var remoteError = null; + var remoteResult = []; + var remoteError = null; - var localPromiseDone = false; - var remotePromiseDone = false; + var localPromiseDone = false; + var remotePromiseDone = false; - var checkAndResolve = function() { - if (localPromiseDone && remotePromiseDone) { - // time to return from this promise - if (localError && remoteError) { - reject([localError, remoteError]); - } else { - Logger.log("About to dedup localResult = "+localResult.length - +"remoteResult = "+remoteResult.length); - var dedupedList = combiner(localResult, remoteResult); - Logger.log("Deduped list = "+dedupedList.length); - resolve(dedupedList); - } + var checkAndResolve = function () { + if (localPromiseDone && remotePromiseDone) { + // time to return from this promise + if (localError && remoteError) { + reject([localError, remoteError]); + } else { + Logger.log( + 'About to dedup localResult = ' + + localResult.length + + 'remoteResult = ' + + remoteResult.length, + ); + var dedupedList = combiner(localResult, remoteResult); + Logger.log('Deduped list = ' + dedupedList.length); + resolve(dedupedList); } - }; + } + }; - localPromise.then(function(currentLocalResult) { - localResult = currentLocalResult; - localPromiseDone = true; - }, function(error) { - localResult = []; - localError = error; - localPromiseDone = true; - }).then(checkAndResolve); + localPromise + .then( + function (currentLocalResult) { + localResult = currentLocalResult; + localPromiseDone = true; + }, + function (error) { + localResult = []; + localError = error; + localPromiseDone = true; + }, + ) + .then(checkAndResolve); - remotePromise.then(function(currentRemoteResult) { - remoteResult = currentRemoteResult; - remotePromiseDone = true; - }, function(error) { - remoteResult = []; - remoteError = error; - remotePromiseDone = true; - }).then(checkAndResolve); - }) - } + remotePromise + .then( + function (currentRemoteResult) { + remoteResult = currentRemoteResult; + remotePromiseDone = true; + }, + function (error) { + remoteResult = []; + remoteError = error; + remotePromiseDone = true; + }, + ) + .then(checkAndResolve); + }); + }; // TODO: Generalize this to work for both sensor data and messages // Do we even need to separate the two kinds of data? // Alternatively, we can maintain another mapping between key -> type // Probably in www/json... - this.getUnifiedSensorDataForInterval = function(key, tq) { - var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); + this.getUnifiedSensorDataForInterval = function (key, tq) { + var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval( + key, + tq, + true, + ); + var remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( + function (serverResponse) { + return serverResponse.phone_data; + }, + ); + return combinedPromise(localPromise, remotePromise, combineWithDedup); }; - this.getUnifiedMessagesForInterval = function(key, tq, withMetadata) { + this.getUnifiedMessagesForInterval = function (key, tq, withMetadata) { var localPromise = $window.cordova.plugins.BEMUserCache.getMessagesForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); + var remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( + function (serverResponse) { + return serverResponse.phone_data; + }, + ); return combinedPromise(localPromise, remotePromise, combineWithDedup); - } -}) -.service('ControlHelper', function($window, - $ionicPopup, - Logger) { - - this.writeFile = function(fileEntry, resultList) { + }; + }) + .service('ControlHelper', function ($window, $ionicPopup, Logger) { + this.writeFile = function (fileEntry, resultList) { // Create a FileWriter object for our FileEntry (log.txt). - } + }; - this.getMyData = function(startTs) { - var fmt = "YYYY-MM-DD"; - // We are only retrieving data for a single day to avoid - // running out of memory on the phone - var startMoment = moment(startTs); - var endMoment = moment(startTs).endOf("day"); - var dumpFile = startMoment.format(fmt) + "." - + endMoment.format(fmt) - + ".timeline"; - alert("Going to retrieve data to "+dumpFile); + this.getMyData = function (startTs) { + var fmt = 'YYYY-MM-DD'; + // We are only retrieving data for a single day to avoid + // running out of memory on the phone + var startMoment = moment(startTs); + var endMoment = moment(startTs).endOf('day'); + var dumpFile = startMoment.format(fmt) + '.' + endMoment.format(fmt) + '.timeline'; + alert('Going to retrieve data to ' + dumpFile); - var writeDumpFile = function(result) { - return new Promise(function(resolve, reject) { - var resultList = result.phone_data; - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log('file system open: ' + fs.name); - fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { - console.log("fileEntry "+fileEntry.nativeURL+" is file?" + fileEntry.isFile.toString()); - fileEntry.createWriter(function (fileWriter) { - fileWriter.onwriteend = function() { - console.log("Successful file write..."); - resolve(); - // readFile(fileEntry); - }; + var writeDumpFile = function (result) { + return new Promise(function (resolve, reject) { + var resultList = result.phone_data; + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { + console.log('file system open: ' + fs.name); + fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { + console.log( + 'fileEntry ' + fileEntry.nativeURL + ' is file?' + fileEntry.isFile.toString(), + ); + fileEntry.createWriter(function (fileWriter) { + fileWriter.onwriteend = function () { + console.log('Successful file write...'); + resolve(); + // readFile(fileEntry); + }; - fileWriter.onerror = function (e) { - console.log("Failed file write: " + e.toString()); - reject(); - }; + fileWriter.onerror = function (e) { + console.log('Failed file write: ' + e.toString()); + reject(); + }; - // If data object is not passed in, - // create a new Blob instead. - var dataObj = new Blob([JSON.stringify(resultList, null, 2)], - { type: 'application/json' }); - fileWriter.write(dataObj); - }); - // this.writeFile(fileEntry, resultList); + // If data object is not passed in, + // create a new Blob instead. + var dataObj = new Blob([JSON.stringify(resultList, null, 2)], { + type: 'application/json', }); + fileWriter.write(dataObj); }); + // this.writeFile(fileEntry, resultList); }); - } - + }); + }); + }; - var emailData = function(result) { - return new Promise(function(resolve, reject) { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log("During email, file system open: "+fs.name); - fs.root.getFile(dumpFile, null, function(fileEntry) { - console.log("fileEntry "+fileEntry.nativeURL+" is file?"+fileEntry.isFile.toString()); - fileEntry.file(function (file) { - var reader = new FileReader(); + var emailData = function (result) { + return new Promise(function (resolve, reject) { + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { + console.log('During email, file system open: ' + fs.name); + fs.root.getFile(dumpFile, null, function (fileEntry) { + console.log( + 'fileEntry ' + fileEntry.nativeURL + ' is file?' + fileEntry.isFile.toString(), + ); + fileEntry.file( + function (file) { + var reader = new FileReader(); - reader.onloadend = function() { - console.log("Successful file read with " + this.result.length +" characters"); - var dataArray = JSON.parse(this.result); - console.log("Successfully read resultList of size "+dataArray.length); - // displayFileData(fileEntry.fullPath + ": " + this.result); - var attachFile = fileEntry.nativeURL; - if (ionic.Platform.isAndroid()) { - // At least on nexus, getting a temporary file puts it into - // the cache, so I can hardcode that for now - attachFile = "app://cache/"+dumpFile; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - } - var email = { - attachments: [ - attachFile - ], - subject: i18next.t('email-service.email-data.subject-data-dump-from-to', {start: startMoment.format(fmt),end: endMoment.format(fmt)}), - body: i18next.t('email-service.email-data.body-data-consists-of-list-of-entries') - } - $window.cordova.plugins.email.open(email).then(resolve()); + reader.onloadend = function () { + console.log('Successful file read with ' + this.result.length + ' characters'); + var dataArray = JSON.parse(this.result); + console.log('Successfully read resultList of size ' + dataArray.length); + // displayFileData(fileEntry.fullPath + ": " + this.result); + var attachFile = fileEntry.nativeURL; + if (ionic.Platform.isAndroid()) { + // At least on nexus, getting a temporary file puts it into + // the cache, so I can hardcode that for now + attachFile = 'app://cache/' + dumpFile; + } + if (ionic.Platform.isIOS()) { + alert(i18next.t('email-service.email-account-mail-app')); } - reader.readAsText(file); - }, function(error) { - $ionicPopup.alert({title: "Error while downloading JSON dump", - template: error}); - reject(error); + var email = { + attachments: [attachFile], + subject: i18next.t('email-service.email-data.subject-data-dump-from-to', { + start: startMoment.format(fmt), + end: endMoment.format(fmt), + }), + body: i18next.t( + 'email-service.email-data.body-data-consists-of-list-of-entries', + ), + }; + $window.cordova.plugins.email.open(email).then(resolve()); + }; + reader.readAsText(file); + }, + function (error) { + $ionicPopup.alert({ + title: 'Error while downloading JSON dump', + template: error, }); - }); - }); + reject(error); + }, + ); }); - }; + }); + }); + }; - getRawEntries(null, startMoment.unix(), endMoment.unix()) - .then(writeDumpFile) - .then(emailData) - .then(function() { - Logger.log("Email queued successfully"); - }) - .catch(function(error) { - Logger.displayError("Error emailing JSON dump", error); - }) + getRawEntries(null, startMoment.unix(), endMoment.unix()) + .then(writeDumpFile) + .then(emailData) + .then(function () { + Logger.log('Email queued successfully'); + }) + .catch(function (error) { + Logger.displayError('Error emailing JSON dump', error); + }); }; - this.getOPCode = function() { + this.getOPCode = function () { return window.cordova.plugins.OPCodeAuth.getOPCode(); }; - this.getSettings = function() { + this.getSettings = function () { return window.cordova.plugins.BEMConnectionSettings.getSettings(); }; + }) -}) - -.factory('Chats', function() { - // Might use a resource here that returns a JSON array + .factory('Chats', function () { + // Might use a resource here that returns a JSON array - // Some fake testing data - var chats = [{ - id: 0, - name: 'Ben Sparrow', - lastText: 'You on your way?', - face: 'img/ben.png' - }, { - id: 1, - name: 'Max Lynx', - lastText: 'Hey, it\'s me', - face: 'img/max.png' - }, { - id: 2, - name: 'Adam Bradleyson', - lastText: 'I should buy a boat', - face: 'img/adam.jpg' - }, { - id: 3, - name: 'Perry Governor', - lastText: 'Look at my mukluks!', - face: 'img/perry.png' - }, { - id: 4, - name: 'Mike Harrington', - lastText: 'This is wicked good ice cream.', - face: 'img/mike.png' - }, { - id: 5, - name: 'Ben Sparrow', - lastText: 'You on your way again?', - face: 'img/ben.png' - }, { - id: 6, - name: 'Max Lynx', - lastText: 'Hey, it\'s me again', - face: 'img/max.png' - }, { - id: 7, - name: 'Adam Bradleyson', - lastText: 'I should buy a boat again', - face: 'img/adam.jpg' - }, { - id: 8, - name: 'Perry Governor', - lastText: 'Look at my mukluks again!', - face: 'img/perry.png' - }, { - id: 9, - name: 'Mike Harrington', - lastText: 'This is wicked good ice cream again.', - face: 'img/mike.png' - }]; + // Some fake testing data + var chats = [ + { + id: 0, + name: 'Ben Sparrow', + lastText: 'You on your way?', + face: 'img/ben.png', + }, + { + id: 1, + name: 'Max Lynx', + lastText: "Hey, it's me", + face: 'img/max.png', + }, + { + id: 2, + name: 'Adam Bradleyson', + lastText: 'I should buy a boat', + face: 'img/adam.jpg', + }, + { + id: 3, + name: 'Perry Governor', + lastText: 'Look at my mukluks!', + face: 'img/perry.png', + }, + { + id: 4, + name: 'Mike Harrington', + lastText: 'This is wicked good ice cream.', + face: 'img/mike.png', + }, + { + id: 5, + name: 'Ben Sparrow', + lastText: 'You on your way again?', + face: 'img/ben.png', + }, + { + id: 6, + name: 'Max Lynx', + lastText: "Hey, it's me again", + face: 'img/max.png', + }, + { + id: 7, + name: 'Adam Bradleyson', + lastText: 'I should buy a boat again', + face: 'img/adam.jpg', + }, + { + id: 8, + name: 'Perry Governor', + lastText: 'Look at my mukluks again!', + face: 'img/perry.png', + }, + { + id: 9, + name: 'Mike Harrington', + lastText: 'This is wicked good ice cream again.', + face: 'img/mike.png', + }, + ]; - return { - all: function() { - return chats; - }, - remove: function(chat) { - chats.splice(chats.indexOf(chat), 1); - }, - get: function(chatId) { - for (var i = 0; i < chats.length; i++) { - if (chats[i].id === parseInt(chatId)) { - return chats[i]; + return { + all: function () { + return chats; + }, + remove: function (chat) { + chats.splice(chats.indexOf(chat), 1); + }, + get: function (chatId) { + for (var i = 0; i < chats.length; i++) { + if (chats[i].id === parseInt(chatId)) { + return chats[i]; + } } - } - return null; - } - }; -}); + return null; + }, + }; + }); diff --git a/www/js/splash/customURL.ts b/www/js/splash/customURL.ts index bc3d93f3e..d351fcc0b 100644 --- a/www/js/splash/customURL.ts +++ b/www/js/splash/customURL.ts @@ -1,22 +1,24 @@ type UrlComponents = { - [key : string] : string -} - -type OnLaunchCustomURL = (rawUrl: string, callback: (url: string, urlComponents: UrlComponents) => void ) => void; + [key: string]: string; +}; +type OnLaunchCustomURL = ( + rawUrl: string, + callback: (url: string, urlComponents: UrlComponents) => void, +) => void; export const onLaunchCustomURL: OnLaunchCustomURL = (rawUrl, handler) => { - try { - const url = rawUrl.split('//')[1]; - const [ route, paramString ] = url.split('?'); - const paramsList = paramString.split('&'); - const urlComponents: UrlComponents = { route : route }; - for (let i = 0; i < paramsList.length; i++) { - const [key, value] = paramsList[i].split('='); - urlComponents[key] = value; - } - handler(url, urlComponents); - }catch { - console.log('not a valid url'); + try { + const url = rawUrl.split('//')[1]; + const [route, paramString] = url.split('?'); + const paramsList = paramString.split('&'); + const urlComponents: UrlComponents = { route: route }; + for (let i = 0; i < paramsList.length; i++) { + const [key, value] = paramsList[i].split('='); + urlComponents[key] = value; } -}; \ No newline at end of file + handler(url, urlComponents); + } catch { + console.log('not a valid url'); + } +}; diff --git a/www/js/splash/localnotify.js b/www/js/splash/localnotify.js index c96ba827d..9f3db3ab3 100644 --- a/www/js/splash/localnotify.js +++ b/www/js/splash/localnotify.js @@ -7,102 +7,132 @@ import angular from 'angular'; -angular.module('emission.splash.localnotify', ['emission.plugin.logger', - 'ionic-toast']) -.factory('LocalNotify', function($window, $ionicPlatform, $ionicPopup, - $state, $rootScope, ionicToast, Logger) { - var localNotify = {}; +angular + .module('emission.splash.localnotify', ['emission.plugin.logger', 'ionic-toast']) + .factory( + 'LocalNotify', + function ($window, $ionicPlatform, $ionicPopup, $state, $rootScope, ionicToast, Logger) { + var localNotify = {}; - /* - * Return the state to redirect to, undefined otherwise - */ - localNotify.getRedirectState = function(data) { - // TODO: Think whether this should be in data or in category - if (angular.isDefined(data)) { - return [data.redirectTo, data.redirectParams]; - } - return undefined; - } + /* + * Return the state to redirect to, undefined otherwise + */ + localNotify.getRedirectState = function (data) { + // TODO: Think whether this should be in data or in category + if (angular.isDefined(data)) { + return [data.redirectTo, data.redirectParams]; + } + return undefined; + }; - localNotify.handleLaunch = function(targetState, targetParams) { - $rootScope.redirectTo = targetState; - $rootScope.redirectParams = targetParams; - $state.go(targetState, targetParams, { reload : true }); - } + localNotify.handleLaunch = function (targetState, targetParams) { + $rootScope.redirectTo = targetState; + $rootScope.redirectParams = targetParams; + $state.go(targetState, targetParams, { reload: true }); + }; - localNotify.handlePrompt = function(notification, targetState, targetParams) { - Logger.log("Prompting for notification "+notification.title+" and text "+notification.text); - var promptPromise = $ionicPopup.show({title: notification.title, - template: notification.text, - buttons: [{ - text: 'Handle', - type: 'button-positive', - onTap: function(e) { - // e.preventDefault() will stop the popup from closing when tapped. - return true; + localNotify.handlePrompt = function (notification, targetState, targetParams) { + Logger.log( + 'Prompting for notification ' + notification.title + ' and text ' + notification.text, + ); + var promptPromise = $ionicPopup.show({ + title: notification.title, + template: notification.text, + buttons: [ + { + text: 'Handle', + type: 'button-positive', + onTap: function (e) { + // e.preventDefault() will stop the popup from closing when tapped. + return true; + }, + }, + { + text: 'Ignore', + type: 'button-positive', + onTap: function (e) { + return false; + }, + }, + ], + }); + promptPromise.then(function (handle) { + if (handle == true) { + localNotify.handleLaunch(targetState, targetParams); + } else { + Logger.log( + 'Ignoring notification ' + notification.title + ' and text ' + notification.text, + ); } - }, { - text: 'Ignore', - type: 'button-positive', - onTap: function(e) { - return false; - } - }] - }); - promptPromise.then(function(handle) { - if (handle == true) { - localNotify.handleLaunch(targetState, targetParams); - } else { - Logger.log("Ignoring notification "+notification.title+" and text "+notification.text); - } - }); - } + }); + }; - localNotify.handleNotification = function(notification,state,data) { - // Comment this out for ease of testing. But in the real world, we do in fact want to - // cancel the notification to avoid "hey! I just fixed this, why is the notification still around!" - // issues - // $window.cordova.plugins.notification.local.cancel(notification.id); - let redirectData = notification; - if (state.event == 'action') { - redirectData = notification.data.action; - } - var [targetState, targetParams] = localNotify.getRedirectState(redirectData); - Logger.log("targetState = "+targetState); - if (angular.isDefined(targetState)) { - if (state.foreground == true) { - localNotify.handlePrompt(notification, targetState, targetParams); - } else { - localNotify.handleLaunch(targetState, targetParams); - } - } - } + localNotify.handleNotification = function (notification, state, data) { + // Comment this out for ease of testing. But in the real world, we do in fact want to + // cancel the notification to avoid "hey! I just fixed this, why is the notification still around!" + // issues + // $window.cordova.plugins.notification.local.cancel(notification.id); + let redirectData = notification; + if (state.event == 'action') { + redirectData = notification.data.action; + } + var [targetState, targetParams] = localNotify.getRedirectState(redirectData); + Logger.log('targetState = ' + targetState); + if (angular.isDefined(targetState)) { + if (state.foreground == true) { + localNotify.handlePrompt(notification, targetState, targetParams); + } else { + localNotify.handleLaunch(targetState, targetParams); + } + } + }; - localNotify.registerRedirectHandler = function() { - Logger.log( "registerUserResponse received!" ); - $window.cordova.plugins.notification.local.on('action', function (notification, state, data) { - localNotify.handleNotification(notification, state, data); - }); - $window.cordova.plugins.notification.local.on('clear', function (notification, state, data) { - // alert("notification cleared, no report"); - }); - $window.cordova.plugins.notification.local.on('cancel', function (notification, state, data) { - // alert("notification cancelled, no report"); - }); - $window.cordova.plugins.notification.local.on('trigger', function (notification, state, data) { - ionicToast.show(`Notification: ${notification.title}\n${notification.text}`, 'bottom', false, 250000); - localNotify.handleNotification(notification, state, data); - }); - $window.cordova.plugins.notification.local.on('click', function (notification, state, data) { - localNotify.handleNotification(notification, state, data); - }); - } + localNotify.registerRedirectHandler = function () { + Logger.log('registerUserResponse received!'); + $window.cordova.plugins.notification.local.on( + 'action', + function (notification, state, data) { + localNotify.handleNotification(notification, state, data); + }, + ); + $window.cordova.plugins.notification.local.on( + 'clear', + function (notification, state, data) { + // alert("notification cleared, no report"); + }, + ); + $window.cordova.plugins.notification.local.on( + 'cancel', + function (notification, state, data) { + // alert("notification cancelled, no report"); + }, + ); + $window.cordova.plugins.notification.local.on( + 'trigger', + function (notification, state, data) { + ionicToast.show( + `Notification: ${notification.title}\n${notification.text}`, + 'bottom', + false, + 250000, + ); + localNotify.handleNotification(notification, state, data); + }, + ); + $window.cordova.plugins.notification.local.on( + 'click', + function (notification, state, data) { + localNotify.handleNotification(notification, state, data); + }, + ); + }; - $ionicPlatform.ready().then(function() { - localNotify.registerRedirectHandler(); - Logger.log("finished registering handlers, about to fire queued events"); - $window.cordova.plugins.notification.local.fireQueuedEvents(); - }); + $ionicPlatform.ready().then(function () { + localNotify.registerRedirectHandler(); + Logger.log('finished registering handlers, about to fire queued events'); + $window.cordova.plugins.notification.local.fireQueuedEvents(); + }); - return localNotify; -}); + return localNotify; + }, + ); diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 22f8407ee..9ceb0a23e 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -5,12 +5,10 @@ import { getConfig } from '../config/dynamicConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; import { getUser, updateUser } from '../commHelper'; -angular.module('emission.splash.notifscheduler', - ['emission.services', - 'emission.plugin.logger']) - -.factory('NotificationScheduler', function($http, $window, $ionicPlatform, Logger) { +angular + .module('emission.splash.notifscheduler', ['emission.services', 'emission.plugin.logger']) + .factory('NotificationScheduler', function ($http, $window, $ionicPlatform, Logger) { const scheduler = {}; let _config; let scheduledPromise = new Promise((rs) => rs()); @@ -18,36 +16,36 @@ angular.module('emission.splash.notifscheduler', // like python range() function range(start, stop, step) { - let a = [start], b = start; - while (b < stop) - a.push(b += step || 1); - return a; + let a = [start], + b = start; + while (b < stop) a.push((b += step || 1)); + return a; } // returns an array of moment objects, for all times that notifications should be sent const calcNotifTimes = (scheme, dayZeroDate, timeOfDay) => { - const notifTimes = []; - for (const s of scheme.schedule) { - // the days to send notifications, as integers, relative to day zero - const notifDays = range(s.start, s.end, s.intervalInDays); - for (const d of notifDays) { - const date = moment(dayZeroDate).add(d, 'days').format('YYYY-MM-DD') - const notifTime = moment(date+' '+timeOfDay, 'YYYY-MM-DD HH:mm'); - notifTimes.push(notifTime); - } + const notifTimes = []; + for (const s of scheme.schedule) { + // the days to send notifications, as integers, relative to day zero + const notifDays = range(s.start, s.end, s.intervalInDays); + for (const d of notifDays) { + const date = moment(dayZeroDate).add(d, 'days').format('YYYY-MM-DD'); + const notifTime = moment(date + ' ' + timeOfDay, 'YYYY-MM-DD HH:mm'); + notifTimes.push(notifTime); } - return notifTimes; - } + } + return notifTimes; + }; // returns true if all expected times are already scheduled const areAlreadyScheduled = (notifs, expectedTimes) => { - for (const t of expectedTimes) { - if (!notifs.some((n) => moment(n.at).isSame(t))) { - return false; - } + for (const t of expectedTimes) { + if (!notifs.some((n) => moment(n.at).isSame(t))) { + return false; } - return true; - } + } + return true; + }; /* remove notif actions as they do not work, can restore post routing migration */ // const setUpActions = () => { @@ -62,155 +60,155 @@ angular.module('emission.splash.notifscheduler', // } function debugGetScheduled(prefix) { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length) - return Logger.log(`${prefix}, there are no scheduled notifications`); - const time = moment(notifs?.[0].trigger.at).format('HH:mm'); - //was in plugin, changed to scheduler - scheduler.scheduledNotifs = notifs.map((n) => { - const time = moment(n.trigger.at).format('LT'); - const date = moment(n.trigger.at).format('LL'); - return { - key: date, - val: time - } - }); - //have the list of scheduled show up in this log - Logger.log(`${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduler.scheduledNotifs[0].key} at ${scheduler.scheduledNotifs[0].val}`); + cordova.plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) return Logger.log(`${prefix}, there are no scheduled notifications`); + const time = moment(notifs?.[0].trigger.at).format('HH:mm'); + //was in plugin, changed to scheduler + scheduler.scheduledNotifs = notifs.map((n) => { + const time = moment(n.trigger.at).format('LT'); + const date = moment(n.trigger.at).format('LL'); + return { + key: date, + val: time, + }; }); + //have the list of scheduled show up in this log + Logger.log( + `${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduler.scheduledNotifs[0].key} at ${scheduler.scheduledNotifs[0].val}`, + ); + }); } //new method to fetch notifications - scheduler.getScheduledNotifs = function() { - return new Promise((resolve, reject) => { - /* if the notifications are still in active scheduling it causes problems + scheduler.getScheduledNotifs = function () { + return new Promise((resolve, reject) => { + /* if the notifications are still in active scheduling it causes problems anywhere from 0-n of the scheduled notifs are displayed if actively scheduling, wait for the scheduledPromise to resolve before fetching prevents such errors */ - if(isScheduling) - { - console.log("requesting fetch while still actively scheduling, waiting on scheduledPromise"); - scheduledPromise.then(() => { - getNotifs().then((notifs) => { - console.log("done scheduling notifs", notifs); - resolve(notifs); - }) - }) - } - else{ - getNotifs().then((notifs) => { - resolve(notifs); - }) - } - }) - } + if (isScheduling) { + console.log( + 'requesting fetch while still actively scheduling, waiting on scheduledPromise', + ); + scheduledPromise.then(() => { + getNotifs().then((notifs) => { + console.log('done scheduling notifs', notifs); + resolve(notifs); + }); + }); + } else { + getNotifs().then((notifs) => { + resolve(notifs); + }); + } + }); + }; //get scheduled notifications from cordova plugin and format them - const getNotifs = function() { - return new Promise((resolve, reject) => { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length){ - console.log("there are no notifications"); - resolve([]); //if none, return empty array - } - - const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing - let scheduledNotifs = []; - scheduledNotifs = notifSubset.map((n) => { - const time = moment(n.trigger.at).format('LT'); - const date = moment(n.trigger.at).format('LL'); - return { - key: date, - val: time - } - }); - resolve(scheduledNotifs); - }); - }) - } + const getNotifs = function () { + return new Promise((resolve, reject) => { + cordova.plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) { + console.log('there are no notifications'); + resolve([]); //if none, return empty array + } + + const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing + let scheduledNotifs = []; + scheduledNotifs = notifSubset.map((n) => { + const time = moment(n.trigger.at).format('LT'); + const date = moment(n.trigger.at).format('LL'); + return { + key: date, + val: time, + }; + }); + resolve(scheduledNotifs); + }); + }); + }; // schedules the notifications using the cordova plugin const scheduleNotifs = (scheme, notifTimes) => { - return new Promise((rs) => { - isScheduling = true; - const localeCode = i18next.resolvedLanguage; - const nots = notifTimes.map((n) => { - const nDate = n.toDate(); - const seconds = nDate.getTime() / 1000; - return { - id: seconds, - title: scheme.title[localeCode], - text: scheme.text[localeCode], - trigger: {at: nDate}, - // actions: 'reminder-actions', - // data: { - // action: { - // redirectTo: 'root.main.control', - // redirectParams: { - // openTimeOfDayPicker: true - // } - // } - // } - } - }); - cordova.plugins.notification.local.cancelAll(() => { - debugGetScheduled("After cancelling"); - cordova.plugins.notification.local.schedule(nots, () => { - debugGetScheduled("After scheduling"); - isScheduling = false; - rs(); //scheduling promise resolved here - }); - }); + return new Promise((rs) => { + isScheduling = true; + const localeCode = i18next.resolvedLanguage; + const nots = notifTimes.map((n) => { + const nDate = n.toDate(); + const seconds = nDate.getTime() / 1000; + return { + id: seconds, + title: scheme.title[localeCode], + text: scheme.text[localeCode], + trigger: { at: nDate }, + // actions: 'reminder-actions', + // data: { + // action: { + // redirectTo: 'root.main.control', + // redirectParams: { + // openTimeOfDayPicker: true + // } + // } + // } + }; }); - } + cordova.plugins.notification.local.cancelAll(() => { + debugGetScheduled('After cancelling'); + cordova.plugins.notification.local.schedule(nots, () => { + debugGetScheduled('After scheduling'); + isScheduling = false; + rs(); //scheduling promise resolved here + }); + }); + }); + }; // determines when notifications are needed, and schedules them if not already scheduled const update = async () => { - const { reminder_assignment, - reminder_join_date, - reminder_time_of_day} = await scheduler.getReminderPrefs(); - const scheme = _config.reminderSchemes[reminder_assignment]; - const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = + await scheduler.getReminderPrefs(); + const scheme = _config.reminderSchemes[reminder_assignment]; + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); - return new Promise((resolve, reject) => { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (areAlreadyScheduled(notifs, notifTimes)) { - Logger.log("Already scheduled, not scheduling again"); - } else { - // to ensure we don't overlap with the last scheduling() request, - // we'll wait for the previous one to finish before scheduling again - scheduledPromise.then(() => { - if (isScheduling) { - console.log("ERROR: Already scheduling notifications, not scheduling again") - } else { - scheduledPromise = scheduleNotifs(scheme, notifTimes); - //enforcing end of scheduling to conisder update through - scheduledPromise.then(() => { - resolve(); - }) - } - }); - } + return new Promise((resolve, reject) => { + cordova.plugins.notification.local.getScheduled((notifs) => { + if (areAlreadyScheduled(notifs, notifTimes)) { + Logger.log('Already scheduled, not scheduling again'); + } else { + // to ensure we don't overlap with the last scheduling() request, + // we'll wait for the previous one to finish before scheduling again + scheduledPromise.then(() => { + if (isScheduling) { + console.log('ERROR: Already scheduling notifications, not scheduling again'); + } else { + scheduledPromise = scheduleNotifs(scheme, notifTimes); + //enforcing end of scheduling to conisder update through + scheduledPromise.then(() => { + resolve(); + }); + } }); + } }); - } + }); + }; /* Randomly assign a scheme, set the join date to today, and use the default time of day from config (or noon if not specified) This is only called once when the user first joins the study */ const initReminderPrefs = () => { - // randomly assign from the schemes listed in config - const schemes = Object.keys(_config.reminderSchemes); - const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; - const todayDate = moment().format('YYYY-MM-DD'); - const defaultTime = _config.reminderSchemes[randAssignment]?.defaultTime || '12:00'; - return { - reminder_assignment: randAssignment, - reminder_join_date: todayDate, - reminder_time_of_day: defaultTime, - }; - } + // randomly assign from the schemes listed in config + const schemes = Object.keys(_config.reminderSchemes); + const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; + const todayDate = moment().format('YYYY-MM-DD'); + const defaultTime = _config.reminderSchemes[randAssignment]?.defaultTime || '12:00'; + return { + reminder_assignment: randAssignment, + reminder_join_date: todayDate, + reminder_time_of_day: defaultTime, + }; + }; /* EXAMPLE VALUES - present in user profile object reminder_assignment: 'passive', @@ -219,53 +217,49 @@ angular.module('emission.splash.notifscheduler', */ scheduler.getReminderPrefs = async () => { - const user = await getUser(); - if (user?.reminder_assignment && - user?.reminder_join_date && - user?.reminder_time_of_day) { - return user; - } - // if no prefs, user just joined, so initialize them - const initPrefs = initReminderPrefs(); - await scheduler.setReminderPrefs(initPrefs); - return { ...user, ...initPrefs }; // user profile + the new prefs - } + const user = await getUser(); + if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { + return user; + } + // if no prefs, user just joined, so initialize them + const initPrefs = initReminderPrefs(); + await scheduler.setReminderPrefs(initPrefs); + return { ...user, ...initPrefs }; // user profile + the new prefs + }; scheduler.setReminderPrefs = async (newPrefs) => { - await updateUser(newPrefs) - const updatePromise = new Promise((resolve, reject) => { - //enforcing update before moving on - update().then(() => { - resolve(); - }); + await updateUser(newPrefs); + const updatePromise = new Promise((resolve, reject) => { + //enforcing update before moving on + update().then(() => { + resolve(); }); + }); - // record the new prefs in client stats - scheduler.getReminderPrefs().then((prefs) => { - // extract only the relevant fields from the prefs, - // and add as a reading to client stats - const { reminder_assignment, - reminder_join_date, - reminder_time_of_day} = prefs; - addStatReading(statKeys.REMINDER_PREFS, { - reminder_assignment, - reminder_join_date, - reminder_time_of_day - }).then(Logger.log("Added reminder prefs to client stats")); - }); + // record the new prefs in client stats + scheduler.getReminderPrefs().then((prefs) => { + // extract only the relevant fields from the prefs, + // and add as a reading to client stats + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; + addStatReading(statKeys.REMINDER_PREFS, { + reminder_assignment, + reminder_join_date, + reminder_time_of_day, + }).then(Logger.log('Added reminder prefs to client stats')); + }); - return updatePromise; - } + return updatePromise; + }; $ionicPlatform.ready().then(async () => { - _config = await getConfig(); - if (!_config.reminderSchemes) { - Logger.log("No reminder schemes found in config, not scheduling notifications"); - return; - } - //setUpActions(); - update(); + _config = await getConfig(); + if (!_config.reminderSchemes) { + Logger.log('No reminder schemes found in config, not scheduling notifications'); + return; + } + //setUpActions(); + update(); }); return scheduler; -}); + }); diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 775ddc4bd..28e37aaa1 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -1,7 +1,6 @@ //naming of this file can be a little confusing - "pushnotifysettings" for rewritten file //https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1375360832 - /* * This module deals with the interaction with the push plugin, the redirection * of silent push notifications and the re-parsing of iOS pushes. It then @@ -21,160 +20,174 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; import { readConsentState, isConsented } from './startprefs'; -angular.module('emission.splash.pushnotify', ['emission.plugin.logger', - 'emission.services']) -.factory('PushNotify', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger) { - - var pushnotify = {}; - var push = null; - pushnotify.CLOUD_NOTIFICATION_EVENT = 'cloud:push:notification'; +angular + .module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services']) + .factory( + 'PushNotify', + function ($window, $state, $rootScope, $ionicPlatform, $ionicPopup, Logger) { + var pushnotify = {}; + var push = null; + pushnotify.CLOUD_NOTIFICATION_EVENT = 'cloud:push:notification'; - pushnotify.startupInit = function() { - push = $window.PushNotification.init({ - "ios": { - "badge": true, - "sound": true, - "vibration": true, - "clearBadge": true - }, - "android": { - "iconColor": "#008acf", - "icon": "ic_mood_question", - "clearNotifications": true - } - }); - push.on('notification', function(data) { - if ($ionicPlatform.is('ios')) { + pushnotify.startupInit = function () { + push = $window.PushNotification.init({ + ios: { + badge: true, + sound: true, + vibration: true, + clearBadge: true, + }, + android: { + iconColor: '#008acf', + icon: 'ic_mood_question', + clearNotifications: true, + }, + }); + push.on('notification', function (data) { + if ($ionicPlatform.is('ios')) { // Parse the iOS values that are returned as strings - if(angular.isDefined(data) && - angular.isDefined(data.additionalData)) { - if(angular.isDefined(data.additionalData.payload)) { - data.additionalData.payload = JSON.parse(data.additionalData.payload); - } - if(angular.isDefined(data.additionalData.data) && typeof(data.additionalData.data) == "string") { - data.additionalData.data = JSON.parse(data.additionalData.data); - } else { - console.log("additionalData is already an object, no need to parse it"); - } + if (angular.isDefined(data) && angular.isDefined(data.additionalData)) { + if (angular.isDefined(data.additionalData.payload)) { + data.additionalData.payload = JSON.parse(data.additionalData.payload); + } + if ( + angular.isDefined(data.additionalData.data) && + typeof data.additionalData.data == 'string' + ) { + data.additionalData.data = JSON.parse(data.additionalData.data); + } else { + console.log('additionalData is already an object, no need to parse it'); + } } else { - Logger.log("No additional data defined, nothing to parse"); + Logger.log('No additional data defined, nothing to parse'); } - } - $rootScope.$emit(pushnotify.CLOUD_NOTIFICATION_EVENT, data); - }); - } + } + $rootScope.$emit(pushnotify.CLOUD_NOTIFICATION_EVENT, data); + }); + }; - pushnotify.registerPromise = function() { - return new Promise(function(resolve, reject) { - pushnotify.startupInit(); - push.on("registration", function(data) { - console.log("Got registration " + data); - resolve({token: data.registrationId, - type: data.registrationType}); - }); - push.on("error", function(error) { - console.log("Got push error " + error); - reject(error); - }); - console.log("push notify = "+push); + pushnotify.registerPromise = function () { + return new Promise(function (resolve, reject) { + pushnotify.startupInit(); + push.on('registration', function (data) { + console.log('Got registration ' + data); + resolve({ token: data.registrationId, type: data.registrationType }); + }); + push.on('error', function (error) { + console.log('Got push error ' + error); + reject(error); + }); + console.log('push notify = ' + push); }); - } + }; - pushnotify.registerPush = function() { - pushnotify.registerPromise().then(function(t) { - // alert("Token = "+JSON.stringify(t)); - Logger.log("Token = "+JSON.stringify(t)); - return $window.cordova.plugins.BEMServerSync.getConfig().then(function(config) { - return config.sync_interval; - }, function(error) { - console.log("Got error "+error+" while reading config, returning default = 3600"); - return 3600; - }).then(function(sync_interval) { - updateUser({ - device_token: t.token, - curr_platform: ionic.Platform.platform(), - curr_sync_interval: sync_interval - }); - return t; - }); - }).then(function(t) { - // alert("Finished saving token = "+JSON.stringify(t.token)); - Logger.log("Finished saving token = "+JSON.stringify(t.token)); - }).catch(function(error) { - Logger.displayError("Error in registering push notifications", error); - }); - } + pushnotify.registerPush = function () { + pushnotify + .registerPromise() + .then(function (t) { + // alert("Token = "+JSON.stringify(t)); + Logger.log('Token = ' + JSON.stringify(t)); + return $window.cordova.plugins.BEMServerSync.getConfig() + .then( + function (config) { + return config.sync_interval; + }, + function (error) { + console.log( + 'Got error ' + error + ' while reading config, returning default = 3600', + ); + return 3600; + }, + ) + .then(function (sync_interval) { + updateUser({ + device_token: t.token, + curr_platform: ionic.Platform.platform(), + curr_sync_interval: sync_interval, + }); + return t; + }); + }) + .then(function (t) { + // alert("Finished saving token = "+JSON.stringify(t.token)); + Logger.log('Finished saving token = ' + JSON.stringify(t.token)); + }) + .catch(function (error) { + Logger.displayError('Error in registering push notifications', error); + }); + }; - var redirectSilentPush = function(event, data) { - Logger.log("Found silent push notification, for platform "+ionic.Platform.platform()); + var redirectSilentPush = function (event, data) { + Logger.log('Found silent push notification, for platform ' + ionic.Platform.platform()); if (!$ionicPlatform.is('ios')) { - Logger.log("Platform is not ios, handleSilentPush is not implemented or needed"); + Logger.log('Platform is not ios, handleSilentPush is not implemented or needed'); // doesn't matter if we finish or not because platforms other than ios don't care return; } - Logger.log("Platform is ios, calling handleSilentPush on DataCollection"); + Logger.log('Platform is ios, calling handleSilentPush on DataCollection'); var notId = data.additionalData.payload.notId; - var finishErrFn = function(error) { - Logger.log("in push.finish, error = "+error); + var finishErrFn = function (error) { + Logger.log('in push.finish, error = ' + error); }; - pushnotify.datacollect.getConfig().then(function(config) { - if(config.ios_use_remote_push_for_sync) { - pushnotify.datacollect.handleSilentPush() - .then(function() { - Logger.log("silent push finished successfully, calling push.finish"); - showDebugLocalNotification("silent push finished, calling push.finish"); - push.finish(function(){}, finishErrFn, notId); - }) - } else { - Logger.log("Using background fetch for sync, no need to redirect push"); - push.finish(function(){}, finishErrFn, notId); - }; - }) - .catch(function(error) { - push.finish(function(){}, finishErrFn, notId); - Logger.displayError("Error while redirecting silent push", error); - }); - } - - var showDebugLocalNotification = function(message) { - pushnotify.datacollect.getConfig().then(function(config) { - if(config.simulate_user_interaction) { - cordova.plugins.notification.local.schedule({ - id: 1, - title: "Debug javascript notification", - text: message, - actions: [], - category: 'SIGN_IN_TO_CLASS' + pushnotify.datacollect + .getConfig() + .then(function (config) { + if (config.ios_use_remote_push_for_sync) { + pushnotify.datacollect.handleSilentPush().then(function () { + Logger.log('silent push finished successfully, calling push.finish'); + showDebugLocalNotification('silent push finished, calling push.finish'); + push.finish(function () {}, finishErrFn, notId); }); + } else { + Logger.log('Using background fetch for sync, no need to redirect push'); + push.finish(function () {}, finishErrFn, notId); } + }) + .catch(function (error) { + push.finish(function () {}, finishErrFn, notId); + Logger.displayError('Error while redirecting silent push', error); + }); + }; + + var showDebugLocalNotification = function (message) { + pushnotify.datacollect.getConfig().then(function (config) { + if (config.simulate_user_interaction) { + cordova.plugins.notification.local.schedule({ + id: 1, + title: 'Debug javascript notification', + text: message, + actions: [], + category: 'SIGN_IN_TO_CLASS', + }); + } }); - } + }; - pushnotify.registerNotificationHandler = function() { - $rootScope.$on(pushnotify.CLOUD_NOTIFICATION_EVENT, function(event, data) { - Logger.log("data = "+JSON.stringify(data)); - if (data.additionalData["content-available"] == 1) { - redirectSilentPush(event, data); - }; // else no need to call finish - }); - }; + pushnotify.registerNotificationHandler = function () { + $rootScope.$on(pushnotify.CLOUD_NOTIFICATION_EVENT, function (event, data) { + Logger.log('data = ' + JSON.stringify(data)); + if (data.additionalData['content-available'] == 1) { + redirectSilentPush(event, data); + } // else no need to call finish + }); + }; - $ionicPlatform.ready().then(function() { - pushnotify.datacollect = $window.cordova.plugins.BEMDataCollection; - readConsentState() - .then(isConsented) - .then(function(consentState) { - if (consentState == true) { + $ionicPlatform.ready().then(function () { + pushnotify.datacollect = $window.cordova.plugins.BEMDataCollection; + readConsentState() + .then(isConsented) + .then(function (consentState) { + if (consentState == true) { pushnotify.registerPush(); - } else { - Logger.log("no consent yet, waiting to sign up for remote push"); - } - }); - pushnotify.registerNotificationHandler(); - Logger.log("pushnotify startup done"); - }); + } else { + Logger.log('no consent yet, waiting to sign up for remote push'); + } + }); + pushnotify.registerNotificationHandler(); + Logger.log('pushnotify startup done'); + }); - return pushnotify; -}); + return pushnotify; + }, + ); diff --git a/www/js/splash/referral.js b/www/js/splash/referral.js index 849de847a..334fd0ebe 100644 --- a/www/js/splash/referral.js +++ b/www/js/splash/referral.js @@ -1,9 +1,10 @@ import angular from 'angular'; import { storageGetDirect, storageRemove, storageSet } from '../plugin/storage'; -angular.module('emission.splash.referral', []) +angular + .module('emission.splash.referral', []) -.factory('ReferralHandler', function($window) { + .factory('ReferralHandler', function ($window) { var referralHandler = {}; var REFERRAL_NAVIGATION_KEY = 'referral_navigation'; @@ -11,34 +12,33 @@ angular.module('emission.splash.referral', []) var REFERRED_GROUP_ID = 'referred_group_id'; var REFERRED_USER_ID = 'referred_user_id'; - referralHandler.getReferralNavigation = function() { + referralHandler.getReferralNavigation = function () { const toReturn = storageGetDirect(REFERRAL_NAVIGATION_KEY); storageRemove(REFERRAL_NAVIGATION_KEY); return toReturn; - } - - referralHandler.setupGroupReferral = function(kvList) { - storageSet(REFERRED_KEY, true); - storageSet(REFERRED_GROUP_ID, kvList['groupid']); - storageSet(REFERRED_USER_ID, kvList['userid']); - storageSet(REFERRAL_NAVIGATION_KEY, 'goals'); - }; - - referralHandler.clearGroupReferral = function(kvList) { - storageRemove(REFERRED_KEY); - storageRemove(REFERRED_GROUP_ID); - storageRemove(REFERRED_USER_ID); - storageRemove(REFERRAL_NAVIGATION_KEY); - }; - - referralHandler.getReferralParams = function(kvList) { - return [storageGetDirect(REFERRED_GROUP_ID), - storageGetDirect(REFERRED_USER_ID)]; - } - - referralHandler.hasPendingRegistration = function() { - return storageGetDirect(REFERRED_KEY) - }; - - return referralHandler; -}); + }; + + referralHandler.setupGroupReferral = function (kvList) { + storageSet(REFERRED_KEY, true); + storageSet(REFERRED_GROUP_ID, kvList['groupid']); + storageSet(REFERRED_USER_ID, kvList['userid']); + storageSet(REFERRAL_NAVIGATION_KEY, 'goals'); + }; + + referralHandler.clearGroupReferral = function (kvList) { + storageRemove(REFERRED_KEY); + storageRemove(REFERRED_GROUP_ID); + storageRemove(REFERRED_USER_ID); + storageRemove(REFERRAL_NAVIGATION_KEY); + }; + + referralHandler.getReferralParams = function (kvList) { + return [storageGetDirect(REFERRED_GROUP_ID), storageGetDirect(REFERRED_USER_ID)]; + }; + + referralHandler.hasPendingRegistration = function () { + return storageGetDirect(REFERRED_KEY); + }; + + return referralHandler; + }); diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index f67cb9d87..a59fdf376 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -18,62 +18,74 @@ import angular from 'angular'; import { addStatEvent, statKeys } from '../plugin/clientStats'; -angular.module('emission.splash.remotenotify', ['emission.plugin.logger']) - -.factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, Logger) { +angular + .module('emission.splash.remotenotify', ['emission.plugin.logger']) + .factory('RemoteNotify', function ($http, $window, $ionicPopup, $rootScope, Logger) { var remoteNotify = {}; - remoteNotify.options = "location=yes,clearcache=no,toolbar=yes,hideurlbar=yes"; + remoteNotify.options = 'location=yes,clearcache=no,toolbar=yes,hideurlbar=yes'; /* TODO: Potentially unify with the survey URL loading */ - remoteNotify.launchWebpage = function(url) { + remoteNotify.launchWebpage = function (url) { // THIS LINE FOR inAppBrowser let iab = $window.cordova.InAppBrowser.open(url, '_blank', remoteNotify.options); - } + }; - remoteNotify.launchPopup = function(title, text) { + remoteNotify.launchPopup = function (title, text) { // THIS LINE FOR inAppBrowser let alertPopup = $ionicPopup.alert({ title: title, - template: text + template: text, }); - } + }; - remoteNotify.init = function() { - $rootScope.$on('cloud:push:notification', function(event, data) { + remoteNotify.init = function () { + $rootScope.$on('cloud:push:notification', function (event, data) { addStatEvent(statKeys.NOTIFICATION_OPEN).then(() => { - console.log("Added "+statKeys.NOTIFICATION_OPEN+" event. Data = " + JSON.stringify(data)); + console.log( + 'Added ' + statKeys.NOTIFICATION_OPEN + ' event. Data = ' + JSON.stringify(data), + ); }); - Logger.log("data = "+JSON.stringify(data)); - if (angular.isDefined(data.additionalData) && - angular.isDefined(data.additionalData.payload) && - angular.isDefined(data.additionalData.payload.alert_type)) { - if(data.additionalData.payload.alert_type == "website") { - var webpage_spec = data.additionalData.payload.spec; - if (angular.isDefined(webpage_spec) && - angular.isDefined(webpage_spec.url) && - webpage_spec.url.startsWith("https://")) { - remoteNotify.launchWebpage(webpage_spec.url); - } else { - $ionicPopup.alert("webpage was not specified correctly. spec is "+JSON.stringify(webpage_spec)); - } + Logger.log('data = ' + JSON.stringify(data)); + if ( + angular.isDefined(data.additionalData) && + angular.isDefined(data.additionalData.payload) && + angular.isDefined(data.additionalData.payload.alert_type) + ) { + if (data.additionalData.payload.alert_type == 'website') { + var webpage_spec = data.additionalData.payload.spec; + if ( + angular.isDefined(webpage_spec) && + angular.isDefined(webpage_spec.url) && + webpage_spec.url.startsWith('https://') + ) { + remoteNotify.launchWebpage(webpage_spec.url); + } else { + $ionicPopup.alert( + 'webpage was not specified correctly. spec is ' + JSON.stringify(webpage_spec), + ); } - if(data.additionalData.payload.alert_type == "popup") { - var popup_spec = data.additionalData.payload.spec; - if (angular.isDefined(popup_spec) && - angular.isDefined(popup_spec.title) && - angular.isDefined(popup_spec.text)) { - remoteNotify.launchPopup(popup_spec.title, popup_spec.text); - } else { - $ionicPopup.alert("webpage was not specified correctly. spec is "+JSON.stringify(popup_spec)); - } + } + if (data.additionalData.payload.alert_type == 'popup') { + var popup_spec = data.additionalData.payload.spec; + if ( + angular.isDefined(popup_spec) && + angular.isDefined(popup_spec.title) && + angular.isDefined(popup_spec.text) + ) { + remoteNotify.launchPopup(popup_spec.title, popup_spec.text); + } else { + $ionicPopup.alert( + 'webpage was not specified correctly. spec is ' + JSON.stringify(popup_spec), + ); } + } } }); - } + }; remoteNotify.init(); return remoteNotify; -}); + }); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 43f29c692..75282bfd3 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,7 +1,7 @@ -import { getAngularService } from "../angular-react-helper"; +import { getAngularService } from '../angular-react-helper'; import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; -import { readIntroDone } from "../onboarding/onboardingHelper"; +import { readIntroDone } from '../onboarding/onboardingHelper'; // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB @@ -13,59 +13,64 @@ let _curr_consented; /** * @function writes the consent document to native storage * @returns Promise to execute the write to storage -*/ + */ function writeConsentToNative() { - //note that this calls to the notification API, + //note that this calls to the notification API, //so should not be called until we have notification permissions //see https://github.com/e-mission/e-mission-docs/issues/1006 return window['cordova'].plugins.BEMDataCollection.markConsented(_req_consent); -}; +} /** * @function marks consent in native storage, local storage, and local var * @returns Promise for marking the consent in native and local storage */ export function markConsented() { - logInfo("changing consent from " + - _curr_consented + " -> " + JSON.stringify(_req_consent)); + logInfo('changing consent from ' + _curr_consented + ' -> ' + JSON.stringify(_req_consent)); // mark in native storage - return readConsentState() - .then(writeConsentToNative) - .then(function (response) { - // mark in local storage - storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, - _req_consent); - // mark in local variable as well - _curr_consented = { ..._req_consent }; - }) - //check for reconsent - .then(readIntroDone) - .then((isIntroDone) => { - if (isIntroDone) { - logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") - const PushNotify = getAngularService("PushNotify"); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - PushNotify.registerPush(); - StoreSeviceSettings.storeDeviceSettings(); - } - }) - .catch((error) => { - displayErrorMsg(error, "Error while while wrting consent to storage"); - }); -}; + return ( + readConsentState() + .then(writeConsentToNative) + .then(function (response) { + // mark in local storage + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, _req_consent); + // mark in local variable as well + _curr_consented = { ..._req_consent }; + }) + //check for reconsent + .then(readIntroDone) + .then((isIntroDone) => { + if (isIntroDone) { + logDebug( + 'reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings', + ); + const PushNotify = getAngularService('PushNotify'); + const StoreSeviceSettings = getAngularService('StoreDeviceSettings'); + PushNotify.registerPush(); + StoreSeviceSettings.storeDeviceSettings(); + } + }) + .catch((error) => { + displayErrorMsg(error, 'Error while while wrting consent to storage'); + }) + ); +} /** * @function checking for consent locally * @returns {boolean} if the consent is marked in the local var */ export function isConsented() { - logDebug("curr consented is" + JSON.stringify(_curr_consented)); - if (_curr_consented == null || _curr_consented == "" || - _curr_consented.approval_date != _req_consent.approval_date) { - logDebug("Not consented in local storage, need to show consent"); + logDebug('curr consented is' + JSON.stringify(_curr_consented)); + if ( + _curr_consented == null || + _curr_consented == '' || + _curr_consented.approval_date != _req_consent.approval_date + ) { + logDebug('Not consented in local storage, need to show consent'); return false; } else { - logDebug("Consented in local storage, no need to show consent"); + logDebug('Consented in local storage, no need to show consent'); return true; } } @@ -75,16 +80,21 @@ export function isConsented() { * @returns nothing, just reads into local variables */ export function readConsentState() { - return fetch("json/startupConfig.json") - .then(response => response.json()) + return fetch('json/startupConfig.json') + .then((response) => response.json()) .then(function (startupConfigResult) { console.log(startupConfigResult); _req_consent = startupConfigResult.emSensorDataCollectionProtocol; - logDebug("required consent version = " + JSON.stringify(_req_consent)); + logDebug('required consent version = ' + JSON.stringify(_req_consent)); return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); - }).then(function (kv_store_consent) { + }) + .then(function (kv_store_consent) { _curr_consented = kv_store_consent; - console.assert(((_req_consent != undefined) && (_req_consent != null)), "in readConsentState $rootScope.req_consent", JSON.stringify(_req_consent)); + console.assert( + _req_consent != undefined && _req_consent != null, + 'in readConsentState $rootScope.req_consent', + JSON.stringify(_req_consent), + ); // we can just launch this, we don't need to wait for it checkNativeConsent(); }); @@ -96,15 +106,16 @@ export function readConsentState() { */ //used in ProfileSettings export function getConsentDocument() { - return window['cordova'].plugins.BEMUserCache.getDocument("config/consent", false) - .then(function (resultDoc) { + return window['cordova'].plugins.BEMUserCache.getDocument('config/consent', false).then( + function (resultDoc) { if (window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc)) { return null; } else { return resultDoc; } - }); -}; + }, + ); +} /** * @function checks the consent doc in native storage @@ -114,11 +125,11 @@ function checkNativeConsent() { getConsentDocument().then(function (resultDoc) { if (resultDoc == null) { if (isConsented()) { - logDebug("Local consent found, native consent missing, writing consent to native"); - displayErrorMsg("Local consent found, native consent missing, writing consent to native"); + logDebug('Local consent found, native consent missing, writing consent to native'); + displayErrorMsg('Local consent found, native consent missing, writing consent to native'); return writeConsentToNative(); } else { - logDebug("Both local and native consent not found, nothing to sync"); + logDebug('Both local and native consent not found, nothing to sync'); } } }); diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index 31543bc6c..ab28cde2c 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,48 +1,53 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { isConsented, readConsentState } from "./startprefs"; +import { isConsented, readConsentState } from './startprefs'; -angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', - 'emission.services']) -.factory('StoreDeviceSettings', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger ) { +angular + .module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services']) + .factory( + 'StoreDeviceSettings', + function ($window, $state, $rootScope, $ionicPlatform, $ionicPopup, Logger) { + var storedevicesettings = {}; - var storedevicesettings = {}; + storedevicesettings.storeDeviceSettings = function () { + var lang = i18next.resolvedLanguage; + var manufacturer = $window.device.manufacturer; + var osver = $window.device.version; + return $window.cordova.getAppVersion + .getVersionNumber() + .then(function (appver) { + var updateJSON = { + phone_lang: lang, + curr_platform: ionic.Platform.platform(), + manufacturer: manufacturer, + client_os_version: osver, + client_app_version: appver, + }; + Logger.log('About to update profile with settings = ' + JSON.stringify(updateJSON)); + return updateUser(updateJSON); + }) + .then(function (updateJSON) { + // alert("Finished saving token = "+JSON.stringify(t.token)); + }) + .catch(function (error) { + Logger.displayError('Error in updating profile to store device settings', error); + }); + }; - storedevicesettings.storeDeviceSettings = function() { - var lang = i18next.resolvedLanguage; - var manufacturer = $window.device.manufacturer; - var osver = $window.device.version; - return $window.cordova.getAppVersion.getVersionNumber().then(function(appver) { - var updateJSON = { - phone_lang: lang, - curr_platform: ionic.Platform.platform(), - manufacturer: manufacturer, - client_os_version: osver, - client_app_version: appver - }; - Logger.log("About to update profile with settings = "+JSON.stringify(updateJSON)); - return updateUser(updateJSON); - }).then(function(updateJSON) { - // alert("Finished saving token = "+JSON.stringify(t.token)); - }).catch(function(error) { - Logger.displayError("Error in updating profile to store device settings", error); - }); - } - - $ionicPlatform.ready().then(function() { - storedevicesettings.datacollect = $window.cordova.plugins.BEMDataCollection; - readConsentState() - .then(isConsented) - .then(function(consentState) { - if (consentState == true) { + $ionicPlatform.ready().then(function () { + storedevicesettings.datacollect = $window.cordova.plugins.BEMDataCollection; + readConsentState() + .then(isConsented) + .then(function (consentState) { + if (consentState == true) { storedevicesettings.storeDeviceSettings(); - } else { - Logger.log("no consent yet, waiting to store device settings in profile"); - } - }); - Logger.log("storedevicesettings startup done"); - }); + } else { + Logger.log('no consent yet, waiting to store device settings in profile'); + } + }); + Logger.log('storedevicesettings startup done'); + }); - return storedevicesettings; -}); + return storedevicesettings; + }, + ); diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 1b85c728e..fb35951ee 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -7,23 +7,23 @@ The start and end times of the addition are determined by the survey response. */ -import React, { useEffect, useState, useContext } from "react"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import moment from "moment"; -import { LabelTabContext } from "../../diary/LabelTab"; -import EnketoModal from "./EnketoModal"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; +import React, { useEffect, useState, useContext } from 'react'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import moment from 'moment'; +import { LabelTabContext } from '../../diary/LabelTab'; +import EnketoModal from './EnketoModal'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; type Props = { - timelineEntry: any, - notesConfig: any, - storeKey: string, -} + timelineEntry: any; + notesConfig: any; + storeKey: string; +}; const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const { t, i18n } = useTranslation(); const [displayLabel, setDisplayLabel] = useState(''); - const { repopulateTimelineEntry } = useContext(LabelTabContext) + const { repopulateTimelineEntry } = useContext(LabelTabContext); useEffect(() => { let newLabel: string; @@ -39,20 +39,19 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { // return a dictionary of fields we want to prefill, using start/enter and end/exit times function getPrefillTimes() { - let begin = timelineEntry.start_ts || timelineEntry.enter_ts; let stop = timelineEntry.end_ts || timelineEntry.exit_ts; // if addition(s) already present on this timeline entry, `begin` where the last one left off - timelineEntry.additionsList.forEach(a => { - if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) - begin = a.data.end_ts; + timelineEntry.additionsList.forEach((a) => { + if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) begin = a.data.end_ts; }); - - const timezone = timelineEntry.start_local_dt?.timezone - || timelineEntry.enter_local_dt?.timezone - || timelineEntry.end_local_dt?.timezone - || timelineEntry.exit_local_dt?.timezone; + + const timezone = + timelineEntry.start_local_dt?.timezone || + timelineEntry.enter_local_dt?.timezone || + timelineEntry.end_local_dt?.timezone || + timelineEntry.exit_local_dt?.timezone; const momentBegin = begin ? moment(begin * 1000).tz(timezone) : null; const momentStop = stop ? moment(stop * 1000).tz(timezone) : null; @@ -80,11 +79,14 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { console.log('About to launch survey ', surveyName); setPrefillTimes(getPrefillTimes()); setModalVisible(true); - }; + } function onResponseSaved(result) { if (result) { - logDebug('AddNoteButton: response was saved, about to repopulateTimelineEntry; result=' + JSON.stringify(result)); + logDebug( + 'AddNoteButton: response was saved, about to repopulateTimelineEntry; result=' + + JSON.stringify(result), + ); repopulateTimelineEntry(timelineEntry._id.$oid); } else { displayErrorMsg('AddNoteButton: response was not saved, result=', result); @@ -94,19 +96,20 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const [prefillTimes, setPrefillTimes] = useState(null); const [modalVisible, setModalVisible] = useState(false); - return (<> - launchAddNoteSurvey()}> - {displayLabel} - - setModalVisible(false)} - onResponseSaved={onResponseSaved} - surveyName={notesConfig?.surveyName} - opts={{ timelineEntry, - dataKey: storeKey, - prefillFields: prefillTimes - }} /> - ); + return ( + <> + launchAddNoteSurvey()}> + {displayLabel} + + setModalVisible(false)} + onResponseSaved={onResponseSaved} + surveyName={notesConfig?.surveyName} + opts={{ timelineEntry, dataKey: storeKey, prefillFields: prefillTimes }} + /> + + ); }; export default AddNoteButton; diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index e29278cca..f1563c4a9 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -2,22 +2,21 @@ Notes are added from the AddNoteButton and are derived from survey responses. */ -import React, { useContext, useState } from "react"; -import moment from "moment"; -import { Modal } from "react-native" -import { Text, Button, DataTable, Dialog } from "react-native-paper"; -import { LabelTabContext } from "../../diary/LabelTab"; -import { getFormattedDateAbbr, isMultiDay } from "../../diary/diaryHelper"; -import { Icon } from "../../components/Icon"; -import EnketoModal from "./EnketoModal"; -import { useTranslation } from "react-i18next"; +import React, { useContext, useState } from 'react'; +import moment from 'moment'; +import { Modal } from 'react-native'; +import { Text, Button, DataTable, Dialog } from 'react-native-paper'; +import { LabelTabContext } from '../../diary/LabelTab'; +import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper'; +import { Icon } from '../../components/Icon'; +import EnketoModal from './EnketoModal'; +import { useTranslation } from 'react-i18next'; type Props = { - timelineEntry: any, - additionEntries: any[], -} + timelineEntry: any; + additionEntries: any[]; +}; const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { - const { t } = useTranslation(); const { repopulateTimelineEntry } = useContext(LabelTabContext); const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = useState(false); @@ -25,41 +24,46 @@ const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { const [editingEntry, setEditingEntry] = useState(null); function setDisplayDt(entry) { - const timezone = timelineEntry.start_local_dt?.timezone - || timelineEntry.enter_local_dt?.timezone - || timelineEntry.end_local_dt?.timezone - || timelineEntry.exit_local_dt?.timezone; + const timezone = + timelineEntry.start_local_dt?.timezone || + timelineEntry.enter_local_dt?.timezone || + timelineEntry.end_local_dt?.timezone || + timelineEntry.exit_local_dt?.timezone; const beginTs = entry.data.start_ts || entry.data.enter_ts; const stopTs = entry.data.end_ts || entry.data.exit_ts; let d; if (isMultiDay(beginTs, stopTs)) { - const beginTsZoned = moment.parseZone(beginTs*1000).tz(timezone); - const stopTsZoned = moment.parseZone(stopTs*1000).tz(timezone); + const beginTsZoned = moment.parseZone(beginTs * 1000).tz(timezone); + const stopTsZoned = moment.parseZone(stopTs * 1000).tz(timezone); d = getFormattedDateAbbr(beginTsZoned.toISOString(), stopTsZoned.toISOString()); } - const begin = moment.parseZone(beginTs*1000).tz(timezone).format('LT'); - const stop = moment.parseZone(stopTs*1000).tz(timezone).format('LT'); - return entry.displayDt = { + const begin = moment + .parseZone(beginTs * 1000) + .tz(timezone) + .format('LT'); + const stop = moment + .parseZone(stopTs * 1000) + .tz(timezone) + .format('LT'); + return (entry.displayDt = { date: d, - time: begin + " - " + stop - } + time: begin + ' - ' + stop, + }); } function deleteEntry(entry) { - console.log("Deleting entry", entry); + console.log('Deleting entry', entry); const dataKey = entry.key || entry.metadata.key; const data = entry.data; const index = additionEntries.indexOf(entry); data.status = 'DELETED'; - return window['cordova'].plugins.BEMUserCache - .putMessage(dataKey, data) - .then(() => { - additionEntries.splice(index, 1); - setConfirmDeleteModalVisible(false); - setEditingEntry(null); - }); + return window['cordova'].plugins.BEMUserCache.putMessage(dataKey, data).then(() => { + additionEntries.splice(index, 1); + setConfirmDeleteModalVisible(false); + setEditingEntry(null); + }); } function confirmDeleteEntry(entry) { @@ -90,66 +94,80 @@ const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { } const sortedEntries = additionEntries?.sort((a, b) => a.data.start_ts - b.data.start_ts); - return (<> - - {sortedEntries?.map((entry, index) => { - const isLastRow = (index == additionEntries.length - 1); - return ( - - editEntry(entry)} - style={[styles.cell, {flex: 5, pointerEvents: 'auto'}]} - textStyle={{fontSize: 12, fontWeight: 'bold'}}> - {entry.data.label} - - editEntry(entry)} - style={[styles.cell, {flex: 4}]} - textStyle={{fontSize: 12, lineHeight: 12}}> - {entry.displayDt?.date} - {entry.displayDt?.time || setDisplayDt(entry)} - - confirmDeleteEntry(entry)} - style={[styles.cell, {flex: 1}]}> - - - - ) - })} - - - - - { t('diary.delete-entry-confirm') } - - {editingEntry?.data?.label} - {editingEntry?.displayDt?.date} - {editingEntry?.displayDt?.time} - - - - - - - - ); + return ( + <> + + {sortedEntries?.map((entry, index) => { + const isLastRow = index == additionEntries.length - 1; + return ( + + editEntry(entry)} + style={[styles.cell, { flex: 5, pointerEvents: 'auto' }]} + textStyle={{ fontSize: 12, fontWeight: 'bold' }}> + {entry.data.label} + + editEntry(entry)} + style={[styles.cell, { flex: 4 }]} + textStyle={{ fontSize: 12, lineHeight: 12 }}> + {entry.displayDt?.date} + + {entry.displayDt?.time || setDisplayDt(entry)} + + + confirmDeleteEntry(entry)} + style={[styles.cell, { flex: 1 }]}> + + + + ); + })} + + + + + {t('diary.delete-entry-confirm')} + + {editingEntry?.data?.label} + {editingEntry?.displayDt?.date} + {editingEntry?.displayDt?.time} + + + + + + + + + ); }; -const styles:any = { +const styles: any = { row: (isLastRow) => ({ minHeight: 36, height: 36, - borderBottomWidth: (isLastRow ? 0 : 1), + borderBottomWidth: isLastRow ? 0 : 1, borderBottomColor: 'rgba(0,0,0,0.1)', pointerEvents: 'all', }), cell: { pointerEvents: 'auto', }, -} +}; export default AddedNotesList; diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index 8b80b6dfe..de1f505f3 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -10,13 +10,12 @@ import { displayError, displayErrorMsg } from '../../plugin/logger'; // import { transform } from 'enketo-transformer/web'; type Props = Omit & { - surveyName: string, - onResponseSaved: (response: any) => void, - opts?: SurveyOptions, -} - -const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => { + surveyName: string; + onResponseSaved: (response: any) => void; + opts?: SurveyOptions; +}; +const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const { t, i18n } = useTranslation(); const headerEl = useRef(null); const surveyJson = useRef(null); @@ -27,9 +26,11 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => const responseText = await fetchUrlCached(url); try { return JSON.parse(responseText); - } catch ({name, message}) { + } catch ({ name, message }) { // not JSON, so it must be XML - return Promise.reject('downloaded survey was not JSON; enketo-transformer is not available yet'); + return Promise.reject( + 'downloaded survey was not JSON; enketo-transformer is not available yet', + ); /* uncomment once enketo-transformer is available */ // if `response` is not JSON, it is an XML string and needs transformation to JSON // const xmlText = await res.text(); @@ -41,18 +42,21 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => const valid = await enketoForm.current.validate(); if (!valid) return false; const result = await saveResponse(surveyName, enketoForm.current, appConfig, opts); - if (!result) { // validation failed + if (!result) { + // validation failed displayErrorMsg(t('survey.enketo-form-errors')); - } else if (result instanceof Error) { // error thrown in saveResponse + } else if (result instanceof Error) { + // error thrown in saveResponse displayError(result); - } else { // success + } else { + // success rest.onDismiss(); onResponseSaved(result); return; } } - // init logic: retrieve form -> inject into DOM -> initialize Enketo -> show modal + // init logic: retrieve form -> inject into DOM -> initialize Enketo -> show modal function initSurvey() { console.debug('Loading survey', surveyName); const formPath = appConfig.survey_info?.surveys?.[surveyName]?.formPath; @@ -89,14 +93,18 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => Just make sure to keep a .form-language-selector element into which the form language selector ( @@ -111,16 +119,44 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) =>
{/* Used some quick-and-dirty inline CSS styles here because the form-footer should be styled in the mother application. The HTML markup can be changed as well. */} - {t('survey.back')} - - {t('survey.next')} -
{t('survey.powered-by')} enketo logo
+ + {t('survey.next')} + +
+ {t('survey.powered-by')}{' '} + + enketo logo + {' '} +
{/*
    */}
    @@ -129,19 +165,17 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => ); return ( - - - - -
    - {enketoContent} -
    + + + + +
    {enketoContent}
    ); -} +}; const s = StyleSheet.create({ dismissBtn: { @@ -152,7 +186,7 @@ const s = StyleSheet.create({ display: 'flex', alignItems: 'center', padding: 0, - } + }, }); export default EnketoModal; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 68d0ae944..fa2412b73 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -8,18 +8,18 @@ The start and end times of the addition are the same as the trip or place. */ -import React, { useContext, useMemo, useState } from "react"; -import { getAngularService } from "../../angular-react-helper"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "react-native-paper"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import EnketoModal from "./EnketoModal"; -import { LabelTabContext } from "../../diary/LabelTab"; +import React, { useContext, useMemo, useState } from 'react'; +import { getAngularService } from '../../angular-react-helper'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from 'react-native-paper'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import EnketoModal from './EnketoModal'; +import { LabelTabContext } from '../../diary/LabelTab'; type Props = { - timelineEntry: any, -} + timelineEntry: any; +}; const UserInputButton = ({ timelineEntry }: Props) => { const { colors } = useTheme(); const { t, i18n } = useTranslation(); @@ -28,13 +28,14 @@ const UserInputButton = ({ timelineEntry }: Props) => { const [modalVisible, setModalVisible] = useState(false); const { repopulateTimelineEntry } = useContext(LabelTabContext); - const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); + const EnketoTripButtonService = getAngularService('EnketoTripButtonService'); const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; // the label resolved from the survey response, or null if there is no response yet - const responseLabel = useMemo(() => ( - timelineEntry.userInput?.[etbsSingleKey]?.data?.label || null - ), [timelineEntry]); + const responseLabel = useMemo( + () => timelineEntry.userInput?.[etbsSingleKey]?.data?.label || null, + [timelineEntry], + ); function launchUserInputSurvey() { logDebug('UserInputButton: About to launch survey'); @@ -45,31 +46,37 @@ const UserInputButton = ({ timelineEntry }: Props) => { function onResponseSaved(result) { if (result) { - logDebug('UserInputButton: response was saved, about to repopulateTimelineEntry; result=' + JSON.stringify(result)); + logDebug( + 'UserInputButton: response was saved, about to repopulateTimelineEntry; result=' + + JSON.stringify(result), + ); repopulateTimelineEntry(timelineEntry._id.$oid); } else { displayErrorMsg('UserInputButton: response was not saved, result=', result); } } - return (<> - launchUserInputSurvey()}> - {/* if no response yet, show the default label */} - {responseLabel || t('diary.choose-survey')} - + return ( + <> + launchUserInputSurvey()}> + {/* if no response yet, show the default label */} + {responseLabel || t('diary.choose-survey')} + - setModalVisible(false)} - onResponseSaved={onResponseSaved} - surveyName={'TripConfirmSurvey'} /* As of now, the survey name is hardcoded. + setModalVisible(false)} + onResponseSaved={onResponseSaved} + surveyName={'TripConfirmSurvey'} /* As of now, the survey name is hardcoded. In the future, if we ever implement something like a "Place Details" survey, we may want to make this configurable. */ - opts={{ timelineEntry, - prefilledSurveyResponse: prevSurveyResponse - }} /> - ); + opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }} + /> + + ); }; export default UserInputButton; diff --git a/www/js/survey/enketo/answer.js b/www/js/survey/enketo/answer.js index e6077c479..cb5745037 100644 --- a/www/js/survey/enketo/answer.js +++ b/www/js/survey/enketo/answer.js @@ -2,192 +2,191 @@ import angular from 'angular'; import MessageFormat from 'messageformat'; import { getConfig } from '../../config/dynamicConfig'; -angular.module('emission.survey.enketo.answer', ['ionic']) -.factory('EnketoSurveyAnswer', function($http) { - /** - * @typedef EnketoAnswerData - * @type {object} - * @property {string} label - display label (this value is use for displaying on the button) - * @property {string} ts - the timestamp at which the survey was filled out (in seconds) - * @property {string} fmt_time - the formatted timestamp at which the survey was filled out - * @property {string} name - survey name - * @property {string} version - survey version - * @property {string} xmlResponse - survey answer XML string - * @property {string} jsonDocResponse - survey answer JSON object - */ - - /** - * @typedef EnketoAnswer - * @type {object} - * @property {EnketoAnswerData} data - answer data - * @property {{[labelField:string]: string}} [labels] - virtual labels (populated by populateLabels method) - */ - - /** - * @typedef EnketoSurveyConfig - * @type {{ - * [surveyName:string]: { - * formPath: string; - * labelFields: string[]; - * version: number; - * compatibleWith: number; - * } - * }} - */ - - const LABEL_FUNCTIONS = { - UseLabelTemplate: (xmlDoc, name) => { - - return _lazyLoadConfig().then(configSurveys => { - - const config = configSurveys[name]; // config for this survey - const lang = i18next.resolvedLanguage; - const labelTemplate = config.labelTemplate?.[lang]; - - if (!labelTemplate) return "Answered"; // no template given in config - if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, - // so we return the unaltered template - - // gather vars that will be interpolated into the template according to the survey config - const labelVars = {} - for (let lblVar in config.labelVars) { - const fieldName = config.labelVars[lblVar].key; - let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); - if (fieldStr == '') fieldStr = null; - if (config.labelVars[lblVar].type == 'length') { - const fieldMatches = fieldStr?.split(' '); - labelVars[lblVar] = fieldMatches?.length || 0; - } else { - throw new Error(`labelVar type ${config.labelVars[lblVar].type } is not supported!`) +angular + .module('emission.survey.enketo.answer', ['ionic']) + .factory('EnketoSurveyAnswer', function ($http) { + /** + * @typedef EnketoAnswerData + * @type {object} + * @property {string} label - display label (this value is use for displaying on the button) + * @property {string} ts - the timestamp at which the survey was filled out (in seconds) + * @property {string} fmt_time - the formatted timestamp at which the survey was filled out + * @property {string} name - survey name + * @property {string} version - survey version + * @property {string} xmlResponse - survey answer XML string + * @property {string} jsonDocResponse - survey answer JSON object + */ + + /** + * @typedef EnketoAnswer + * @type {object} + * @property {EnketoAnswerData} data - answer data + * @property {{[labelField:string]: string}} [labels] - virtual labels (populated by populateLabels method) + */ + + /** + * @typedef EnketoSurveyConfig + * @type {{ + * [surveyName:string]: { + * formPath: string; + * labelFields: string[]; + * version: number; + * compatibleWith: number; + * } + * }} + */ + + const LABEL_FUNCTIONS = { + UseLabelTemplate: (xmlDoc, name) => { + return _lazyLoadConfig().then((configSurveys) => { + const config = configSurveys[name]; // config for this survey + const lang = i18next.resolvedLanguage; + const labelTemplate = config.labelTemplate?.[lang]; + + if (!labelTemplate) return 'Answered'; // no template given in config + if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, + // so we return the unaltered template + + // gather vars that will be interpolated into the template according to the survey config + const labelVars = {}; + for (let lblVar in config.labelVars) { + const fieldName = config.labelVars[lblVar].key; + let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); + if (fieldStr == '') fieldStr = null; + if (config.labelVars[lblVar].type == 'length') { + const fieldMatches = fieldStr?.split(' '); + labelVars[lblVar] = fieldMatches?.length || 0; + } else { + throw new Error(`labelVar type ${config.labelVars[lblVar].type} is not supported!`); + } } - } - // use MessageFormat interpolate the label template with the label vars - const mf = new MessageFormat(lang); - const label = mf.compile(labelTemplate)(labelVars); - return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas - }) + // use MessageFormat interpolate the label template with the label vars + const mf = new MessageFormat(lang); + const label = mf.compile(labelTemplate)(labelVars); + return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas + }); + }, + }; + + /** @type {EnketoSurveyConfig} _config */ + let _config; + + /** + * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. + * @param {XMLDocument} xmlDoc survey answer object + * @param {string} tagName tag name + * @returns {string} answer string. If not found, return "\" + */ + function _getAnswerByTagName(xmlDoc, tagName) { + const vals = xmlDoc.getElementsByTagName(tagName); + const val = vals.length ? vals[0].innerHTML : null; + if (!val) return ''; + return val; } - }; - - /** @type {EnketoSurveyConfig} _config */ - let _config; - - /** - * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. - * @param {XMLDocument} xmlDoc survey answer object - * @param {string} tagName tag name - * @returns {string} answer string. If not found, return "\" - */ - function _getAnswerByTagName(xmlDoc, tagName) { - const vals = xmlDoc.getElementsByTagName(tagName); - const val = vals.length ? vals[0].innerHTML : null; - if (!val) return ''; - return val; - } - - /** - * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config - * @returns {Promise} enketo survey config - */ - function _lazyLoadConfig() { - if (_config !== undefined) { - return Promise.resolve(_config); + + /** + * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config + * @returns {Promise} enketo survey config + */ + function _lazyLoadConfig() { + if (_config !== undefined) { + return Promise.resolve(_config); + } + return getConfig().then((newConfig) => { + Logger.log('Resolved UI_CONFIG_READY promise in answer.js, filling in templates'); + _config = newConfig.survey_info.surveys; + return _config; + }); + } + + /** + * filterByNameAndVersion filter the survey answers by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. + * @param {string} name survey name (defined in enketo survey config) + * @param {EnketoAnswer[]} answers survey answers + * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. + * @return {Promise} filtered survey answers + */ + function filterByNameAndVersion(name, answers) { + return _lazyLoadConfig().then((config) => + answers.filter( + (answer) => + answer.data.name === name && answer.data.version >= config[name].compatibleWith, + ), + ); } - return getConfig().then((newConfig) => { - Logger.log("Resolved UI_CONFIG_READY promise in answer.js, filling in templates"); - _config = newConfig.survey_info.surveys; - return _config; - }) - } - - /** - * filterByNameAndVersion filter the survey answers by survey name and their version. - * The version for filtering is specified in enketo survey `compatibleWith` config. - * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. - * @param {string} name survey name (defined in enketo survey config) - * @param {EnketoAnswer[]} answers survey answers - * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. - * @return {Promise} filtered survey answers - */ - function filterByNameAndVersion(name, answers) { - return _lazyLoadConfig().then(config => - answers.filter(answer => - answer.data.name === name && - answer.data.version >= config[name].compatibleWith - ) - ); - } - - /** - * resolve answer label for the survey - * @param {string} name survey name - * @param {XMLDocument} xmlDoc survey answer object - * @returns {Promise} label string Promise - */ - function resolveLabel(name, xmlDoc) { - // Some studies may want a custom label function for their survey. - // Those can be added in LABEL_FUNCTIONS with the survey name as the key. - // Otherwise, UseLabelTemplate will create a label using the template in the config - if (LABEL_FUNCTIONS[name]) - return LABEL_FUNCTIONS[name](xmlDoc); - return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); - } - - /** - * resolve timestamps label from the survey response - * @param {XMLDocument} xmlDoc survey answer object - * @param {object} trip trip object - * @returns {object} object with `start_ts` and `end_ts` - * - null if no timestamps are resolved - * - undefined if the timestamps are invalid - */ - function resolveTimestamps(xmlDoc, timelineEntry) { - // check for Date and Time fields - const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; - let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; - const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; - let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; - - // if any of the fields are missing, return null - if (!startDate || !startTime || !endDate || !endTime) return null; - - const timezone = timelineEntry.start_local_dt?.timezone - || timelineEntry.enter_local_dt?.timezone - || timelineEntry.end_local_dt?.timezone - || timelineEntry.exit_local_dt?.timezone; - // split by + or - to get time without offset - startTime = startTime.split(/\-|\+/)[0]; - endTime = endTime.split(/\-|\+/)[0]; - - let additionStartTs = moment.tz(startDate+'T'+startTime, timezone).unix(); - let additionEndTs = moment.tz(endDate+'T'+endTime, timezone).unix(); - - if (additionStartTs > additionEndTs) { - return undefined; // if the start time is after the end time, this is an invalid response + + /** + * resolve answer label for the survey + * @param {string} name survey name + * @param {XMLDocument} xmlDoc survey answer object + * @returns {Promise} label string Promise + */ + function resolveLabel(name, xmlDoc) { + // Some studies may want a custom label function for their survey. + // Those can be added in LABEL_FUNCTIONS with the survey name as the key. + // Otherwise, UseLabelTemplate will create a label using the template in the config + if (LABEL_FUNCTIONS[name]) return LABEL_FUNCTIONS[name](xmlDoc); + return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); } - /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to + /** + * resolve timestamps label from the survey response + * @param {XMLDocument} xmlDoc survey answer object + * @param {object} trip trip object + * @returns {object} object with `start_ts` and `end_ts` + * - null if no timestamps are resolved + * - undefined if the timestamps are invalid + */ + function resolveTimestamps(xmlDoc, timelineEntry) { + // check for Date and Time fields + const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; + let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; + const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; + let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; + + // if any of the fields are missing, return null + if (!startDate || !startTime || !endDate || !endTime) return null; + + const timezone = + timelineEntry.start_local_dt?.timezone || + timelineEntry.enter_local_dt?.timezone || + timelineEntry.end_local_dt?.timezone || + timelineEntry.exit_local_dt?.timezone; + // split by + or - to get time without offset + startTime = startTime.split(/\-|\+/)[0]; + endTime = endTime.split(/\-|\+/)[0]; + + let additionStartTs = moment.tz(startDate + 'T' + startTime, timezone).unix(); + let additionEndTs = moment.tz(endDate + 'T' + endTime, timezone).unix(); + + if (additionStartTs > additionEndTs) { + return undefined; // if the start time is after the end time, this is an invalid response + } + + /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to the millisecond. To avoid precision issues, we will check if the start/end timestamps from the survey response are within the same minute as the start/end or enter/exit timestamps. If so, we will use the exact trip/place timestamps */ - const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; - const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; - if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) - additionStartTs = entryStartTs; - if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) - additionEndTs = entryEndTs; - - // return unix timestamps in seconds + const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; + const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; + if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) + additionStartTs = entryStartTs; + if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) + additionEndTs = entryEndTs; + + // return unix timestamps in seconds + return { + start_ts: additionStartTs, + end_ts: additionEndTs, + }; + } + return { - start_ts: additionStartTs, - end_ts: additionEndTs - }; - } - - return { - filterByNameAndVersion, - resolveLabel, - resolveTimestamps, - }; -}); + filterByNameAndVersion, + resolveLabel, + resolveTimestamps, + }; + }); diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index 49f7747f6..a5bb7edd2 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -4,102 +4,130 @@ import angular from 'angular'; -angular.module('emission.survey.enketo.add-note-button', - ['emission.services', - 'emission.survey.enketo.answer', - 'emission.survey.inputmatcher']) -.factory("EnketoNotesButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { - var enbs = {}; - console.log("Creating EnketoNotesButtonService"); - enbs.SINGLE_KEY="NOTES"; - enbs.MANUAL_KEYS = []; +angular + .module('emission.survey.enketo.add-note-button', [ + 'emission.services', + 'emission.survey.enketo.answer', + 'emission.survey.inputmatcher', + ]) + .factory( + 'EnketoNotesButtonService', + function (InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { + var enbs = {}; + console.log('Creating EnketoNotesButtonService'); + enbs.SINGLE_KEY = 'NOTES'; + enbs.MANUAL_KEYS = []; - /** - * Set the keys for trip and/or place additions whichever will be enabled, - * and sets the name of the surveys they will use. - */ - enbs.initConfig = function(tripSurveyName, placeSurveyName) { - enbs.tripSurveyName = tripSurveyName; - if (tripSurveyName) { - enbs.MANUAL_KEYS.push("manual/trip_addition_input") - } - enbs.placeSurveyName = placeSurveyName; - if (placeSurveyName) { - enbs.MANUAL_KEYS.push("manual/place_addition_input") - } - } + /** + * Set the keys for trip and/or place additions whichever will be enabled, + * and sets the name of the surveys they will use. + */ + enbs.initConfig = function (tripSurveyName, placeSurveyName) { + enbs.tripSurveyName = tripSurveyName; + if (tripSurveyName) { + enbs.MANUAL_KEYS.push('manual/trip_addition_input'); + } + enbs.placeSurveyName = placeSurveyName; + if (placeSurveyName) { + enbs.MANUAL_KEYS.push('manual/place_addition_input'); + } + }; - /** - * Embed 'inputType' to the timelineEntry. - */ - enbs.extractResult = function(results) { - const resultsPromises = [EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results)]; - if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { - resultsPromises.push(EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results)); - } - return Promise.all(resultsPromises); - }; + /** + * Embed 'inputType' to the timelineEntry. + */ + enbs.extractResult = function (results) { + const resultsPromises = [ + EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results), + ]; + if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { + resultsPromises.push( + EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results), + ); + } + return Promise.all(resultsPromises); + }; - enbs.processManualInputs = function(manualResults, resultMap) { - console.log("ENKETO: processManualInputs with ", manualResults, " and ", resultMap); - const surveyResults = manualResults.flat(2); - resultMap[enbs.SINGLE_KEY] = surveyResults; - } + enbs.processManualInputs = function (manualResults, resultMap) { + console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); + const surveyResults = manualResults.flat(2); + resultMap[enbs.SINGLE_KEY] = surveyResults; + }; - enbs.populateInputsAndInferences = function(timelineEntry, manualResultMap) { - console.log("ENKETO: populating timelineEntry,", timelineEntry, " with result map", manualResultMap); - if (angular.isDefined(timelineEntry)) { - // initialize additions array as empty if it doesn't already exist - timelineEntry.additionsList ||= []; - enbs.populateManualInputs(timelineEntry, enbs.SINGLE_KEY, manualResultMap[enbs.SINGLE_KEY]); - } else { - console.log("timelineEntry information not yet bound, skipping fill"); - } - } + enbs.populateInputsAndInferences = function (timelineEntry, manualResultMap) { + console.log( + 'ENKETO: populating timelineEntry,', + timelineEntry, + ' with result map', + manualResultMap, + ); + if (angular.isDefined(timelineEntry)) { + // initialize additions array as empty if it doesn't already exist + timelineEntry.additionsList ||= []; + enbs.populateManualInputs( + timelineEntry, + enbs.SINGLE_KEY, + manualResultMap[enbs.SINGLE_KEY], + ); + } else { + console.log('timelineEntry information not yet bound, skipping fill'); + } + }; - /** - * Embed 'inputType' to the timelineEntry - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - enbs.populateManualInputs = function (timelineEntry, inputType, inputList) { - // there is not necessarily just one addition per timeline entry, - // so unlike user inputs, we don't want to replace the server entry with - // the unprocessed entry - // but we also don't want to blindly append the unprocessed entry; what - // if it was a deletion. - // what we really want to do is to merge the unprocessed and processed entries - // taking deletion into account - // one option for that is to just combine the processed and unprocessed entries - // into a single list - // note that this is not necessarily the most performant approach, since we will - // be re-matching entries that have already been matched on the server - // but the number of matched entries is likely to be small, so we can live - // with the performance for now - const unprocessedAdditions = InputMatcher.getAdditionsForTimelineEntry(timelineEntry, inputList); - const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); - const dedupedList = InputMatcher.getUniqueEntries(combinedPotentialAdditionList); - Logger.log("After combining unprocessed ("+unprocessedAdditions.length+ - ") with server ("+timelineEntry.additions.length+ - ") for a combined ("+combinedPotentialAdditionList.length+ - "), deduped entries are ("+dedupedList.length+")"); + /** + * Embed 'inputType' to the timelineEntry + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + enbs.populateManualInputs = function (timelineEntry, inputType, inputList) { + // there is not necessarily just one addition per timeline entry, + // so unlike user inputs, we don't want to replace the server entry with + // the unprocessed entry + // but we also don't want to blindly append the unprocessed entry; what + // if it was a deletion. + // what we really want to do is to merge the unprocessed and processed entries + // taking deletion into account + // one option for that is to just combine the processed and unprocessed entries + // into a single list + // note that this is not necessarily the most performant approach, since we will + // be re-matching entries that have already been matched on the server + // but the number of matched entries is likely to be small, so we can live + // with the performance for now + const unprocessedAdditions = InputMatcher.getAdditionsForTimelineEntry( + timelineEntry, + inputList, + ); + const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); + const dedupedList = InputMatcher.getUniqueEntries(combinedPotentialAdditionList); + Logger.log( + 'After combining unprocessed (' + + unprocessedAdditions.length + + ') with server (' + + timelineEntry.additions.length + + ') for a combined (' + + combinedPotentialAdditionList.length + + '), deduped entries are (' + + dedupedList.length + + ')', + ); - enbs.populateInput(timelineEntry.additionsList, inputType, dedupedList); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - enbs.editingTrip = angular.undefined; - } + enbs.populateInput(timelineEntry.additionsList, inputType, dedupedList); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + enbs.editingTrip = angular.undefined; + }; - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - enbs.populateInput = function(timelineEntryField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - timelineEntryField.length = 0; - userInputEntry.forEach(ta => { + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + enbs.populateInput = function (timelineEntryField, inputType, userInputEntry) { + if (angular.isDefined(userInputEntry)) { + timelineEntryField.length = 0; + userInputEntry.forEach((ta) => { timelineEntryField.push(ta); }); - } - } + } + }; - return enbs; -}); + return enbs; + }, + ); diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js index 6e710435f..66cf82cd7 100644 --- a/www/js/survey/enketo/enketo-trip-button.js +++ b/www/js/survey/enketo/enketo-trip-button.js @@ -13,99 +13,111 @@ import angular from 'angular'; -angular.module('emission.survey.enketo.trip.button', - ['emission.survey.enketo.answer', - 'emission.survey.inputmatcher']) -.factory("EnketoTripButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { - var etbs = {}; - console.log("Creating EnketoTripButtonService"); - etbs.key = "manual/trip_user_input"; - etbs.SINGLE_KEY="SURVEY"; - etbs.MANUAL_KEYS = [etbs.key]; +angular + .module('emission.survey.enketo.trip.button', [ + 'emission.survey.enketo.answer', + 'emission.survey.inputmatcher', + ]) + .factory( + 'EnketoTripButtonService', + function (InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { + var etbs = {}; + console.log('Creating EnketoTripButtonService'); + etbs.key = 'manual/trip_user_input'; + etbs.SINGLE_KEY = 'SURVEY'; + etbs.MANUAL_KEYS = [etbs.key]; - /** - * Embed 'inputType' to the trip. - */ - etbs.extractResult = (results) => EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); + /** + * Embed 'inputType' to the trip. + */ + etbs.extractResult = (results) => + EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); - etbs.processManualInputs = function(manualResults, resultMap) { - if (manualResults.length > 1) { - Logger.displayError("Found "+manualResults.length+" results expected 1", manualResults); - } else { - console.log("ENKETO: processManualInputs with ", manualResults, " and ", resultMap); - const surveyResult = manualResults[0]; - resultMap[etbs.SINGLE_KEY] = surveyResult; - } - } + etbs.processManualInputs = function (manualResults, resultMap) { + if (manualResults.length > 1) { + Logger.displayError( + 'Found ' + manualResults.length + ' results expected 1', + manualResults, + ); + } else { + console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); + const surveyResult = manualResults[0]; + resultMap[etbs.SINGLE_KEY] = surveyResult; + } + }; - etbs.populateInputsAndInferences = function(trip, manualResultMap) { - console.log("ENKETO: populating trip,", trip, " with result map", manualResultMap); - if (angular.isDefined(trip)) { - // console.log("Expectation: "+JSON.stringify(trip.expectation)); - // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); - trip.userInput = {}; - etbs.populateManualInputs(trip, trip.getNextEntry(), etbs.SINGLE_KEY, - manualResultMap[etbs.SINGLE_KEY]); - trip.finalInference = {}; - etbs.inferFinalLabels(trip); - etbs.updateVerifiability(trip); - } else { - console.log("Trip information not yet bound, skipping fill"); - } - } + etbs.populateInputsAndInferences = function (trip, manualResultMap) { + console.log('ENKETO: populating trip,', trip, ' with result map', manualResultMap); + if (angular.isDefined(trip)) { + // console.log("Expectation: "+JSON.stringify(trip.expectation)); + // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); + trip.userInput = {}; + etbs.populateManualInputs( + trip, + trip.getNextEntry(), + etbs.SINGLE_KEY, + manualResultMap[etbs.SINGLE_KEY], + ); + trip.finalInference = {}; + etbs.inferFinalLabels(trip); + etbs.updateVerifiability(trip); + } else { + console.log('Trip information not yet bound, skipping fill'); + } + }; - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { - // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, - inputList); - var userInputEntry = unprocessedLabelEntry; - if (!angular.isDefined(userInputEntry)) { + /** + * Embed 'inputType' to the trip + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { + // Check unprocessed labels first since they are more recent + const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, inputList); + var userInputEntry = unprocessedLabelEntry; + if (!angular.isDefined(userInputEntry)) { userInputEntry = trip.user_input?.[etbs.inputType2retKey(inputType)]; - } - etbs.populateInput(trip.userInput, inputType, userInputEntry); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - etbs.editingTrip = angular.undefined; - } + } + etbs.populateInput(trip.userInput, inputType, userInputEntry); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + etbs.editingTrip = angular.undefined; + }; - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - etbs.populateInput = function(tripField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - tripField[inputType] = userInputEntry; - } - } + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + etbs.populateInput = function (tripField, inputType, userInputEntry) { + if (angular.isDefined(userInputEntry)) { + tripField[inputType] = userInputEntry; + } + }; - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - etbs.inferFinalLabels = function(trip) { - // currently a NOP since we don't have any other trip properties - return; - } + /** + * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. + * The algorithm below operationalizes these principles: + * - Never consider label tuples that contradict a green label + * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before + * - After filtering, predict the most likely choices at the level of individual labels, not label tuples + * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold + */ + etbs.inferFinalLabels = function (trip) { + // currently a NOP since we don't have any other trip properties + return; + }; - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - etbs.inputType2retKey = function(inputType) { - return etbs.key.split("/")[1]; - } + /** + * MODE (becomes manual/mode_confirm) becomes mode_confirm + */ + etbs.inputType2retKey = function (inputType) { + return etbs.key.split('/')[1]; + }; - etbs.updateVerifiability = function(trip) { - // currently a NOP since we don't have any other trip properties - trip.verifiability = "cannot-verify"; - return; - } + etbs.updateVerifiability = function (trip) { + // currently a NOP since we don't have any other trip properties + trip.verifiability = 'cannot-verify'; + return; + }; - return etbs; -}); + return etbs; + }, + ); diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 6e9147cf8..b1e228540 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -1,10 +1,10 @@ -import { getAngularService } from "../../angular-react-helper"; +import { getAngularService } from '../../angular-react-helper'; import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; -import { logDebug } from "../../plugin/logger"; +import { logDebug } from '../../plugin/logger'; -export type PrefillFields = {[key: string]: string}; +export type PrefillFields = { [key: string]: string }; export type SurveyOptions = { undismissable?: boolean; @@ -37,12 +37,10 @@ function getXmlWithPrefills(xmlModel: string, prefillFields: PrefillFields) { * @param opts object with options like 'prefilledSurveyResponse' or 'prefillFields' * @returns XML string of an existing or prefilled model response, or null if no response is available */ -export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string|null { +export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string | null { if (!xmlModel) return null; - if (opts.prefilledSurveyResponse) - return opts.prefilledSurveyResponse; - if (opts.prefillFields) - return getXmlWithPrefills(xmlModel, opts.prefillFields); + if (opts.prefilledSurveyResponse) return opts.prefilledSurveyResponse; + if (opts.prefillFields) return getXmlWithPrefills(xmlModel, opts.prefillFields); return null; } @@ -58,58 +56,59 @@ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, op const xmlParser = new window.DOMParser(); const xmlResponse = enketoForm.getDataStr(); const xmlDoc = xmlParser.parseFromString(xmlResponse, 'text/xml'); - const xml2js = new XMLParser({ignoreAttributes: false, attributeNamePrefix: 'attr'}); + const xml2js = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: 'attr' }); const jsonDocResponse = xml2js.parse(xmlResponse); - return EnketoSurveyAnswer.resolveLabel(surveyName, xmlDoc).then(rsLabel => { - const data: any = { - label: rsLabel, - name: surveyName, - version: appConfig.survey_info.surveys[surveyName].version, - xmlResponse, - jsonDocResponse, - }; - if (opts.timelineEntry) { - let timestamps = EnketoSurveyAnswer.resolveTimestamps(xmlDoc, opts.timelineEntry); - if (timestamps === undefined) { - // timestamps were resolved, but they are invalid - return new Error(i18next.t('survey.enketo-timestamps-invalid')); //"Timestamps are invalid. Please ensure that the start time is before the end time."); + return EnketoSurveyAnswer.resolveLabel(surveyName, xmlDoc) + .then((rsLabel) => { + const data: any = { + label: rsLabel, + name: surveyName, + version: appConfig.survey_info.surveys[surveyName].version, + xmlResponse, + jsonDocResponse, + }; + if (opts.timelineEntry) { + let timestamps = EnketoSurveyAnswer.resolveTimestamps(xmlDoc, opts.timelineEntry); + if (timestamps === undefined) { + // timestamps were resolved, but they are invalid + return new Error(i18next.t('survey.enketo-timestamps-invalid')); //"Timestamps are invalid. Please ensure that the start time is before the end time."); + } + // if timestamps were not resolved from the survey, we will use the trip or place timestamps + timestamps ||= opts.timelineEntry; + data.start_ts = timestamps.start_ts || timestamps.enter_ts; + data.end_ts = timestamps.end_ts || timestamps.exit_ts; + // UUID generated using this method https://stackoverflow.com/a/66332305 + data.match_id = URL.createObjectURL(new Blob([])).slice(-36); + } else { + const now = Date.now(); + data.ts = now / 1000; // convert to seconds to be consistent with the server + data.fmt_time = new Date(now); } - // if timestamps were not resolved from the survey, we will use the trip or place timestamps - timestamps ||= opts.timelineEntry; - data.start_ts = timestamps.start_ts || timestamps.enter_ts; - data.end_ts = timestamps.end_ts || timestamps.exit_ts; - // UUID generated using this method https://stackoverflow.com/a/66332305 - data.match_id = URL.createObjectURL(new Blob([])).slice(-36); - } else { - const now = Date.now(); - data.ts = now/1000; // convert to seconds to be consistent with the server - data.fmt_time = new Date(now); - } - // use dataKey passed into opts if available, otherwise get it from the config - const dataKey = opts.dataKey || appConfig.survey_info.surveys[surveyName].dataKey; - return window['cordova'].plugins.BEMUserCache - .putMessage(dataKey, data) - .then(() => data); - }).then(data => data); + // use dataKey passed into opts if available, otherwise get it from the config + const dataKey = opts.dataKey || appConfig.survey_info.surveys[surveyName].dataKey; + return window['cordova'].plugins.BEMUserCache.putMessage(dataKey, data).then(() => data); + }) + .then((data) => data); } const _getMostRecent = (answers) => { answers.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); - console.log("first answer is ", answers[0], " last answer is ", answers[answers.length-1]); + console.log('first answer is ', answers[0], ' last answer is ', answers[answers.length - 1]); return answers[0]; -} +}; /* - * We retrieve all the records every time instead of caching because of the - * usage pattern. We assume that the demographic survey is edited fairly - * rarely, so loading it every time will likely do a bunch of unnecessary work. - * Loading it on demand seems like the way to go. If we choose to experiment - * with incremental updates, we may want to revisit this. -*/ + * We retrieve all the records every time instead of caching because of the + * usage pattern. We assume that the demographic survey is edited fairly + * rarely, so loading it every time will likely do a bunch of unnecessary work. + * Loading it on demand seems like the way to go. If we choose to experiment + * with incremental updates, we may want to revisit this. + */ export function loadPreviousResponseForSurvey(dataKey: string) { const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); - logDebug("loadPreviousResponseForSurvey: dataKey = " + dataKey + "; tq = " + tq); - return UnifiedDataLoader.getUnifiedMessagesForInterval(dataKey, tq) - .then(answers => _getMostRecent(answers)) + logDebug('loadPreviousResponseForSurvey: dataKey = ' + dataKey + '; tq = ' + tq); + return UnifiedDataLoader.getUnifiedMessagesForInterval(dataKey, tq).then((answers) => + _getMostRecent(answers), + ); } diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index 98eba65db..bc43591e0 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -6,33 +6,29 @@ * All UI elements should only use $scope variables. */ -import i18next from "i18next"; -import { getAngularService } from "../../angular-react-helper"; +import i18next from 'i18next'; +import { getAngularService } from '../../angular-react-helper'; const unlabeledCheck = (t) => { - try { - const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); - const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; - return typeof t.userInput[etbsSingleKey] === 'undefined'; - } - catch (e) { - console.log("Error in retrieving EnketoTripButtonService: ", e); - } -} + try { + const EnketoTripButtonService = getAngularService('EnketoTripButtonService'); + const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; + return typeof t.userInput[etbsSingleKey] === 'undefined'; + } catch (e) { + console.log('Error in retrieving EnketoTripButtonService: ', e); + } +}; const UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck -} + key: 'unlabeled', + text: i18next.t('diary.unlabeled'), + filter: unlabeledCheck, +}; const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: unlabeledCheck -} + key: 'to_label', + text: i18next.t('diary.to-label'), + filter: unlabeledCheck, +}; -export const configuredFilters = [ - TO_LABEL, - UNLABELED -]; \ No newline at end of file +export const configuredFilters = [TO_LABEL, UNLABELED]; diff --git a/www/js/survey/input-matcher.js b/www/js/survey/input-matcher.js index 2e3d5b908..6fc3178df 100644 --- a/www/js/survey/input-matcher.js +++ b/www/js/survey/input-matcher.js @@ -2,23 +2,37 @@ import angular from 'angular'; -angular.module('emission.survey.inputmatcher', ['emission.plugin.logger']) -.factory('InputMatcher', function(Logger){ - var im = {}; - - const EPOCH_MAXIMUM = 2**31 - 1; - const fmtTs = function(ts_in_secs, tz) { - return moment(ts_in_secs * 1000).tz(tz).format(); - } - - var printUserInput = function(ui) { - return fmtTs(ui.data.start_ts, ui.metadata.time_zone) + "("+ui.data.start_ts + ") -> "+ - fmtTs(ui.data.end_ts, ui.metadata.time_zone) + "("+ui.data.end_ts + ")"+ - " " + ui.data.label + " logged at "+ ui.metadata.write_ts; - } - - im.validUserInputForDraftTrip = function(trip, userInput, logsEnabled) { - if (logsEnabled) { +angular + .module('emission.survey.inputmatcher', ['emission.plugin.logger']) + .factory('InputMatcher', function (Logger) { + var im = {}; + + const EPOCH_MAXIMUM = 2 ** 31 - 1; + const fmtTs = function (ts_in_secs, tz) { + return moment(ts_in_secs * 1000) + .tz(tz) + .format(); + }; + + var printUserInput = function (ui) { + return ( + fmtTs(ui.data.start_ts, ui.metadata.time_zone) + + '(' + + ui.data.start_ts + + ') -> ' + + fmtTs(ui.data.end_ts, ui.metadata.time_zone) + + '(' + + ui.data.end_ts + + ')' + + ' ' + + ui.data.label + + ' logged at ' + + ui.metadata.write_ts + ); + }; + + im.validUserInputForDraftTrip = function (trip, userInput, logsEnabled) { + if (logsEnabled) { Logger.log(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} @@ -29,40 +43,40 @@ angular.module('emission.survey.inputmatcher', ['emission.plugin.logger']) || ${-(userInput.data.start_ts - trip.start_ts) <= 15 * 60}) && ${userInput.data.end_ts <= trip.end_ts} `); - } - return (userInput.data.start_ts >= trip.start_ts - && userInput.data.start_ts < trip.end_ts - || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) - && userInput.data.end_ts <= trip.end_ts; - } - - im.validUserInputForTimelineEntry = function(tlEntry, userInput, logsEnabled) { - if (!tlEntry.origin_key) return false; - if (tlEntry.origin_key.includes('UNPROCESSED') == true) + } + return ( + ((userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts) || + -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && + userInput.data.end_ts <= trip.end_ts + ); + }; + + im.validUserInputForTimelineEntry = function (tlEntry, userInput, logsEnabled) { + if (!tlEntry.origin_key) return false; + if (tlEntry.origin_key.includes('UNPROCESSED') == true) return im.validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); - /* Place-level inputs always have a key starting with 'manual/place', and + /* Place-level inputs always have a key starting with 'manual/place', and trip-level inputs never have a key starting with 'manual/place' So if these don't match, we can immediately return false */ - const entryIsPlace = tlEntry.origin_key == 'analysis/confirmed_place'; - const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); - if (entryIsPlace != isPlaceInput) - return false; - - let entryStart = tlEntry.start_ts || tlEntry.enter_ts; - let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; - if (!entryStart && entryEnd) { - // if a place has no enter time, this is the first start_place of the first composite trip object - // so we will set the start time to the start of the day of the end time for the purpose of comparison - entryStart = moment.unix(entryEnd).startOf('day').unix(); - } - if (!entryEnd) { + const entryIsPlace = tlEntry.origin_key == 'analysis/confirmed_place'; + const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); + if (entryIsPlace != isPlaceInput) return false; + + let entryStart = tlEntry.start_ts || tlEntry.enter_ts; + let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; + if (!entryStart && entryEnd) { + // if a place has no enter time, this is the first start_place of the first composite trip object + // so we will set the start time to the start of the day of the end time for the purpose of comparison + entryStart = moment.unix(entryEnd).startOf('day').unix(); + } + if (!entryEnd) { // if a place has no exit time, the user hasn't left there yet // so we will set the end time as high as possible for the purpose of comparison entryEnd = EPOCH_MAXIMUM; - } - - if (logsEnabled) { + } + + if (logsEnabled) { Logger.log(`Cleaned trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} @@ -73,141 +87,187 @@ angular.module('emission.survey.inputmatcher', ['emission.plugin.logger']) end checks are ${userInput.data.end_ts <= entryEnd} || ${userInput.data.end_ts - entryEnd <= 15 * 60}) `); - } + } - /* For this input to match, it must begin after the start of the timelineEntry (inclusive) + /* For this input to match, it must begin after the start of the timelineEntry (inclusive) but before the end of the timelineEntry (exclusive) */ - const startChecks = userInput.data.start_ts >= entryStart && - userInput.data.start_ts < entryEnd; - /* A matching user input must also finish before the end of the timelineEntry, + const startChecks = + userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; + /* A matching user input must also finish before the end of the timelineEntry, or within 15 minutes. */ - var endChecks = (userInput.data.end_ts <= entryEnd || - (userInput.data.end_ts - entryEnd) <= 15 * 60); - if (startChecks && !endChecks) { + var endChecks = + userInput.data.end_ts <= entryEnd || userInput.data.end_ts - entryEnd <= 15 * 60; + if (startChecks && !endChecks) { const nextEntryObj = tlEntry.getNextEntry(); if (nextEntryObj) { - const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; - if (!nextEntryEnd) { // the last place will not have an exit_ts - endChecks = true; // so we will just skip the end check - } else { - endChecks = userInput.data.end_ts <= nextEntryEnd; - Logger.log("Second level of end checks when the next trip is defined("+userInput.data.end_ts+" <= "+ nextEntryEnd+") = "+endChecks); - } + const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; + if (!nextEntryEnd) { + // the last place will not have an exit_ts + endChecks = true; // so we will just skip the end check + } else { + endChecks = userInput.data.end_ts <= nextEntryEnd; + Logger.log( + 'Second level of end checks when the next trip is defined(' + + userInput.data.end_ts + + ' <= ' + + nextEntryEnd + + ') = ' + + endChecks, + ); + } } else { - // next trip is not defined, last trip - endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) - Logger.log("Second level of end checks for the last trip of the day"); - Logger.log("compare "+userInput.data.end_local_dt.day + " with " + userInput.data.start_local_dt.day + " = " + endChecks); + // next trip is not defined, last trip + endChecks = userInput.data.end_local_dt.day == userInput.data.start_local_dt.day; + Logger.log('Second level of end checks for the last trip of the day'); + Logger.log( + 'compare ' + + userInput.data.end_local_dt.day + + ' with ' + + userInput.data.start_local_dt.day + + ' = ' + + endChecks, + ); } if (endChecks) { - // If we have flipped the values, check to see that there - // is sufficient overlap - const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) - Logger.log("Flipped endCheck, overlap("+overlapDuration+ - ")/trip("+tlEntry.duration+") = "+ (overlapDuration / tlEntry.duration)); - endChecks = (overlapDuration/tlEntry.duration) > 0.5; + // If we have flipped the values, check to see that there + // is sufficient overlap + const overlapDuration = + Math.min(userInput.data.end_ts, entryEnd) - + Math.max(userInput.data.start_ts, entryStart); + Logger.log( + 'Flipped endCheck, overlap(' + + overlapDuration + + ')/trip(' + + tlEntry.duration + + ') = ' + + overlapDuration / tlEntry.duration, + ); + endChecks = overlapDuration / tlEntry.duration > 0.5; } - } - return startChecks && endChecks; - } - - // parallels get_not_deleted_candidates() in trip_queries.py - const getNotDeletedCandidates = function(candidates) { - console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); - // We want to retain all ACTIVE entries that have not been DELETED - const allActiveList = candidates.filter(c => !c.data.status || c.data.status == 'ACTIVE'); - const allDeletedIds = candidates.filter(c => c.data.status && c.data.status == 'DELETED').map(c => c.data['match_id']); - const notDeletedActive = allActiveList.filter(c => !allDeletedIds.includes(c.data['match_id'])); - console.log(`Found ${allActiveList.length} active entries, + } + return startChecks && endChecks; + }; + + // parallels get_not_deleted_candidates() in trip_queries.py + const getNotDeletedCandidates = function (candidates) { + console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); + // We want to retain all ACTIVE entries that have not been DELETED + const allActiveList = candidates.filter((c) => !c.data.status || c.data.status == 'ACTIVE'); + const allDeletedIds = candidates + .filter((c) => c.data.status && c.data.status == 'DELETED') + .map((c) => c.data['match_id']); + const notDeletedActive = allActiveList.filter( + (c) => !allDeletedIds.includes(c.data['match_id']), + ); + console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> ${notDeletedActive.length} non deleted active entries`); - return notDeletedActive; - } + return notDeletedActive; + }; - im.getUserInputForTrip = function(trip, nextTrip, userInputList) { - const logsEnabled = userInputList.length < 20; + im.getUserInputForTrip = function (trip, nextTrip, userInputList) { + const logsEnabled = userInputList.length < 20; - if (userInputList === undefined) { - Logger.log("In getUserInputForTrip, no user input, returning undefined"); + if (userInputList === undefined) { + Logger.log('In getUserInputForTrip, no user input, returning undefined'); return undefined; - } - - if (logsEnabled) { - console.log("Input list = "+userInputList.map(printUserInput)); - } - // undefined != true, so this covers the label view case as well - var potentialCandidates = userInputList.filter((ui) => im.validUserInputForTimelineEntry(trip, ui, logsEnabled)); - if (potentialCandidates.length === 0) { + } + + if (logsEnabled) { + console.log('Input list = ' + userInputList.map(printUserInput)); + } + // undefined != true, so this covers the label view case as well + var potentialCandidates = userInputList.filter((ui) => + im.validUserInputForTimelineEntry(trip, ui, logsEnabled), + ); + if (potentialCandidates.length === 0) { if (logsEnabled) { - Logger.log("In getUserInputForTripStartEnd, no potential candidates, returning []"); + Logger.log('In getUserInputForTripStartEnd, no potential candidates, returning []'); } return undefined; - } + } - if (potentialCandidates.length === 1) { - Logger.log("In getUserInputForTripStartEnd, one potential candidate, returning "+ printUserInput(potentialCandidates[0])); + if (potentialCandidates.length === 1) { + Logger.log( + 'In getUserInputForTripStartEnd, one potential candidate, returning ' + + printUserInput(potentialCandidates[0]), + ); return potentialCandidates[0]; - } + } - Logger.log("potentialCandidates are "+potentialCandidates.map(printUserInput)); - var sortedPC = potentialCandidates.sort(function(pc1, pc2) { + Logger.log('potentialCandidates are ' + potentialCandidates.map(printUserInput)); + var sortedPC = potentialCandidates.sort(function (pc1, pc2) { return pc2.metadata.write_ts - pc1.metadata.write_ts; - }); - var mostRecentEntry = sortedPC[0]; - Logger.log("Returning mostRecentEntry "+printUserInput(mostRecentEntry)); - return mostRecentEntry; - } - - // return array of matching additions for a trip or place - im.getAdditionsForTimelineEntry = function(entry, additionsList) { - const logsEnabled = additionsList.length < 20; - - if (additionsList === undefined) { - Logger.log("In getAdditionsForTimelineEntry, no addition input, returning []"); - return []; - } - - // get additions that have not been deleted - // and filter out additions that do not start within the bounds of the timeline entry - const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => im.validUserInputForTimelineEntry(entry, ui, logsEnabled)); - - if (logsEnabled) { - console.log("Matching Addition list = "+matchingAdditions.map(printUserInput)); - } - return matchingAdditions; - } - - im.getUniqueEntries = function(combinedList) { - // we should not get any non-ACTIVE entries here - // since we have run filtering algorithms on both the phone and the server - const allDeleted = combinedList.filter(c => c.data.status && c.data.status == 'DELETED'); - if (allDeleted.length > 0) { - Logger.displayError("Found "+allDeletedEntries.length - +" non-ACTIVE addition entries while trying to dedup entries", - allDeletedEntries); - } - const uniqueMap = new Map(); - combinedList.forEach((e) => { + }); + var mostRecentEntry = sortedPC[0]; + Logger.log('Returning mostRecentEntry ' + printUserInput(mostRecentEntry)); + return mostRecentEntry; + }; + + // return array of matching additions for a trip or place + im.getAdditionsForTimelineEntry = function (entry, additionsList) { + const logsEnabled = additionsList.length < 20; + + if (additionsList === undefined) { + Logger.log('In getAdditionsForTimelineEntry, no addition input, returning []'); + return []; + } + + // get additions that have not been deleted + // and filter out additions that do not start within the bounds of the timeline entry + const notDeleted = getNotDeletedCandidates(additionsList); + const matchingAdditions = notDeleted.filter((ui) => + im.validUserInputForTimelineEntry(entry, ui, logsEnabled), + ); + + if (logsEnabled) { + console.log('Matching Addition list = ' + matchingAdditions.map(printUserInput)); + } + return matchingAdditions; + }; + + im.getUniqueEntries = function (combinedList) { + // we should not get any non-ACTIVE entries here + // since we have run filtering algorithms on both the phone and the server + const allDeleted = combinedList.filter((c) => c.data.status && c.data.status == 'DELETED'); + if (allDeleted.length > 0) { + Logger.displayError( + 'Found ' + + allDeletedEntries.length + + ' non-ACTIVE addition entries while trying to dedup entries', + allDeletedEntries, + ); + } + const uniqueMap = new Map(); + combinedList.forEach((e) => { const existingVal = uniqueMap.get(e.data.match_id); // if the existing entry and the input entry don't match // and they are both active, we have an error // let's notify the user for now if (existingVal) { - if ((existingVal.data.start_ts != e.data.start_ts) || - (existingVal.data.end_ts != e.data.end_ts) || - (existingVal.data.write_ts != e.data.write_ts)) { - Logger.displayError("Found two ACTIVE entries with the same match ID but different timestamps "+existingVal.data.match_id, - JSON.stringify(existingVal) + " vs. "+ JSON.stringify(e)); - } else { - console.log("Found two entries with match_id "+existingVal.data.match_id+" but they are identical"); - } + if ( + existingVal.data.start_ts != e.data.start_ts || + existingVal.data.end_ts != e.data.end_ts || + existingVal.data.write_ts != e.data.write_ts + ) { + Logger.displayError( + 'Found two ACTIVE entries with the same match ID but different timestamps ' + + existingVal.data.match_id, + JSON.stringify(existingVal) + ' vs. ' + JSON.stringify(e), + ); + } else { + console.log( + 'Found two entries with match_id ' + + existingVal.data.match_id + + ' but they are identical', + ); + } } else { - uniqueMap.set(e.data.match_id, e); + uniqueMap.set(e.data.match_id, e); } - }); - return Array.from(uniqueMap.values()); - } + }); + return Array.from(uniqueMap.values()); + }; - return im; -}); + return im; + }); diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index ca71721a7..36a350bd3 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -2,28 +2,38 @@ In the default configuration, these are the "Mode" and "Purpose" buttons. Next to the buttons is a small checkmark icon, which marks inferrel labels as confirmed */ -import React, { useContext, useEffect, useState, useMemo } from "react"; -import { getAngularService } from "../../angular-react-helper"; -import { View, Modal, ScrollView, Pressable, useWindowDimensions } from "react-native"; -import { IconButton, Text, Dialog, useTheme, RadioButton, Button, TextInput } from "react-native-paper"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import { LabelTabContext } from "../../diary/LabelTab"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import { getLabelInputDetails, getLabelInputs, readableLabelToKey } from "./confirmHelper"; +import React, { useContext, useEffect, useState, useMemo } from 'react'; +import { getAngularService } from '../../angular-react-helper'; +import { View, Modal, ScrollView, Pressable, useWindowDimensions } from 'react-native'; +import { + IconButton, + Text, + Dialog, + useTheme, + RadioButton, + Button, + TextInput, +} from 'react-native-paper'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import { LabelTabContext } from '../../diary/LabelTab'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import { getLabelInputDetails, getLabelInputs, readableLabelToKey } from './confirmHelper'; -const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { +const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const { colors } = useTheme(); const { t } = useTranslation(); const { repopulateTimelineEntry, labelOptions } = useContext(LabelTabContext); const { height: windowHeight } = useWindowDimensions(); // modal visible for which input type? (mode or purpose or replaced_mode, null if not visible) - const [ modalVisibleFor, setModalVisibleFor ] = useState<'MODE'|'PURPOSE'|'REPLACED_MODE'|null>(null); - const [otherLabel, setOtherLabel] = useState(null); + const [modalVisibleFor, setModalVisibleFor] = useState< + 'MODE' | 'PURPOSE' | 'REPLACED_MODE' | null + >(null); + const [otherLabel, setOtherLabel] = useState(null); const chosenLabel = useMemo(() => { if (otherLabel != null) return 'other'; - return trip.userInput[modalVisibleFor]?.value + return trip.userInput[modalVisibleFor]?.value; }, [modalVisibleFor, otherLabel]); // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue @@ -51,94 +61,116 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { } function store(inputType, chosenLabel, isOther) { - if (!chosenLabel) return displayErrorMsg("Label is empty"); + if (!chosenLabel) return displayErrorMsg('Label is empty'); if (isOther) { /* Let's make the value for user entered inputs look consistent with our other values (i.e. lowercase, and with underscores instead of spaces) */ chosenLabel = readableLabelToKey(chosenLabel); } const inputDataToStore = { - "start_ts": trip.start_ts, - "end_ts": trip.end_ts, - "label": chosenLabel, + start_ts: trip.start_ts, + end_ts: trip.end_ts, + label: chosenLabel, }; const storageKey = getLabelInputDetails()[inputType].key; window['cordova'].plugins.BEMUserCache.putMessage(storageKey, inputDataToStore).then(() => { dismiss(); repopulateTimelineEntry(trip._id.$oid); - logDebug("Successfully stored input data "+JSON.stringify(inputDataToStore)); + logDebug('Successfully stored input data ' + JSON.stringify(inputDataToStore)); }); } const inputKeys = Object.keys(trip.inputDetails); - return (<> - - - {inputKeys.map((key, i) => { - const input = trip.inputDetails[key]; - const inputIsConfirmed = trip.userInput[input.name]; - const inputIsInferred = trip.finalInference[input.name]; - let fillColor, textColor, borderColor; - if (inputIsConfirmed) { - fillColor = colors.primary; - } else if (inputIsInferred) { - fillColor = colors.secondaryContainer; - borderColor = colors.secondary; - textColor = colors.onSecondaryContainer; - } - const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; + return ( + <> + + + {inputKeys.map((key, i) => { + const input = trip.inputDetails[key]; + const inputIsConfirmed = trip.userInput[input.name]; + const inputIsInferred = trip.finalInference[input.name]; + let fillColor, textColor, borderColor; + if (inputIsConfirmed) { + fillColor = colors.primary; + } else if (inputIsInferred) { + fillColor = colors.secondaryContainer; + borderColor = colors.secondary; + textColor = colors.onSecondaryContainer; + } + const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; - return ( - - {t(input.labeltext)} - setModalVisibleFor(input.name)}> - { t(btnText) } - - - ) - })} - - {trip.verifiability === 'can-verify' && ( - - + return ( + + {t(input.labeltext)} + setModalVisibleFor(input.name)}> + {t(btnText)} + + + ); + })} - )} - - dismiss()}> - dismiss()}> - - - {(modalVisibleFor == 'MODE') && t('diary.select-mode-scroll') || - (modalVisibleFor == 'PURPOSE') && t('diary.select-purpose-scroll') || - (modalVisibleFor == 'REPLACED_MODE') && t('diary.select-replaced-mode-scroll')} - - - - onChooseLabel(val)} value={chosenLabel}> - {labelOptions?.[modalVisibleFor]?.map((o, i) => ( - // @ts-ignore - - ))} - - - - {otherLabel != null && <> - setOtherLabel(t)} /> - - - - } - - - - ); + {trip.verifiability === 'can-verify' && ( + + + + )} + + dismiss()}> + dismiss()}> + + + {(modalVisibleFor == 'MODE' && t('diary.select-mode-scroll')) || + (modalVisibleFor == 'PURPOSE' && t('diary.select-purpose-scroll')) || + (modalVisibleFor == 'REPLACED_MODE' && t('diary.select-replaced-mode-scroll'))} + + + + onChooseLabel(val)} value={chosenLabel}> + {labelOptions?.[modalVisibleFor]?.map((o, i) => ( + // @ts-ignore + + ))} + + + + {otherLabel != null && ( + <> + setOtherLabel(t)} + /> + + + + + )} + + + + + ); }; export default MultilabelButtonGroup; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index b668669bf..a8972709b 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -1,34 +1,36 @@ // may refactor this into a React hook once it's no longer used by any Angular screens -import { getAngularService } from "../../angular-react-helper"; -import { fetchUrlCached } from "../../commHelper"; -import i18next from "i18next"; -import { logDebug } from "../../plugin/logger"; +import { getAngularService } from '../../angular-react-helper'; +import { fetchUrlCached } from '../../commHelper'; +import i18next from 'i18next'; +import { logDebug } from '../../plugin/logger'; type InputDetails = { [k in T]?: { - name: string, - labeltext: string, - choosetext: string, - key: string, - } + name: string; + labeltext: string; + choosetext: string; + key: string; + }; }; -export type LabelOptions = { +export type LabelOptions = { [k in T]: { - value: string, - baseMode: string, - met?: {range: any[], mets: number} - met_equivalent?: string, - kgCo2PerKm: number, - text?: string, - }[] -} & { translations: { - [lang: string]: { [translationKey: string]: string } -}}; + value: string; + baseMode: string; + met?: { range: any[]; mets: number }; + met_equivalent?: string; + kgCo2PerKm: number; + text?: string; + }[]; +} & { + translations: { + [lang: string]: { [translationKey: string]: string }; + }; +}; let appConfig; -export let labelOptions: LabelOptions<'MODE'|'PURPOSE'|'REPLACED_MODE'>; -export let inputDetails: InputDetails<'MODE'|'PURPOSE'|'REPLACED_MODE'>; +export let labelOptions: LabelOptions<'MODE' | 'PURPOSE' | 'REPLACED_MODE'>; +export let inputDetails: InputDetails<'MODE' | 'PURPOSE' | 'REPLACED_MODE'>; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; @@ -36,11 +38,15 @@ export async function getLabelOptions(appConfigParam?) { if (appConfig.label_options) { const labelOptionsJson = await fetchUrlCached(appConfig.label_options); - logDebug("label_options found in config, using dynamic label options at " + appConfig.label_options); + logDebug( + 'label_options found in config, using dynamic label options at ' + appConfig.label_options, + ); labelOptions = JSON.parse(labelOptionsJson) as LabelOptions; } else { const defaultLabelOptionsURL = 'json/label-options.json.sample'; - logDebug("No label_options found in config, using default label options at " + defaultLabelOptionsURL); + logDebug( + 'No label_options found in config, using default label options at ' + defaultLabelOptionsURL, + ); const defaultLabelOptionsJson = await fetchUrlCached(defaultLabelOptionsURL); labelOptions = JSON.parse(defaultLabelOptionsJson) as LabelOptions; } @@ -51,7 +57,10 @@ export async function getLabelOptions(appConfigParam?) { labelOptions[opt]?.forEach?.((o, i) => { const translationKey = o.value; // If translation exists in labelOptions, use that. Otherwise, use the one in the i18next. If there is not "translations" field in labelOptions, defaultly use the one in the i18next. - const translation = labelOptions.translations ? labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`) : i18next.t(`multilabel.${translationKey}`); + const translation = labelOptions.translations + ? labelOptions.translations[lang][translationKey] || + i18next.t(`multilabel.${translationKey}`) + : i18next.t(`multilabel.${translationKey}`); labelOptions[opt][i].text = translation; }); } @@ -60,18 +69,18 @@ export async function getLabelOptions(appConfigParam?) { export const baseLabelInputDetails = { MODE: { - name: "MODE", - labeltext: "diary.mode", - choosetext: "diary.choose-mode", - key: "manual/mode_confirm", + name: 'MODE', + labeltext: 'diary.mode', + choosetext: 'diary.choose-mode', + key: 'manual/mode_confirm', }, PURPOSE: { - name: "PURPOSE", - labeltext: "diary.purpose", - choosetext: "diary.choose-purpose", - key: "manual/purpose_confirm", + name: 'PURPOSE', + labeltext: 'diary.purpose', + choosetext: 'diary.choose-purpose', + key: 'manual/purpose_confirm', }, -} +}; export function getLabelInputDetails(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; @@ -83,13 +92,14 @@ export function getLabelInputDetails(appConfigParam?) { return baseLabelInputDetails; } // else this is a program, so add the REPLACED_MODE - inputDetails = { ...baseLabelInputDetails, + inputDetails = { + ...baseLabelInputDetails, REPLACED_MODE: { - name: "REPLACED_MODE", - labeltext: "diary.replaces", - choosetext: "diary.choose-replaced-mode", - key: "manual/replaced_mode", - } + name: 'REPLACED_MODE', + labeltext: 'diary.replaces', + choosetext: 'diary.choose-replaced-mode', + key: 'manual/replaced_mode', + }, }; return inputDetails; } @@ -99,16 +109,14 @@ export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails); /** @description replace all underscores with spaces, and capitalizes the first letter of each word */ export const labelKeyToReadable = (otherValue: string) => { - const words = otherValue.replace(/_/g, " ").trim().split(" "); - if (words.length == 0) return ""; - return words.map((word) => - word[0].toUpperCase() + word.slice(1) - ).join(" "); -} + const words = otherValue.replace(/_/g, ' ').trim().split(' '); + if (words.length == 0) return ''; + return words.map((word) => word[0].toUpperCase() + word.slice(1)).join(' '); +}; /** @description replaces all spaces with underscores, and lowercases the string */ export const readableLabelToKey = (otherText: string) => - otherText.trim().replace(/ /g, "_").toLowerCase(); + otherText.trim().replace(/ /g, '_').toLowerCase(); export const getFakeEntry = (otherValue) => ({ text: labelKeyToReadable(otherValue), @@ -116,4 +124,4 @@ export const getFakeEntry = (otherValue) => ({ }); export const labelKeyToRichMode = (labelKey: string) => - labelOptions?.MODE?.find(m => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + labelOptions?.MODE?.find((m) => m.value == labelKey)?.text || labelKeyToReadable(labelKey); diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts index 8d71266d9..28d91d48d 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.ts +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -6,53 +6,52 @@ * All UI elements should only use $scope variables. */ -import i18next from "i18next"; +import i18next from 'i18next'; const unlabeledCheck = (t) => { - return t.INPUTS - .map((inputType, index) => !t.userInput[inputType]) - .reduce((acc, val) => acc || val, false); -} + return t.INPUTS.map((inputType, index) => !t.userInput[inputType]).reduce( + (acc, val) => acc || val, + false, + ); +}; const invalidCheck = (t) => { - const retVal = - (t.userInput['MODE'] && t.userInput['MODE'].value === 'pilot_ebike') && - (!t.userInput['REPLACED_MODE'] || - t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || - t.userInput['REPLACED_MODE'].value === 'same_mode'); - return retVal; -} + const retVal = + t.userInput['MODE'] && + t.userInput['MODE'].value === 'pilot_ebike' && + (!t.userInput['REPLACED_MODE'] || + t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || + t.userInput['REPLACED_MODE'].value === 'same_mode'); + return retVal; +}; const toLabelCheck = (trip) => { - if (trip.expectation) { - console.log(trip.expectation.to_label) - return trip.expectation.to_label && unlabeledCheck(trip); - } else { - return true; - } -} + if (trip.expectation) { + console.log(trip.expectation.to_label); + return trip.expectation.to_label && unlabeledCheck(trip); + } else { + return true; + } +}; const UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck, - width: "col-50" -} + key: 'unlabeled', + text: i18next.t('diary.unlabeled'), + filter: unlabeledCheck, + width: 'col-50', +}; const INVALID_EBIKE = { - key: "invalid_ebike", - text: i18next.t("diary.invalid-ebike"), - filter: invalidCheck -} + key: 'invalid_ebike', + text: i18next.t('diary.invalid-ebike'), + filter: invalidCheck, +}; const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: toLabelCheck, - width: "col-50" -} - -export const configuredFilters = [ - TO_LABEL, - UNLABELED -]; \ No newline at end of file + key: 'to_label', + text: i18next.t('diary.to-label'), + filter: toLabelCheck, + width: 'col-50', +}; + +export const configuredFilters = [TO_LABEL, UNLABELED]; diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index 7d1bc4007..c4a8c732c 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -1,205 +1,240 @@ import angular from 'angular'; -import { baseLabelInputDetails, getBaseLabelInputs, getFakeEntry, getLabelInputDetails, getLabelInputs, getLabelOptions } from './confirmHelper'; +import { + baseLabelInputDetails, + getBaseLabelInputs, + getFakeEntry, + getLabelInputDetails, + getLabelInputs, + getLabelOptions, +} from './confirmHelper'; import { getConfig } from '../../config/dynamicConfig'; -angular.module('emission.survey.multilabel.buttons', - ['emission.survey.inputmatcher']) - -.factory("MultiLabelService", function($rootScope, InputMatcher, $timeout, $ionicPlatform, Logger) { - var mls = {}; - console.log("Creating MultiLabelService"); - mls.init = function(config) { - Logger.log("About to initialize the MultiLabelService"); - mls.ui_config = config; - getLabelOptions(config).then((inputParams) => mls.inputParams = inputParams); - mls.MANUAL_KEYS = Object.values(getLabelInputDetails(config)).map((inp) => inp.key); - Logger.log("finished initializing the MultiLabelService"); - }; - - $ionicPlatform.ready().then(function() { - Logger.log("UI_CONFIG: about to call configReady function in MultiLabelService"); - getConfig().then((newConfig) => { - mls.init(newConfig); - }).catch((err) => Logger.displayError("Error while handling config in MultiLabelService", err)); - }); - - /** - * Embed 'inputType' to the trip. - */ - - mls.extractResult = (results) => results; - - mls.processManualInputs = function(manualResults, resultMap) { - var mrString = 'unprocessed manual inputs ' - + manualResults.map(function(item, index) { - return ` ${item.length} ${getLabelInputs()[index]}`; - }); - console.log(mrString); - manualResults.forEach(function(mr, index) { - resultMap[getLabelInputs()[index]] = mr; +angular + .module('emission.survey.multilabel.buttons', ['emission.survey.inputmatcher']) + + .factory( + 'MultiLabelService', + function ($rootScope, InputMatcher, $timeout, $ionicPlatform, Logger) { + var mls = {}; + console.log('Creating MultiLabelService'); + mls.init = function (config) { + Logger.log('About to initialize the MultiLabelService'); + mls.ui_config = config; + getLabelOptions(config).then((inputParams) => (mls.inputParams = inputParams)); + mls.MANUAL_KEYS = Object.values(getLabelInputDetails(config)).map((inp) => inp.key); + Logger.log('finished initializing the MultiLabelService'); + }; + + $ionicPlatform.ready().then(function () { + Logger.log('UI_CONFIG: about to call configReady function in MultiLabelService'); + getConfig() + .then((newConfig) => { + mls.init(newConfig); + }) + .catch((err) => + Logger.displayError('Error while handling config in MultiLabelService', err), + ); }); - } - - mls.populateInputsAndInferences = function(trip, manualResultMap) { - if (angular.isDefined(trip)) { - // console.log("Expectation: "+JSON.stringify(trip.expectation)); - // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); - trip.userInput = {}; - getLabelInputs().forEach(function(item, index) { - mls.populateManualInputs(trip, trip.nextTrip, item, - manualResultMap[item]); + + /** + * Embed 'inputType' to the trip. + */ + + mls.extractResult = (results) => results; + + mls.processManualInputs = function (manualResults, resultMap) { + var mrString = + 'unprocessed manual inputs ' + + manualResults.map(function (item, index) { + return ` ${item.length} ${getLabelInputs()[index]}`; + }); + console.log(mrString); + manualResults.forEach(function (mr, index) { + resultMap[getLabelInputs()[index]] = mr; }); - trip.finalInference = {}; - mls.inferFinalLabels(trip); - mls.expandInputsIfNecessary(trip); - mls.updateVerifiability(trip); - } else { - console.log("Trip information not yet bound, skipping fill"); - } - } - - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { - // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, - inputList); - var userInputLabel = unprocessedLabelEntry? unprocessedLabelEntry.data.label : undefined; - if (!angular.isDefined(userInputLabel)) { + }; + + mls.populateInputsAndInferences = function (trip, manualResultMap) { + if (angular.isDefined(trip)) { + // console.log("Expectation: "+JSON.stringify(trip.expectation)); + // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); + trip.userInput = {}; + getLabelInputs().forEach(function (item, index) { + mls.populateManualInputs(trip, trip.nextTrip, item, manualResultMap[item]); + }); + trip.finalInference = {}; + mls.inferFinalLabels(trip); + mls.expandInputsIfNecessary(trip); + mls.updateVerifiability(trip); + } else { + console.log('Trip information not yet bound, skipping fill'); + } + }; + + /** + * Embed 'inputType' to the trip + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { + // Check unprocessed labels first since they are more recent + const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, inputList); + var userInputLabel = unprocessedLabelEntry ? unprocessedLabelEntry.data.label : undefined; + if (!angular.isDefined(userInputLabel)) { userInputLabel = trip.user_input?.[mls.inputType2retKey(inputType)]; - } - mls.populateInput(trip.userInput, inputType, userInputLabel); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - mls.editingTrip = angular.undefined; - } - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - mls.populateInput = function(tripField, inputType, userInputLabel) { - if (angular.isDefined(userInputLabel)) { - console.log("populateInput: looking in map of "+inputType+" for userInputLabel"+userInputLabel); - var userInputEntry = mls.inputParams[inputType].find(o => o.value == userInputLabel); - if (!angular.isDefined(userInputEntry)) { - userInputEntry = getFakeEntry(userInputLabel); - mls.inputParams[inputType].push(userInputEntry); } - console.log("Mapped label "+userInputLabel+" to entry "+JSON.stringify(userInputEntry)); - tripField[inputType] = userInputEntry; - } - } - - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - mls.inferFinalLabels = function(trip) { - // Deep copy the possibility tuples - let labelsList = []; - if (angular.isDefined(trip.inferred_labels)) { - labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); - } - - // Capture the level of certainty so we can reconstruct it later - const totalCertainty = labelsList.map(item => item.p).reduce(((item, rest) => item + rest), 0); - - // Filter out the tuples that are inconsistent with existing green labels - for (const inputType of getLabelInputs()) { - const userInput = trip.userInput[inputType]; - if (userInput) { - const retKey = mls.inputType2retKey(inputType); - labelsList = labelsList.filter(item => item.labels[retKey] == userInput.value); - } - } - - // Red labels if we have no possibilities left - if (labelsList.length == 0) { - for (const inputType of getLabelInputs()) mls.populateInput(trip.finalInference, inputType, undefined); - } - else { - // Normalize probabilities to previous level of certainty - const certaintyScalar = totalCertainty/labelsList.map(item => item.p).reduce((item, rest) => item + rest); - labelsList.forEach(item => item.p*=certaintyScalar); - - for (const inputType of getLabelInputs()) { - // For each label type, find the most probable value by binning by label value and summing - const retKey = mls.inputType2retKey(inputType); - let valueProbs = new Map(); - for (const tuple of labelsList) { - const labelValue = tuple.labels[retKey]; - if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); - valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); + mls.populateInput(trip.userInput, inputType, userInputLabel); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + mls.editingTrip = angular.undefined; + }; + + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + mls.populateInput = function (tripField, inputType, userInputLabel) { + if (angular.isDefined(userInputLabel)) { + console.log( + 'populateInput: looking in map of ' + + inputType + + ' for userInputLabel' + + userInputLabel, + ); + var userInputEntry = mls.inputParams[inputType].find((o) => o.value == userInputLabel); + if (!angular.isDefined(userInputEntry)) { + userInputEntry = getFakeEntry(userInputLabel); + mls.inputParams[inputType].push(userInputEntry); + } + console.log( + 'Mapped label ' + userInputLabel + ' to entry ' + JSON.stringify(userInputEntry), + ); + tripField[inputType] = userInputEntry; } - let max = {p: 0, labelValue: undefined}; - for (const [thisLabelValue, thisP] of valueProbs) { - // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) - if (thisP > max.p) max = {p: thisP, labelValue: thisLabelValue}; + }; + + /** + * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. + * The algorithm below operationalizes these principles: + * - Never consider label tuples that contradict a green label + * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before + * - After filtering, predict the most likely choices at the level of individual labels, not label tuples + * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold + */ + mls.inferFinalLabels = function (trip) { + // Deep copy the possibility tuples + let labelsList = []; + if (angular.isDefined(trip.inferred_labels)) { + labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); } - // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold - // Fails safe if confidence_threshold doesn't exist - if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - - mls.populateInput(trip.finalInference, inputType, max.labelValue); - } - } - } - - /* - * Uses either 2 or 3 labels depending on the type of install (program vs. study) - * and the primary mode. - * This used to be in the controller, where it really should be, but we had - * to move it to the service because we need to invoke it from the list view - * as part of filtering "To Label" entries. - * - * TODO: Move it back later after the diary vs. label unification - */ - mls.expandInputsIfNecessary = function(trip) { - console.log("Reading expanding inputs for ", trip); - const inputValue = trip.userInput["MODE"]? trip.userInput["MODE"].value : undefined; - console.log("Experimenting with expanding inputs for mode "+inputValue); - if (mls.ui_config.intro.mode_studied) { - if (inputValue == mls.ui_config.intro.mode_studied) { - Logger.log("Found "+mls.ui_config.intro.mode_studied+" mode in a program, displaying full details"); + // Capture the level of certainty so we can reconstruct it later + const totalCertainty = labelsList + .map((item) => item.p) + .reduce((item, rest) => item + rest, 0); + + // Filter out the tuples that are inconsistent with existing green labels + for (const inputType of getLabelInputs()) { + const userInput = trip.userInput[inputType]; + if (userInput) { + const retKey = mls.inputType2retKey(inputType); + labelsList = labelsList.filter((item) => item.labels[retKey] == userInput.value); + } + } + + // Red labels if we have no possibilities left + if (labelsList.length == 0) { + for (const inputType of getLabelInputs()) + mls.populateInput(trip.finalInference, inputType, undefined); + } else { + // Normalize probabilities to previous level of certainty + const certaintyScalar = + totalCertainty / labelsList.map((item) => item.p).reduce((item, rest) => item + rest); + labelsList.forEach((item) => (item.p *= certaintyScalar)); + + for (const inputType of getLabelInputs()) { + // For each label type, find the most probable value by binning by label value and summing + const retKey = mls.inputType2retKey(inputType); + let valueProbs = new Map(); + for (const tuple of labelsList) { + const labelValue = tuple.labels[retKey]; + if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); + valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); + } + let max = { p: 0, labelValue: undefined }; + for (const [thisLabelValue, thisP] of valueProbs) { + // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) + if (thisP > max.p) max = { p: thisP, labelValue: thisLabelValue }; + } + + // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold + // Fails safe if confidence_threshold doesn't exist + if (max.p <= trip.confidence_threshold) max.labelValue = undefined; + + mls.populateInput(trip.finalInference, inputType, max.labelValue); + } + } + }; + + /* + * Uses either 2 or 3 labels depending on the type of install (program vs. study) + * and the primary mode. + * This used to be in the controller, where it really should be, but we had + * to move it to the service because we need to invoke it from the list view + * as part of filtering "To Label" entries. + * + * TODO: Move it back later after the diary vs. label unification + */ + mls.expandInputsIfNecessary = function (trip) { + console.log('Reading expanding inputs for ', trip); + const inputValue = trip.userInput['MODE'] ? trip.userInput['MODE'].value : undefined; + console.log('Experimenting with expanding inputs for mode ' + inputValue); + if (mls.ui_config.intro.mode_studied) { + if (inputValue == mls.ui_config.intro.mode_studied) { + Logger.log( + 'Found ' + + mls.ui_config.intro.mode_studied + + ' mode in a program, displaying full details', + ); trip.inputDetails = getLabelInputDetails(); trip.INPUTS = getLabelInputs(); - } else { - Logger.log("Found non "+mls.ui_config.intro.mode_studied+" mode in a program, displaying base details"); + } else { + Logger.log( + 'Found non ' + + mls.ui_config.intro.mode_studied + + ' mode in a program, displaying base details', + ); trip.inputDetails = baseLabelInputDetails; trip.INPUTS = getBaseLabelInputs(); + } + } else { + Logger.log('study, not program, displaying full details'); + trip.INPUTS = getLabelInputs(); + trip.inputDetails = getLabelInputDetails(); + } + }; + + /** + * MODE (becomes manual/mode_confirm) becomes mode_confirm + */ + mls.inputType2retKey = function (inputType) { + return getLabelInputDetails()[inputType].key.split('/')[1]; + }; + + mls.updateVerifiability = function (trip) { + var allGreen = true; + var someYellow = false; + for (const inputType of trip.INPUTS) { + const green = trip.userInput[inputType]; + const yellow = trip.finalInference[inputType] && !green; + if (yellow) someYellow = true; + if (!green) allGreen = false; } - } else { - Logger.log("study, not program, displaying full details"); - trip.INPUTS = getLabelInputs(); - trip.inputDetails = getLabelInputDetails(); - } - } - - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - mls.inputType2retKey = function(inputType) { - return getLabelInputDetails()[inputType].key.split("/")[1]; - } - - mls.updateVerifiability = function(trip) { - var allGreen = true; - var someYellow = false; - for (const inputType of trip.INPUTS) { - const green = trip.userInput[inputType]; - const yellow = trip.finalInference[inputType] && !green; - if (yellow) someYellow = true; - if (!green) allGreen = false; - } - trip.verifiability = someYellow ? "can-verify" : (allGreen ? "already-verified" : "cannot-verify"); - } - - return mls; -}); + trip.verifiability = someYellow + ? 'can-verify' + : allGreen + ? 'already-verified' + : 'cannot-verify'; + }; + + return mls; + }, + ); diff --git a/www/js/survey/survey.ts b/www/js/survey/survey.ts index 66f662082..a12e65713 100644 --- a/www/js/survey/survey.ts +++ b/www/js/survey/survey.ts @@ -1,16 +1,16 @@ -import { configuredFilters as multilabelConfiguredFilters } from "./multilabel/infinite_scroll_filters"; -import { configuredFilters as enketoConfiguredFilters } from "./enketo/infinite_scroll_filters"; +import { configuredFilters as multilabelConfiguredFilters } from './multilabel/infinite_scroll_filters'; +import { configuredFilters as enketoConfiguredFilters } from './enketo/infinite_scroll_filters'; -type SurveyOption = { filter: Array, service: string, elementTag: string } -export const SurveyOptions: {[key: string]: SurveyOption} = { +type SurveyOption = { filter: Array; service: string; elementTag: string }; +export const SurveyOptions: { [key: string]: SurveyOption } = { MULTILABEL: { filter: multilabelConfiguredFilters, - service: "MultiLabelService", - elementTag: "multilabel" + service: 'MultiLabelService', + elementTag: 'multilabel', }, ENKETO: { filter: enketoConfiguredFilters, - service: "EnketoTripButtonService", - elementTag: "enketo-trip-button" - } -} + service: 'EnketoTripButtonService', + elementTag: 'enketo-trip-button', + }, +}; diff --git a/www/js/useAppConfig.ts b/www/js/useAppConfig.ts index 633069326..96d1a56cb 100644 --- a/www/js/useAppConfig.ts +++ b/www/js/useAppConfig.ts @@ -1,10 +1,9 @@ -import { useEffect, useState } from "react"; -import { getAngularService } from "./angular-react-helper" -import { configChanged, getConfig, setConfigChanged } from "./config/dynamicConfig"; -import { logDebug } from "./plugin/logger"; +import { useEffect, useState } from 'react'; +import { getAngularService } from './angular-react-helper'; +import { configChanged, getConfig, setConfigChanged } from './config/dynamicConfig'; +import { logDebug } from './plugin/logger'; const useAppConfig = () => { - const [appConfig, setAppConfig] = useState(null); const $ionicPlatform = getAngularService('$ionicPlatform'); @@ -27,6 +26,6 @@ const useAppConfig = () => { updateConfig().then(() => setConfigChanged(false)); } return appConfig; -} +}; export default useAppConfig; diff --git a/www/js/useAppStateChange.ts b/www/js/useAppStateChange.ts index 8b9c6497c..470eb67a6 100644 --- a/www/js/useAppStateChange.ts +++ b/www/js/useAppStateChange.ts @@ -7,23 +7,20 @@ import { useEffect, useRef } from 'react'; import { AppState } from 'react-native'; const useAppStateChange = (onResume) => { + const appState = useRef(AppState.currentState); - const appState = useRef(AppState.currentState); - - useEffect(() => { - const subscription = AppState.addEventListener('change', nextAppState => { - if ( appState.current != 'active' && nextAppState === 'active') { - onResume(); - } - - appState.current = nextAppState; - console.log('AppState', appState.current); - }); - - }, []); - - return {}; - } - - export default useAppStateChange; - \ No newline at end of file + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (appState.current != 'active' && nextAppState === 'active') { + onResume(); + } + + appState.current = nextAppState; + console.log('AppState', appState.current); + }); + }, []); + + return {}; +}; + +export default useAppStateChange; diff --git a/www/js/usePermissionStatus.ts b/www/js/usePermissionStatus.ts index 035ba6b16..1bef38c44 100644 --- a/www/js/usePermissionStatus.ts +++ b/www/js/usePermissionStatus.ts @@ -1,352 +1,434 @@ import { useEffect, useState, useMemo } from 'react'; -import useAppStateChange from "./useAppStateChange"; -import useAppConfig from "./useAppConfig"; +import useAppStateChange from './useAppStateChange'; +import useAppConfig from './useAppConfig'; import { useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; //refreshing checks with the plugins to update the check's statusState export function refreshAllChecks(checkList) { - //refresh each check - checkList.forEach((lc) => { - lc.refresh(); - }); - console.log("setting checks are", checkList); + //refresh each check + checkList.forEach((lc) => { + lc.refresh(); + }); + console.log('setting checks are', checkList); } const usePermissionStatus = () => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const appConfig = useAppConfig(); + + const [error, setError] = useState(''); + const [errorVis, setErrorVis] = useState(false); - const { t } = useTranslation(); - const { colors } = useTheme(); - const appConfig = useAppConfig(); + const [checkList, setCheckList] = useState([]); + const [explanationList, setExplanationList] = useState>([]); + const [haveSetText, setHaveSetText] = useState(false); - const [error, setError] = useState(""); - const [errorVis, setErrorVis] = useState(false); + let iconMap = (statusState) => (statusState ? 'check-circle-outline' : 'alpha-x-circle-outline'); + let colorMap = (statusState) => (statusState ? colors.success : colors.danger); - const [checkList, setCheckList] = useState([]); - const [explanationList, setExplanationList] = useState>([]); - const [haveSetText, setHaveSetText] = useState(false); + const overallStatus = useMemo(() => { + let status = true; + if (!checkList?.length) return undefined; // if checks not loaded yet, status is undetermined + checkList.forEach((lc) => { + console.debug('check in permission status for ' + lc.name + ':', lc.statusState); + if (lc.statusState === false) { + status = false; + } + }); + return status; + }, [checkList]); - let iconMap = (statusState) => statusState ? "check-circle-outline" : "alpha-x-circle-outline"; - let colorMap = (statusState) => statusState ? colors.success : colors.danger; + //using this function to update checks rather than mutate + //this cues React to update UI + function updateCheck(newObject) { + var tempList = [...checkList]; //make a copy rather than mutate + //update the visiblility pieces here, rather than mutating + newObject.statusIcon = iconMap(newObject.statusState); + newObject.statusColor = colorMap(newObject.statusState); + //"find and replace" the check + tempList.forEach((item, i) => { + if (item.name == newObject.name) { + tempList[i] = newObject; + } + }); + setCheckList(tempList); + } - const overallStatus = useMemo(() => { - let status = true; - if (!checkList?.length) return undefined; // if checks not loaded yet, status is undetermined - checkList.forEach((lc) => { - console.debug('check in permission status for ' + lc.name + ':', lc.statusState); - if (lc.statusState === false) { - status = false; - } - }) + async function checkOrFix(checkObj, nativeFn, showError = true) { + console.log('checking object', checkObj.name, checkObj); + let newCheck = checkObj; + return nativeFn() + .then((status) => { + console.log('availability ', status); + newCheck.statusState = true; + updateCheck(newCheck); + console.log('after checking object', checkObj.name, checkList); return status; - }, [checkList]) + }) + .catch((error) => { + console.log('Error', error); + if (showError) { + console.log('please fix again'); + setError(error); + setErrorVis(true); + } + newCheck.statusState = false; + updateCheck(newCheck); + console.log('after checking object', checkObj.name, checkList); + return error; + }); + } - //using this function to update checks rather than mutate - //this cues React to update UI - function updateCheck(newObject) { - var tempList = [...checkList]; //make a copy rather than mutate - //update the visiblility pieces here, rather than mutating - newObject.statusIcon = iconMap(newObject.statusState); - newObject.statusColor = colorMap(newObject.statusState); - //"find and replace" the check - tempList.forEach((item, i) => { - if(item.name == newObject.name){ - tempList[i] = newObject; - } - }); - setCheckList(tempList); + function setupAndroidLocChecks() { + let fixSettings = function () { + console.log('Fix and refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationSettings, + true, + ); + }; + let checkSettings = function () { + console.log('Refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, + false, + ); + }; + let fixPerms = function () { + console.log('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, + true, + ).then((error) => { + if (error) { + locPermissionsCheck.desc = error; + } + }); + }; + let checkPerms = function () { + console.log('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, + false, + ); + }; + var androidSettingsDescTag = 'intro.appstatus.locsettings.description.android-gte-9'; + if (window['device'].version.split('.')[0] < 9) { + androidSettingsDescTag = 'intro.appstatus.locsettings.description.android-lt-9'; } - - async function checkOrFix(checkObj, nativeFn, showError=true) { - console.log("checking object", checkObj.name, checkObj); - let newCheck = checkObj; - return nativeFn() - .then((status) => { - console.log("availability ", status) - newCheck.statusState = true; - updateCheck(newCheck); - console.log("after checking object", checkObj.name, checkList); - return status; - }).catch((error) => { - console.log("Error", error) - if (showError) { - console.log("please fix again"); - setError(error); - setErrorVis(true); - }; - newCheck.statusState = false; - updateCheck(newCheck); - console.log("after checking object", checkObj.name, checkList); - return error; - }); + var androidPermDescTag = 'intro.appstatus.locperms.description.android-gte-12'; + if (window['device'].version.split('.')[0] < 6) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6'; + } else if (window['device'].version.split('.')[0] < 10) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-6-9'; + } else if (window['device'].version.split('.')[0] < 11) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-10'; + } else if (window['device'].version.split('.')[0] < 12) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-11'; } + console.log('description tags are ' + androidSettingsDescTag + ' ' + androidPermDescTag); + // location settings + let locSettingsCheck = { + name: t('intro.appstatus.locsettings.name'), + desc: t(androidSettingsDescTag), + fix: fixSettings, + refresh: checkSettings, + }; + let locPermissionsCheck = { + name: t('intro.appstatus.locperms.name'), + desc: t(androidPermDescTag), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(locSettingsCheck, locPermissionsCheck); + setCheckList(tempChecks); + } - function setupAndroidLocChecks() { - let fixSettings = function() { - console.log("Fix and refresh location settings"); - return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationSettings, true); - }; - let checkSettings = function() { - console.log("Refresh location settings"); - return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, false); - }; - let fixPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, - true).then((error) => {if(error){locPermissionsCheck.desc = error}}); - }; - let checkPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, false); - }; - var androidSettingsDescTag = "intro.appstatus.locsettings.description.android-gte-9"; - if (window['device'].version.split(".")[0] < 9) { - androidSettingsDescTag = "intro.appstatus.locsettings.description.android-lt-9"; - } - var androidPermDescTag = "intro.appstatus.locperms.description.android-gte-12"; - if(window['device'].version.split(".")[0] < 6) { - androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6'; - } else if (window['device'].version.split(".")[0] < 10) { - androidPermDescTag = "intro.appstatus.locperms.description.android-6-9"; - } else if (window['device'].version.split(".")[0] < 11) { - androidPermDescTag= "intro.appstatus.locperms.description.android-10"; - } else if (window['device'].version.split(".")[0] < 12) { - androidPermDescTag= "intro.appstatus.locperms.description.android-11"; - } - console.log("description tags are "+androidSettingsDescTag+" "+androidPermDescTag); - // location settings - let locSettingsCheck = { - name: t("intro.appstatus.locsettings.name"), - desc: t(androidSettingsDescTag), - fix: fixSettings, - refresh: checkSettings + function setupIOSLocChecks() { + let fixSettings = function () { + console.log('Fix and refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationSettings, + true, + ); + }; + let checkSettings = function () { + console.log('Refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, + false, + ); + }; + let fixPerms = function () { + console.log('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, + true, + ).then((error) => { + if (error) { + locPermissionsCheck.desc = error; } - let locPermissionsCheck = { - name: t("intro.appstatus.locperms.name"), - desc: t(androidPermDescTag), - fix: fixPerms, - refresh: checkPerms - } - let tempChecks = checkList; - tempChecks.push(locSettingsCheck, locPermissionsCheck); - setCheckList(tempChecks); + }); + }; + let checkPerms = function () { + console.log('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, + false, + ); + }; + var iOSSettingsDescTag = 'intro.appstatus.locsettings.description.ios'; + var iOSPermDescTag = 'intro.appstatus.locperms.description.ios-gte-13'; + if (window['device'].version.split('.')[0] < 13) { + iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13'; } + console.log('description tags are ' + iOSSettingsDescTag + ' ' + iOSPermDescTag); - function setupIOSLocChecks() { - let fixSettings = function() { - console.log("Fix and refresh location settings"); - return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationSettings, - true); - }; - let checkSettings = function() { - console.log("Refresh location settings"); - return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, - false); - }; - let fixPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, - true).then((error) => {if(error){locPermissionsCheck.desc = error}}); - }; - let checkPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, - false); - }; - var iOSSettingsDescTag = "intro.appstatus.locsettings.description.ios"; - var iOSPermDescTag = "intro.appstatus.locperms.description.ios-gte-13"; - if(window['device'].version.split(".")[0] < 13) { - iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13'; - } - console.log("description tags are "+iOSSettingsDescTag+" "+iOSPermDescTag); + const locSettingsCheck = { + name: t('intro.appstatus.locsettings.name'), + desc: t(iOSSettingsDescTag), + fix: fixSettings, + refresh: checkSettings, + }; + const locPermissionsCheck = { + name: t('intro.appstatus.locperms.name'), + desc: t(iOSPermDescTag), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(locSettingsCheck, locPermissionsCheck); + setCheckList(tempChecks); + } - const locSettingsCheck = { - name: t("intro.appstatus.locsettings.name"), - desc: t(iOSSettingsDescTag), - fix: fixSettings, - refresh: checkSettings - }; - const locPermissionsCheck = { - name: t("intro.appstatus.locperms.name"), - desc: t(iOSPermDescTag), - fix: fixPerms, - refresh: checkPerms - }; - let tempChecks = checkList; - tempChecks.push(locSettingsCheck, locPermissionsCheck); - setCheckList(tempChecks); - } + function setupAndroidFitnessChecks() { + if (window['device'].version.split('.')[0] >= 10) { + let fixPerms = function () { + console.log('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, + true, + ).then((error) => { + if (error) { + fitnessPermissionsCheck.desc = error; + } + }); + }; + let checkPerms = function () { + console.log('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, + false, + ); + }; - function setupAndroidFitnessChecks() { - if(window['device'].version.split(".")[0] >= 10){ - let fixPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, - true).then((error) => {if(error){fitnessPermissionsCheck.desc = error}}); - }; - let checkPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, - false); - }; - - let fitnessPermissionsCheck = { - name: t("intro.appstatus.fitnessperms.name"), - desc: t("intro.appstatus.fitnessperms.description.android"), - fix: fixPerms, - refresh: checkPerms - } - let tempChecks = checkList; - tempChecks.push(fitnessPermissionsCheck); - setCheckList(tempChecks); - } + let fitnessPermissionsCheck = { + name: t('intro.appstatus.fitnessperms.name'), + desc: t('intro.appstatus.fitnessperms.description.android'), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(fitnessPermissionsCheck); + setCheckList(tempChecks); } + } - function setupIOSFitnessChecks() { - let fixPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, - true).then((error) => {if(error){fitnessPermissionsCheck.desc = error}}); - }; - let checkPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, - false); - }; - - let fitnessPermissionsCheck = { - name: t("intro.appstatus.fitnessperms.name"), - desc: t("intro.appstatus.fitnessperms.description.ios"), - fix: fixPerms, - refresh: checkPerms + function setupIOSFitnessChecks() { + let fixPerms = function () { + console.log('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, + true, + ).then((error) => { + if (error) { + fitnessPermissionsCheck.desc = error; } - let tempChecks = checkList; - tempChecks.push(fitnessPermissionsCheck); - setCheckList(tempChecks); - } + }); + }; + let checkPerms = function () { + console.log('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, + false, + ); + }; - function setupAndroidNotificationChecks() { - let fixPerms = function() { - console.log("fix and refresh notification permissions"); - return checkOrFix(appAndChannelNotificationsCheck, window['cordova'].plugins.BEMDataCollection.fixShowNotifications, - true); - }; - let checkPerms = function() { - console.log("fix and refresh notification permissions"); - return checkOrFix(appAndChannelNotificationsCheck, window['cordova'].plugins.BEMDataCollection.isValidShowNotifications, - false); - }; - let appAndChannelNotificationsCheck = { - name: t("intro.appstatus.notificationperms.app-enabled-name"), - desc: t("intro.appstatus.notificationperms.description.android-enable"), - fix: fixPerms, - refresh: checkPerms - } - let tempChecks = checkList; - tempChecks.push(appAndChannelNotificationsCheck); - setCheckList(tempChecks); + let fitnessPermissionsCheck = { + name: t('intro.appstatus.fitnessperms.name'), + desc: t('intro.appstatus.fitnessperms.description.ios'), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(fitnessPermissionsCheck); + setCheckList(tempChecks); + } + + function setupAndroidNotificationChecks() { + let fixPerms = function () { + console.log('fix and refresh notification permissions'); + return checkOrFix( + appAndChannelNotificationsCheck, + window['cordova'].plugins.BEMDataCollection.fixShowNotifications, + true, + ); + }; + let checkPerms = function () { + console.log('fix and refresh notification permissions'); + return checkOrFix( + appAndChannelNotificationsCheck, + window['cordova'].plugins.BEMDataCollection.isValidShowNotifications, + false, + ); + }; + let appAndChannelNotificationsCheck = { + name: t('intro.appstatus.notificationperms.app-enabled-name'), + desc: t('intro.appstatus.notificationperms.description.android-enable'), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(appAndChannelNotificationsCheck); + setCheckList(tempChecks); + } + + function setupAndroidBackgroundRestrictionChecks() { + let fixPerms = function () { + console.log('fix and refresh backgroundRestriction permissions'); + return checkOrFix( + unusedAppsUnrestrictedCheck, + window['cordova'].plugins.BEMDataCollection.fixUnusedAppRestrictions, + true, + ); + }; + let checkPerms = function () { + console.log('fix and refresh backgroundRestriction permissions'); + return checkOrFix( + unusedAppsUnrestrictedCheck, + window['cordova'].plugins.BEMDataCollection.isUnusedAppUnrestricted, + false, + ); + }; + let fixBatteryOpt = function () { + console.log('fix and refresh battery optimization permissions'); + return checkOrFix( + ignoreBatteryOptCheck, + window['cordova'].plugins.BEMDataCollection.fixIgnoreBatteryOptimizations, + true, + ); + }; + let checkBatteryOpt = function () { + console.log('fix and refresh battery optimization permissions'); + return checkOrFix( + ignoreBatteryOptCheck, + window['cordova'].plugins.BEMDataCollection.isIgnoreBatteryOptimizations, + false, + ); + }; + var androidUnusedDescTag = + 'intro.appstatus.unusedapprestrict.description.android-disable-gte-13'; + if (window['device'].version.split('.')[0] == 12) { + androidUnusedDescTag = 'intro.appstatus.unusedapprestrict.description.android-disable-12'; + } else if (window['device'].version.split('.')[0] < 12) { + androidUnusedDescTag = 'intro.appstatus.unusedapprestrict.description.android-disable-lt-12'; } + let unusedAppsUnrestrictedCheck = { + name: t('intro.appstatus.unusedapprestrict.name'), + desc: t(androidUnusedDescTag), + fix: fixPerms, + refresh: checkPerms, + }; + let ignoreBatteryOptCheck = { + name: t('intro.appstatus.ignorebatteryopt.name'), + desc: t('intro.appstatus.ignorebatteryopt.description'), + fix: fixBatteryOpt, + refresh: checkBatteryOpt, + }; + let tempChecks = checkList; + tempChecks.push(unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck); + setCheckList(tempChecks); + } - function setupAndroidBackgroundRestrictionChecks() { - let fixPerms = function() { - console.log("fix and refresh backgroundRestriction permissions"); - return checkOrFix(unusedAppsUnrestrictedCheck, window['cordova'].plugins.BEMDataCollection.fixUnusedAppRestrictions, - true); - }; - let checkPerms = function() { - console.log("fix and refresh backgroundRestriction permissions"); - return checkOrFix(unusedAppsUnrestrictedCheck, window['cordova'].plugins.BEMDataCollection.isUnusedAppUnrestricted, - false); - }; - let fixBatteryOpt = function() { - console.log("fix and refresh battery optimization permissions"); - return checkOrFix(ignoreBatteryOptCheck, window['cordova'].plugins.BEMDataCollection.fixIgnoreBatteryOptimizations, - true); - }; - let checkBatteryOpt = function() { - console.log("fix and refresh battery optimization permissions"); - return checkOrFix(ignoreBatteryOptCheck, window['cordova'].plugins.BEMDataCollection.isIgnoreBatteryOptimizations, - false); - }; - var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-13"; - if (window['device'].version.split(".")[0] == 12) { - androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-12"; - } - else if (window['device'].version.split(".")[0] < 12) { - androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-lt-12"; - } - let unusedAppsUnrestrictedCheck = { - name: t("intro.appstatus.unusedapprestrict.name"), - desc: t(androidUnusedDescTag), - fix: fixPerms, - refresh: checkPerms - } - let ignoreBatteryOptCheck = { - name: t("intro.appstatus.ignorebatteryopt.name"), - desc: t("intro.appstatus.ignorebatteryopt.description"), - fix: fixBatteryOpt, - refresh: checkBatteryOpt - } - let tempChecks = checkList; - tempChecks.push(unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck); - setCheckList(tempChecks); + function setupPermissionText() { + let tempExplanations = explanationList; + + let overallFitnessName = t('intro.appstatus.overall-fitness-name-android'); + let locExplanation = t('intro.appstatus.overall-loc-description'); + if (window['device'].platform.toLowerCase() == 'ios') { + overallFitnessName = t('intro.appstatus.overall-fitness-name-ios'); } + tempExplanations.push({ name: t('intro.appstatus.overall-loc-name'), desc: locExplanation }); + tempExplanations.push({ + name: overallFitnessName, + desc: t('intro.appstatus.overall-fitness-description'), + }); + tempExplanations.push({ + name: t('intro.appstatus.overall-notification-name'), + desc: t('intro.appstatus.overall-notification-description'), + }); + tempExplanations.push({ + name: t('intro.appstatus.overall-background-restrictions-name'), + desc: t('intro.appstatus.overall-background-restrictions-description'), + }); - function setupPermissionText() { - let tempExplanations = explanationList; + setExplanationList(tempExplanations); - let overallFitnessName = t('intro.appstatus.overall-fitness-name-android'); - let locExplanation = t('intro.appstatus.overall-loc-description'); - if(window['device'].platform.toLowerCase() == "ios") { - overallFitnessName = t('intro.appstatus.overall-fitness-name-ios'); - } - tempExplanations.push({name: t('intro.appstatus.overall-loc-name'), desc: locExplanation}); - tempExplanations.push({name: overallFitnessName, desc: t('intro.appstatus.overall-fitness-description')}); - tempExplanations.push({name: t('intro.appstatus.overall-notification-name'), desc: t('intro.appstatus.overall-notification-description')}); - tempExplanations.push({name: t('intro.appstatus.overall-background-restrictions-name'), desc: t('intro.appstatus.overall-background-restrictions-description')}); + //TODO - update samsung handling based on feedback - setExplanationList(tempExplanations); - - //TODO - update samsung handling based on feedback + console.log('Explanation = ' + explanationList); + } - console.log("Explanation = "+explanationList); + function createChecklist() { + if (window['device'].platform.toLowerCase() == 'android') { + setupAndroidLocChecks(); + setupAndroidFitnessChecks(); + setupAndroidNotificationChecks(); + setupAndroidBackgroundRestrictionChecks(); + } else if (window['device'].platform.toLowerCase() == 'ios') { + setupIOSLocChecks(); + setupIOSFitnessChecks(); + setupAndroidNotificationChecks(); + } else { + setError('Alert! unknownplatform, no tracking'); + setErrorVis(true); + console.log('Alert! unknownplatform, no tracking'); //need an alert, can use AlertBar? } - function createChecklist(){ - if(window['device'].platform.toLowerCase() == "android") { - setupAndroidLocChecks(); - setupAndroidFitnessChecks(); - setupAndroidNotificationChecks(); - setupAndroidBackgroundRestrictionChecks(); - } else if (window['device'].platform.toLowerCase() == "ios") { - setupIOSLocChecks(); - setupIOSFitnessChecks(); - setupAndroidNotificationChecks(); - } else { - setError("Alert! unknownplatform, no tracking"); - setErrorVis(true); - console.log("Alert! unknownplatform, no tracking"); //need an alert, can use AlertBar? - } - - refreshAllChecks(checkList); + refreshAllChecks(checkList); + } + + useAppStateChange(function () { + console.log('PERMISSION CHECK: app has resumed, should refresh'); + refreshAllChecks(checkList); + }); + + //load when ready + useEffect(() => { + if (appConfig && window['device']?.platform) { + setupPermissionText(); + setHaveSetText(true); + console.log('setting up permissions'); + createChecklist(); } + }, [appConfig]); - useAppStateChange( function() { - console.log("PERMISSION CHECK: app has resumed, should refresh"); - refreshAllChecks(checkList); - }); + return { checkList, overallStatus, error, errorVis, setErrorVis, explanationList }; +}; - //load when ready - useEffect(() => { - if (appConfig && window['device']?.platform) { - setupPermissionText(); - setHaveSetText(true); - console.log("setting up permissions"); - createChecklist(); - } - }, [appConfig]); - - return {checkList, overallStatus, error, errorVis, setErrorVis, explanationList}; - } - - export default usePermissionStatus; +export default usePermissionStatus;